From 63edc987ad6764ee8e0cbf40bf44584a16a46abd Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 21:55:16 -0400 Subject: [PATCH 001/295] docs: ADR 041 - own the terminal layer on official libghostty-vt Record the staged plan to vendor ghostty-web's TypeScript into webtty and move it onto Ghostty's official, unpatched libghostty-vt wasm, with the spike evidence: cell-for-cell parity on real PTY captures, frame decode cost (8.8 ms per frame today, 0.2 ms with one viewport read), scrollback far below the configured size, and the wasm pin. Decides steps 1 and 2; steps 3, 4 and 6 get their own ADRs. --- docs/adrs/041.client.libghostty-vt.md | 120 ++++++++++++++++++++++++++ docs/specs/client.md | 15 ++++ docs/specs/config.md | 5 +- 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 docs/adrs/041.client.libghostty-vt.md diff --git a/docs/adrs/041.client.libghostty-vt.md b/docs/adrs/041.client.libghostty-vt.md new file mode 100644 index 0000000..28d4040 --- /dev/null +++ b/docs/adrs/041.client.libghostty-vt.md @@ -0,0 +1,120 @@ +# ADR 041: client — Own the terminal layer on official libghostty-vt + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-12 + +--- + +## Context + +The client's terminal is the npm package `ghostty-web` (pinned at `0.4.0-next.20.g1858a59`): Coder's TypeScript terminal (renderer, input, selection, addons) over a `ghostty-vt.wasm` built from Ghostty `5714ed07` (2025-12-01) plus Coder's `ghostty-wasm-api.patch`. That base has stopped moving with Ghostty: + +- **Upstream is slow and the wasm is frozen.** ghostty-web's last commit is from 2026-06-28, and its wasm is still the patched December 2025 Ghostty. Fixes webtty needs wait on releases that may not come. +- **webtty works around it from outside.** ADR 039 sets the renderer's private `devicePixelRatio`; ADR 040 swaps the renderer's private `ctx` and calls `renderLine`; cursor style (ADR 013/015, `cursor.ts`), mouse motion (`mouse.ts`), IME (ADR 038, `compose.ts`) and XTWINOPS size replies (`kitty.ts`) are client-side patches over ghostty-web behaviour. Fractional DPR (coder/ghostty-web#198) and IME (#97/#190) are among the fixes webtty currently carries as private-field workarounds. Private-field casts compile either way, so an upgrade can silently drop a fix. +- **Ghostty now ships the library officially.** libghostty-vt is built for `wasm32-freestanding` from Ghostty main and published per commit at `tip.files.ghostty.org//ghostty-vt-small.wasm`, signed with Ghostty's minisign key. A gap analysis of the Coder patch found that every export it adds has an official equivalent or a TS composition: lifecycle/write, modes, hyperlinks, DSR/DA responses (`WRITE_PTY` effect) and the upstream functions ghostty-web uses map one to one; render state and scrollback are composed in TS (`ROW_DATA_CELLS_RAW` plus a per-row style cache; `scroll_viewport` plus `DATA_SCROLLBAR`). The patch's two core edits are not needed: the `render.zig` default-colour fallback becomes "theme colour in TS", and the `Screen.zig` stale-row fix (coder/ghostty-web#180) was not reproduced on the official wasm at `05dcf887` with the #180 stress payload (0 failures over 300 repetitions, against 6–36 failures per 30–60 repetitions on a pre-fix Coder build). That payload is kept as a regression test for the binding. +- **The official wasm has APIs webtty fakes today:** cursor visual style and blink, title/bell effects, cell pixel size via `ghostty_terminal_resize(cols, rows, cell_width_px, cell_height_px)` plus the `OPT_SIZE` callback (so Ghostty answers XTWINOPS 14/16/18/19t and mode 2048; with no callback installed, no report is sent), and render-state `OVERSCAN` with per-row `VIEWPORT_Y` for pixel-smooth scroll. + +A spike measured both engines (headless Chrome 155, 200×60, decode cost only, not glyph drawing): + +| | ghostty-web as webtty runs it | ghostty-web, one `getViewport` per frame | Official, `CELLS_RAW` | +|---|---|---|---| +| Full-dirty frame, SGR-heavy | 8.8 ms | 0.27 ms | 0.17 ms | +| Full-dirty frame, plain | 8.0 ms | 0.23 ms | 0.17–0.19 ms | +| Typing (one row changes) | 0.30 ms | 0.25 ms | 0.005 ms | + +ghostty-web's renderer calls `getLine(y)` once per row, and each call copies and parses the whole viewport. Write throughput (64 KB chunks, no rendering) was about 2–10× higher on the official wasm (e.g. `seq 5000` capture: 41.7 vs 106–111 MB/s; plain ASCII: 84–87 vs 770–863 MB/s). + +The spike also found: + +- **Parity.** On 7 of 8 real PTY captures (ls, vim, nvim, top, man/less, an SGR/256/truecolor test, `seq 5000`) the engines matched cell for cell (text, fg, bg, flags, width, cursor) over about 3.95M comparisons, with 512-byte and 1-byte writes. This needs DEC mode 2027 (grapheme clustering) on by default, which the Coder patch forces; without it VS16 emoji are width 1. The one remaining difference is a Hindi conjunct (U+0938 U+094D U+0924 U+0947) that the official wasm clusters as one width-2 cell; this looks like the newer Unicode rule for Indic conjuncts (not confirmed). +- **Scrollback is far smaller than configured.** The client passes `Math.ceil(config.scrollback / 80)` as the scrollback limit, and the Coder wasm reads it as bytes. With the default config (`256 * 1024` becomes `Math.ceil(262144 / 80) = 3277`), users get about 500–650 rows at 120 columns (224 at 200×60), far below what the setting suggests; a config value has to be about 80× larger to get its intended row count. `scrollback: 0` becomes a limit of 0, which the Coder patch treats as unlimited. +- **The Coder wasm leaks on truecolor output.** With many distinct truecolor styles, `memory.buffer` grows to 58.75 MB after 20k lines and 261.75 MB after 100k, while keeping about 673 rows. The official wasm holds at 24.44 MB on the same input. +- **The Coder wasm crashes** (`memory access out of bounds`) on the second terminal created in an instance after the first wrote a multi-codepoint grapheme and was freed; `term.write('')` throws `RangeError`. webtty creates one `Terminal` per page (`src/client/index.ts`), so this is not reachable today; it would matter only if a terminal is ever recreated in place (e.g. on font zoom or reconnect). + +## Decision + +Take ownership of the terminal layer: vendor ghostty-web's TypeScript into webtty, then move it onto the official, unpatched libghostty-vt wasm, in stages that each ship on their own, leave the client working, and can be reverted on their own. + +**Scope of this ADR.** This ADR records the direction and the stage plan, and decides steps 1 and 2. Step 2's items are bug fixes in code webtty then owns. Step 3 (official wasm and binding), step 4 (scrollback in lines, client and config) and step 6 (`OVERSCAN` smooth scroll, which supersedes ADR 040 in part) each get their own ADR before they are implemented; the rules below are inputs to those ADRs. + +1. **Vendor ghostty-web unchanged.** Copy its `lib/` into `src/client/vendor/ghostty-web/` with its MIT `LICENSE` and a provenance note (upstream repo and commit). Commit the Coder `ghostty-vt.wasm` in the same directory with a sidecar recording npm `ghostty-web@0.4.0-next.20.g1858a59`, Ghostty `5714ed07` plus `ghostty-wasm-api.patch`, the licence (MIT) and its sha256; it stays until step 5. Import from the vendored path and drop the npm dependency. Because the TS is bundled into `client-browser.js`, ghostty-web's `new URL('../ghostty-vt.wasm', import.meta.url)` no longer points at the wasm, so `Ghostty.load` gets an explicit wasm path (`/ghostty-vt.wasm`). `scripts/build.ts` copies the wasm from the vendored path instead of `require.resolve('ghostty-web')`, `src/server/static.ts`/`routes.ts` resolve it from `dist/` or the vendored path instead of `node_modules`, and `static.test.ts` drops `ghosttyWebRootFromMain`. `dist/ghostty-web.js` goes away and `client.html` loads only `client-browser.js` (changes ADR 012's asset list). No terminal behaviour change; the private members webtty uses become ordinary code it owns. Acceptance: the existing tests pass and the served wasm is byte-identical to the npm package's (same sha256). +2. **Fix ghostty-web bugs at source.** One fix per change, each deleting the matching webtty workaround. The list is closed; anything else is a separate change. + + | Fix in vendored ghostty-web | webtty workaround deleted | ADR touched | + |---|---|---| + | One viewport read per frame instead of `getLine` per row | none (8.8 → 0.27 ms per frame) | none | + | Whole-ratio DPR in the renderer | private `devicePixelRatio` set in `index.ts` | 039 | + | Wheel encoding: SGR scroll when mouse tracking is on, with `mouseScrollSpeed` | wheel intercept in `index.ts` | 017 | + | Hover motion encoded as Cb=35 | `mouse.ts` `rewriteHoverMotion` | none (bug fix) | + | Default arrow pointer instead of I-beam | `normalizeCursor` MutationObserver in `index.ts` | none (bug fix) | + | FitAddon without the 15 px scrollbar reserve | `fit.ts` `computeGrid` | 033 | + | Textarea keeps focus and follows the cursor cell | focus and positioning half of `compose.ts` | 038 | + | DECSCUSR applied inside the terminal, with a cursor-cell repaint | `cursor.ts` stream interceptor and ghost-cursor repaint | 013, 015 | + +3. **Official wasm and binding, alongside the Coder engine** (own ADR). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. +4. **Scrollback in lines** (own ADR, client and config). A new client key `scrollbackLines` (default 10,000) sets `SCROLLBACK_MAX_LINES`, with `SCROLLBACK_MAX_BYTES` at 32 MiB. `scrollbackLines: 0` means unlimited lines: `SCROLLBACK_MAX_LINES` NULL, bytes still 32 MiB. `scrollback` keeps its current meaning, the server's replay buffer in characters (`src/server/websocket.ts`), and the client stops deriving its limit from it. Official engine only. +5. **Delete the Coder engine and the workarounds the official API makes unnecessary**, after one release with `official` as the default: the Coder wasm and its sidecar, cursor style from the render-state cursor, size reports answered by Ghostty through `OPT_SIZE` (deletes the `kitty.ts` size replies; depends on step 3 installing `OPT_SIZE`), the official mouse/key encoders and `terminal_paste`, theme colours from render state. +6. **Pixel-smooth scroll through `OVERSCAN`** (own ADR, supersedes ADR 040 in part). Draw the extra rows the render state provides at a fractional offset, replacing the strip canvas and `renderLine` swap of ADR 040. When it ships, ADR 040 gets a "Superseded in part by" note and the client SPEC's ADR 040 row is updated. + +Future work, not decided here: official selection, search, synchronized-output hold (mode 2026), OSC 52 and notifications through effects. + +**Rollback.** + +- Step 1: revert the change; the npm dependency and `dist/ghostty-web.js` come back. +- Step 2: each fix is its own change; reverting one restores its workaround. +- Step 3: `?engine=coder` falls back per tab; flipping the build-time default back is a one-line change. The Coder engine is kept for at least one release after the default flips. +- Step 4: revert restores the derived byte limit; `scrollback` and the server are untouched, so no config needs migrating back. +- Step 5: git revert only; this is why it waits for a release on the official default. +- Step 6: the ADR 040 path stays until `OVERSCAN` frames are verified in Chrome, Firefox and Safari; reverting restores it. + +Rules for the binding and the vendored official wasm (inputs to the step 3 and step 4 ADRs): + +- **Commit the wasm.** Vendor the official `ghostty-vt-small.wasm` from a commit that has a tip build, with its `.minisig` (captured from the GitHub `tip` release while that commit is tip; R2 does not serve it) and a JSON sidecar recording the commit, source URL, size and sha256. A script fetches and verifies a new pin against the key in Ghostty's `PACKAGING.md`; the build/test step re-checks sha256, size and signature offline. No network at build time. First pin: Ghostty `05dcf887ef51ea27821f20979493306ec3a19149` (2026-10-12), 780,049 bytes, sha256 `c8a8a0cdd3eeb17951b0c23277d99e1b88016e2afe48f932b37992dfb88607ee`. +- **Scrollback is lines plus a byte ceiling.** Set `SCROLLBACK_MAX_LINES` and `SCROLLBACK_MAX_BYTES` together; never pass 0 bytes (disables scrollback) or NULL for both. A line limit alone does nothing, because the default 10,000-byte cap wins. +- **Wasm memory views do not outlive an export call.** Memory growth detaches the `ArrayBuffer` silently. Rebuild typed-array views after every export call, and copy `CELLS_RAW` rows and callback payloads into JS-owned arrays at once. +- **One binding module.** Every wasm export call lives in the vendored `ghostty.ts`; nothing else in the client touches wasm memory or exports. +- **Layouts come from `ghostty_type_json`.** Struct offsets and enum values are read from the wasm's own manifest at load, not hard-coded, so a pin bump that moves a field fails loudly instead of misreading memory. + +Code borrowed from other projects keeps its licence notice and a header naming source repo, commit and path. + +### Considered Options + +- **Vendor the TS, then move it to the official wasm (chosen)** — owns the code webtty is tuned against, and reaches current Ghostty without carrying a Ghostty diff. +- **Keep depending on ghostty-web (status quo)** — no work now, but the wasm stays on December 2025 Ghostty with its byte-capped scrollback, truecolor memory growth and free/recreate crash, and every fix stays a private-field workaround. +- **Upstream the fixes to coder/ghostty-web and keep the npm dependency** — rejected: the last commit is from 2026-06-28 and the release cadence is unknown, so fixes would wait on someone else's release, and the wasm would still be the patched December 2025 build. +- **Rebase the Coder patch onto current Ghostty** and build the patched wasm with Zig — rejected: carries a Ghostty diff for good, needs Zig in CI, and every Ghostty bump means a patch rebase. +- **Fork ghostty-web as a separate package** — same code ownership as vendoring, plus a publish and version-bump loop for a package only webtty uses. +- **Vendor the TS, keep the Coder wasm for good** — steps 1–2 alone. Fixes the per-row frame cost (8.8 → 0.27 ms) but keeps the frozen, patched wasm and has no `OVERSCAN`. +- **Depend on `@gkoreli/ghostty-vt-js`** — an MIT ghostty-web-shaped TS layer over unpatched Ghostty, but pinned at Ghostty `09557e91` (2026-08-10), 1071 commits behind the pin above. It calls exports the current wasm no longer has (it fails at once with `ghostty_wasm_alloc_u8_array is not a function`), predates `CELLS_RAW`, `next_dirty` and `OVERSCAN`, and replaces FitAddon and the renderer API webtty uses. One maintainer, created 2026-09-19. Borrow its function-table trampoline (`src/wasm/fn-table.ts`, verified to install a `WRITE_PTY` callback on the official wasm) with attribution; do not depend on it. +- **Thin rewrite of the whole terminal** — a new renderer, input and selection on the official API. Most of ghostty-web's TS works and is what webtty is tuned against; rewriting it re-opens every behaviour ADR 013–040 settled. +- **A Zig shim around Ghostty** that exports one bulk-row helper — no Ghostty diff, but adds a Zig toolchain to the build and binds to internal Zig APIs, which are less stable than the C ABI. `CELLS_RAW` with a style cache already matches a bulk copy per frame, so the shim buys nothing measurable. +- **ReleaseFast (`ghostty-vt.wasm`, about 1.06 MB) instead of ReleaseSmall (780 KB)** — left to the step 3 ADR. Frame decode is already about 0.2 ms on ReleaseSmall, so the deciding factor is `vt_write` throughput on a large `cat`, which has not been compared between the two. + +## Consequences + +- Frame decode drops from about 8–9 ms to about 0.2 ms at 200×60 (from step 2 on), and typing frames to about 0.005 ms (from step 3). +- Scrollback holds about the configured line count up to the byte ceiling (about 32k plain lines at 120 columns under 32 MiB; fewer with truecolor or wide terminals, e.g. 14,271 rows of heavy truecolor under 16 MiB; page granularity can land a few hundred rows under the limit, e.g. 9,588 rows for a 10,000-line limit). +- Wasm memory, measured in Node/V8 (not yet in a browser): about 11 MB per tab at 10,000 lines and 120 columns of plain text, about 25 MB for heavy truecolor, about 56 MB at 300 columns with heavy truecolor. The byte ceiling bounds scrollback pages only; total memory is roughly the ceiling plus the active screen plus allocator and page-pool overhead, which is how a tab can exceed 32 MiB. Step 4 confirms the figures in headless Chrome. +- **Config migration.** `scrollback` keeps its meaning (server replay buffer, default 262144), so existing configs replay as before. Client history moves to `scrollbackLines`: a user who raised `scrollback` to get more terminal history gets the 10,000-line default instead and must set `scrollbackLines`. The config SPEC documents both keys when step 4 ships. +- webtty owns the terminal code: fixes are normal edits with tests, and the `as unknown as` casts into the renderer go away. +- About 780 KB of binary in git per official pin (plus the Coder wasm until step 5), and webtty carries the vendored ghostty-web code (MIT, Coder) from here on. +- **API instability.** libghostty-vt is unversioned and its headers say the API will change (allocator exports, the mode API and colour getters all changed between Ghostty `09557e91` and `05dcf887`). A pin bump is a deliberate change: re-run parity and the binding's tests. The wasm does not record its commit (`ghostty_build_info` reports `0.1.0-dev`); the sidecar is the only record. +- **Memory never shrinks.** Wasm linear memory has no maximum and stays at its high-water mark after `reset` and `free`; compression is unsupported on wasm32. A tab gives memory back only when it closes. +- **Tip-build availability.** Only some main commits get a tip wasm (8 of the last 25 checked), and the `.minisig` is downloadable only while the commit is the current `tip`. Committing the wasm and its signature removes the runtime dependence; pinning an arbitrary commit means a local Zig build. +- **Kitty graphics stays client-side.** It is compiled out of the wasm32 build (`kittyGraphics: false`), so ADR 032's overlay remains. +- **Behaviour changes apps can see.** Primary DA (`ESC[?62;22c`) is unchanged. Secondary DA stays `ESC[>1;10;0c` only because the binding installs `DEVICE_ATTRIBUTES`; step 3 verifies both end to end. Replies that are actually new are the ones the Coder patch dropped: XTWINOPS and mode 2048 size reports from Ghostty (through `OPT_SIZE`), OSC 4/10/11/12 colour queries (a no-op in the Coder patch), DECRQM mode reports, XTVERSION, the kitty keyboard query and ENQ. The Hindi conjunct above takes one wide cell, and newer Ghostty parsing applies everywhere. All of these replies reach the PTY only through the `WRITE_PTY` callback; step 3 installs it, or the official engine answers nothing. +- The official artifact needs wasm SIMD (Chrome 91+, Firefox 89+, Safari 16.4+). +- Not yet measured: glyph drawing cost, Firefox and Safari, resize/reflow parity, OSC/query replies end to end, secondary DA without a callback in a browser, and `OVERSCAN` frames in a browser. The step 3 matrix and step 6 must cover them before the default flips or step 6 ships. + +## Related Decisions + +- [ADR 012 — Static asset build](012.client.static-assets.md) +- [ADR 013 — DECSCUSR cursor style](013.client.cursor-style.md) +- [ADR 015 — Cursor ghost repaint](015.client.cursor-ghost-repaint.md) +- [ADR 017 — Mouse scroll](017.client.mouse-scroll.md) +- [ADR 032 — Kitty graphics via overlay canvas](032.client.kitty-graphics-overlay.md) +- [ADR 033 — Fit without the scrollbar reserve](033.client.padding-fit.md) +- [ADR 038 — IME composition at the cursor](038.client.ime-composition-at-cursor.md) +- [ADR 039 — Follow devicePixelRatio changes](039.client.device-pixel-ratio-change.md) +- [ADR 040 — Pixel-smooth scrollback scrolling](040.client.pixel-smooth-scroll.md) diff --git a/docs/specs/client.md b/docs/specs/client.md index 8e089c3..119af81 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -25,6 +25,8 @@ dist/ ghostty-vt.wasm ← ghostty-web package ``` +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): once ghostty-web is vendored and bundled into `client-browser.js`, `ghostty-web.js` goes away and `client.html` loads only `client-browser.js`. `ghostty-vt.wasm` is then copied by `scripts/build.ts` from the vendored path instead of the npm package, and the server resolves it from `dist/` or the vendored path instead of `node_modules`. It stays the Coder-patched build (vendored with its provenance) until the official libghostty-vt wasm, committed under `src/client/vendor/ghostty-vt/`, becomes the default and the Coder engine is removed. + ## Source layout ``` @@ -34,6 +36,15 @@ src/client/ index.css ← terminal styles ``` +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): + +``` +src/client/vendor/ + ghostty-web/ ← ghostty-web TS (MIT LICENSE + upstream commit note); all wasm calls in ghostty.ts; + Coder ghostty-vt.wasm + sidecar (npm version, Ghostty commit + patch, sha256) until the Coder engine is removed + ghostty-vt/ ← official ghostty-vt-small.wasm + .minisig + provenance sidecar (commit, URL, sha256) +``` + ## Page `GET /s/:id` returns `dist/client.html` — a static HTML shell. On load, `client-browser.js`: @@ -167,6 +178,10 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ | | Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, updates the renderer's pixel ratio and repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md) | ✅ | | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | +| Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own code with no npm `ghostty-web` dependency; ghostty-web bugs are fixed at source instead of patched from outside, and terminal behaviour is otherwise unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Official libghostty-vt wasm | The terminal runs on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked) with current Ghostty parsing and query replies; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index d6171ec..ab51762 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -1,6 +1,6 @@ # SPEC: Config -**Last Updated:** 2026-03-27 +**Last Updated:** 2026-10-12 --- @@ -122,6 +122,8 @@ All keys are optional — omit any key to use the default value. | `theme` | object | Campbell | Terminal color palette — see theme keys below | | `keyboardBindings` | array | `[]` | Custom key-to-sequence bindings sent to the PTY. See [key-bindings spec](key-bindings.md) for schema and examples. | +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): a client key `scrollbackLines` (number, default `10000`; `0` = unlimited lines) sets the terminal's history in lines, under a 32 MiB byte ceiling. `scrollback` keeps its meaning as the server's replay buffer, and the client stops deriving its history limit from it (today it passes `Math.ceil(scrollback / 80)`, which the terminal reads as bytes). + ### Theme keys All theme keys are optional; omitted keys fall back to the Campbell (Windows Terminal default) palette. @@ -213,5 +215,6 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | +| Scrollback in lines | `scrollbackLines` (default `10000`, `0` = unlimited) sets terminal history in lines under a 32 MiB ceiling; `scrollback` stays the server replay buffer, so existing configs keep their meaning | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | | Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ✅ | From 3fb6e9c13079bf1404a797becb849179a7a4ae28 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 22:17:09 -0400 Subject: [PATCH 002/295] refactor(client): vendor ghostty-web and drop the npm dependency Copy ghostty-web's TypeScript (coder/ghostty-web 1858a59, the commit the pinned npm 0.4.0-next.20 was built from) byte-for-byte into src/client/vendor/ghostty-web with its MIT licence and a provenance note, and commit its Coder-patched ghostty-vt.wasm with a sha256 sidecar. The client bundles the vendored code into client-browser.js and loads the wasm explicitly from /ghostty-vt.wasm; dist/ghostty-web.js is gone. The server resolves the wasm from dist/ or the vendored path instead of node_modules. No terminal behaviour change: an A/B run in headless Chrome against origin/main rendered pixel-identically. ADR 041 step 1. --- .gitattributes | 1 + biome.json | 2 +- bun.lock | 3 - docs/specs/client.md | 24 +- package.json | 1 - scripts/build.ts | 44 +- src/client/compose.ts | 2 +- src/client/cursor.ts | 2 +- src/client/graphics.ts | 2 +- src/client/index.ts | 8 +- src/client/smooth-scroll.ts | 2 +- src/client/vendor/ghostty-web.test.ts | 22 + src/client/vendor/ghostty-web/LICENSE | 21 + src/client/vendor/ghostty-web/LICENSE.ghostty | 21 + src/client/vendor/ghostty-web/PROVENANCE.md | 11 + src/client/vendor/ghostty-web/addons/fit.ts | 242 +++ src/client/vendor/ghostty-web/buffer.ts | 407 ++++ .../vendor/ghostty-web/event-emitter.ts | 27 + src/client/vendor/ghostty-web/ghostty-vt.wasm | Bin 0 -> 423939 bytes .../vendor/ghostty-web/ghostty-vt.wasm.json | 21 + src/client/vendor/ghostty-web/ghostty.ts | 883 ++++++++ src/client/vendor/ghostty-web/index.ts | 101 + .../vendor/ghostty-web/input-handler.ts | 1106 ++++++++++ src/client/vendor/ghostty-web/interfaces.ts | 218 ++ .../vendor/ghostty-web/link-detector.ts | 215 ++ .../providers/osc8-link-provider.ts | 256 +++ .../providers/url-regex-provider.ts | 150 ++ src/client/vendor/ghostty-web/renderer.ts | 1001 +++++++++ .../vendor/ghostty-web/selection-manager.ts | 1056 +++++++++ src/client/vendor/ghostty-web/terminal.ts | 1916 +++++++++++++++++ src/client/vendor/ghostty-web/types.ts | 797 +++++++ src/server/index.ts | 6 +- src/server/routes.test.ts | 17 + src/server/routes.ts | 22 +- src/server/static.test.ts | 52 +- src/server/static.ts | 50 +- src/version.test.ts | 9 +- 37 files changed, 8584 insertions(+), 134 deletions(-) create mode 100644 src/client/vendor/ghostty-web.test.ts create mode 100644 src/client/vendor/ghostty-web/LICENSE create mode 100644 src/client/vendor/ghostty-web/LICENSE.ghostty create mode 100644 src/client/vendor/ghostty-web/PROVENANCE.md create mode 100644 src/client/vendor/ghostty-web/addons/fit.ts create mode 100644 src/client/vendor/ghostty-web/buffer.ts create mode 100644 src/client/vendor/ghostty-web/event-emitter.ts create mode 100755 src/client/vendor/ghostty-web/ghostty-vt.wasm create mode 100644 src/client/vendor/ghostty-web/ghostty-vt.wasm.json create mode 100644 src/client/vendor/ghostty-web/ghostty.ts create mode 100644 src/client/vendor/ghostty-web/index.ts create mode 100644 src/client/vendor/ghostty-web/input-handler.ts create mode 100644 src/client/vendor/ghostty-web/interfaces.ts create mode 100644 src/client/vendor/ghostty-web/link-detector.ts create mode 100644 src/client/vendor/ghostty-web/providers/osc8-link-provider.ts create mode 100644 src/client/vendor/ghostty-web/providers/url-regex-provider.ts create mode 100644 src/client/vendor/ghostty-web/renderer.ts create mode 100644 src/client/vendor/ghostty-web/selection-manager.ts create mode 100644 src/client/vendor/ghostty-web/terminal.ts create mode 100644 src/client/vendor/ghostty-web/types.ts diff --git a/.gitattributes b/.gitattributes index 6313b56..596e734 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +*.wasm binary diff --git a/biome.json b/biome.json index 497f75e..dbac7e5 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/node_modules", "!**/dist"] + "includes": ["**", "!**/node_modules", "!**/dist", "!src/client/vendor/ghostty-web"] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 9728941..32ee523 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "webtty", "dependencies": { "@lydell/node-pty": "1.2.0-beta.15", - "ghostty-web": "0.4.0-next.20.g1858a59", "ws": "8.21.3", }, "devDependencies": { @@ -301,8 +300,6 @@ "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - "ghostty-web": ["ghostty-web@0.4.0-next.20.g1858a59", "", {}, "sha512-NXA9H3IJlx+DGJukXbOPQWFkigYdAatTqkoIvM8tvhfbaYoDf3gGKxXLLyXtLYOBt5qzGdEZa294TU36gULjVg=="], - "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], diff --git a/docs/specs/client.md b/docs/specs/client.md index 119af81..7028983 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -19,13 +19,15 @@ The client has one surface: a full-viewport terminal view, one session per tab. ``` dist/ client.html ← static HTML shell; served at GET /s/:id - client-browser.js ← compiled from src/client/index.ts + client-browser.js ← compiled from src/client/index.ts, with the vendored ghostty-web TS bundled in client.css ← copied from src/client/index.css - ghostty-web.js ← ghostty-web package - ghostty-vt.wasm ← ghostty-web package + ghostty-vt.wasm ← copied from src/client/vendor/ghostty-web/; served at GET /ghostty-vt.wasm + THIRD_PARTY_LICENSES ← Coder's ghostty-web and Ghostty's MIT notices, from src/client/vendor/ghostty-web/ ``` -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): once ghostty-web is vendored and bundled into `client-browser.js`, `ghostty-web.js` goes away and `client.html` loads only `client-browser.js`. `ghostty-vt.wasm` is then copied by `scripts/build.ts` from the vendored path instead of the npm package, and the server resolves it from `dist/` or the vendored path instead of `node_modules`. It stays the Coder-patched build (vendored with its provenance) until the official libghostty-vt wasm, committed under `src/client/vendor/ghostty-vt/`, becomes the default and the Coder engine is removed. +`client.html` loads only `client-browser.js`. The npm `ghostty-web.js` build inlined the wasm as a base64 `data:` URL, so the browser never fetched `/ghostty-vt.wasm`. The vendored TS is bundled without inlining, so the client now fetches it on every page load through an explicit `Ghostty.load('/ghostty-vt.wasm')`, and the page depends on the server serving that route as `application/wasm`. The server resolves the wasm from `dist/` (npm install) or the vendored path (local dev); nothing comes from `node_modules` ([ADR 041](../adrs/041.client.libghostty-vt.md)). + +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): the wasm stays the Coder-patched build until the official libghostty-vt wasm, committed under `src/client/vendor/ghostty-vt/`, becomes the default and the Coder engine is removed. ## Source layout @@ -34,14 +36,19 @@ src/client/ index.ts ← browser TypeScript entry point index.html ← HTML shell (no inline script) index.css ← terminal styles + vendor/ + ghostty-web/ ← coder/ghostty-web lib/ at 1858a59 (non-test TS, unchanged), MIT LICENSE, PROVENANCE.md; + Ghostty's LICENSE.ghostty; Coder ghostty-vt.wasm + ghostty-vt.wasm.json sidecar (npm version, Ghostty commit + patch, + licence, size, sha256), checked by vendor/ghostty-web.test.ts ``` +The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. + Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): ``` src/client/vendor/ - ghostty-web/ ← ghostty-web TS (MIT LICENSE + upstream commit note); all wasm calls in ghostty.ts; - Coder ghostty-vt.wasm + sidecar (npm version, Ghostty commit + patch, sha256) until the Coder engine is removed + ghostty-web/ ← all wasm calls in ghostty.ts; Coder wasm + sidecar until the Coder engine is removed ghostty-vt/ ← official ghostty-vt-small.wasm + .minisig + provenance sidecar (commit, URL, sha256) ``` @@ -52,7 +59,7 @@ src/client/vendor/ 1. Reads `sessionId` from `window.location.pathname` (`/s/main` → `main`) 2. Fetches `GET /api/config` to get terminal config 3. Sets `document.title = sessionId + ' | webtty'` -4. Initialises a `ghostty-web` `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior) +4. Loads `/ghostty-vt.wasm` and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior) 5. Connects to `ws:///ws/:id?cols=&rows=` over WebSocket 6. Fits the terminal to the viewport from the full container size (no scrollbar reserve, see ADR 033) and observes resize events 7. Sends a `{ type: 'resize', cols, rows }` JSON message on open and on every terminal resize @@ -178,7 +185,8 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ | | Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, updates the renderer's pixel ratio and repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md) | ✅ | | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | -| Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own code with no npm `ghostty-web` dependency; ghostty-web bugs are fixed at source instead of patched from outside, and terminal behaviour is otherwise unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | +| ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Official libghostty-vt wasm | The terminal runs on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked) with current Ghostty parsing and query replies; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | diff --git a/package.json b/package.json index 203837d..6fb9ddc 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.15", - "ghostty-web": "0.4.0-next.20.g1858a59", "ws": "8.21.3" }, "devDependencies": { diff --git a/scripts/build.ts b/scripts/build.ts index 35c1e5c..dd72608 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,16 +1,13 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; -const require = createRequire(import.meta.url); - const serverResult = await Bun.build({ entrypoints: ['./src/server/index.ts', './src/cli/index.ts'], outdir: './dist', target: 'node', format: 'esm', sourcemap: 'external', - external: ['@lydell/node-pty', 'ws', 'ghostty-web'], + external: ['@lydell/node-pty', 'ws'], banner: '#!/usr/bin/env node\n', }); @@ -26,7 +23,6 @@ const clientResult = await Bun.build({ target: 'browser', format: 'esm', minify: true, - external: ['ghostty-web'], naming: 'client-browser.js', }); @@ -36,26 +32,36 @@ if (!clientResult.success) { process.exit(1); } -const clientOut = path.resolve('./dist/client-browser.js'); -const clientJs = fs.readFileSync(clientOut, 'utf8'); -fs.writeFileSync(clientOut, clientJs.replace(/"ghostty-web"/g, '"/dist/ghostty-web.js"')); - fs.copyFileSync(path.resolve('./src/client/client.html'), path.resolve('./dist/client.html')); fs.copyFileSync(path.resolve('./src/client/index.css'), path.resolve('./dist/client.css')); -// Copy ghostty-web assets into dist/ so they ship with the package. -// Without this, `npx webtty` fails — the package is extracted to a temp -// directory with no node_modules, so require.resolve('ghostty-web') throws. -const ghosttyWebMain = require.resolve('ghostty-web') as string; -const ghosttyWebRoot = ghosttyWebMain.replace(/[/\\]dist[/\\].*$/, ''); -fs.copyFileSync( - path.join(ghosttyWebRoot, 'dist', 'ghostty-web.js'), - path.resolve('./dist/ghostty-web.js'), -); +// The vendored ghostty-web TypeScript is bundled into client-browser.js; its wasm +// ships next to it in dist/ so the published package needs nothing outside dist/. fs.copyFileSync( - path.join(ghosttyWebRoot, 'ghostty-vt.wasm'), + path.resolve('./src/client/vendor/ghostty-web/ghostty-vt.wasm'), path.resolve('./dist/ghostty-vt.wasm'), ); +// The vendored TS has no licence headers, so nothing survives minification; the +// MIT notices for the code in client-browser.js and ghostty-vt.wasm ship here instead. +const vendorDir = './src/client/vendor/ghostty-web'; +fs.writeFileSync( + path.resolve('./dist/THIRD_PARTY_LICENSES'), + [ + 'ghostty-web (bundled in client-browser.js; ghostty-vt.wasm build and patch)', + 'https://github.com/coder/ghostty-web', + '', + fs.readFileSync(path.resolve(vendorDir, 'LICENSE'), 'utf8').trim(), + '', + '---', + '', + 'Ghostty (compiled into ghostty-vt.wasm)', + 'https://github.com/ghostty-org/ghostty', + '', + fs.readFileSync(path.resolve(vendorDir, 'LICENSE.ghostty'), 'utf8').trim(), + '', + ].join('\n'), +); + const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 4; console.log(`✓ Build complete (${totalFiles} files)`); diff --git a/src/client/compose.ts b/src/client/compose.ts index 53b3295..bdd09fa 100644 --- a/src/client/compose.ts +++ b/src/client/compose.ts @@ -14,7 +14,7 @@ * through ghostty-web's own compositionend handling. */ -import type { Terminal } from 'ghostty-web'; +import type { Terminal } from './vendor/ghostty-web'; // ghostty-web's defaults for a theme without foreground/background. const DEFAULT_FOREGROUND = '#d4d4d4'; diff --git a/src/client/cursor.ts b/src/client/cursor.ts index a53dcb2..da04736 100644 --- a/src/client/cursor.ts +++ b/src/client/cursor.ts @@ -1,4 +1,4 @@ -import type { Terminal } from 'ghostty-web'; +import type { Terminal } from './vendor/ghostty-web'; // ghostty-web does not yet read cursor style from the WASM render state after // write() — getCursor() hardcodes style: 'block' (see TODO in ghostty-web source). diff --git a/src/client/graphics.ts b/src/client/graphics.ts index 541c00b..392bee0 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -1,4 +1,3 @@ -import type { Terminal } from 'ghostty-web'; import { ApcSplitter, axisScale, @@ -16,6 +15,7 @@ import { sizeReply, type Transmission, } from './kitty'; +import type { Terminal } from './vendor/ghostty-web'; // Sizes reported to programs (CSI 14t/16t) are device pixels, as in kitty and // Ghostty: programs such as terminal-browser divide them by the display's diff --git a/src/client/index.ts b/src/client/index.ts index 511448a..3f221b5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,3 @@ -import { init, Terminal } from 'ghostty-web'; import { Composer } from './compose'; import { applyDecscusr } from './cursor'; import { EdgeExtender } from './edge'; @@ -8,6 +7,7 @@ import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; import { encodePaste } from './paste'; import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; +import { Ghostty, Terminal } from './vendor/ghostty-web'; interface KeyboardBinding { key: string; @@ -62,13 +62,17 @@ const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); document.title = `${sessionId} | webtty`; -await init(); +// The vendored ghostty-web is bundled into /dist/client-browser.js, so its default +// wasm lookup (relative to its own module URL) would depend on where the bundle sits. +// Load the wasm from the path the server serves it at instead (ADR 041). +const ghostty = await Ghostty.load('/ghostty-vt.wasm'); // Translate webtty's theme keys to ghostty-web's names and keep `padding`, a webtty // setting and not a terminal colour, out of the theme it receives (ADR 036). const terminalTheme = toTerminalTheme(config.theme ?? {}); const term = new Terminal({ + ghostty, cols: config.cols, rows: config.rows, cursorStyle: config.cursorStyle, diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 0d6afec..d607071 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -15,7 +15,7 @@ * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ -import type { Terminal } from 'ghostty-web'; +import type { Terminal } from './vendor/ghostty-web'; export interface ScrollSplit { /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ diff --git a/src/client/vendor/ghostty-web.test.ts b/src/client/vendor/ghostty-web.test.ts new file mode 100644 index 0000000..e3db54f --- /dev/null +++ b/src/client/vendor/ghostty-web.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-web'); + +describe('vendored ghostty-vt.wasm', () => { + const sidecar = JSON.parse( + fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt.wasm.json'), 'utf8'), + ) as { file: string; size: number; sha256: string }; + const wasm = fs.readFileSync(path.join(VENDOR_DIR, sidecar.file)); + + test('matches the size recorded in its sidecar', () => { + expect(wasm.byteLength).toBe(sidecar.size); + }); + + test('matches the sha256 recorded in its sidecar', () => { + expect(createHash('sha256').update(wasm).digest('hex')).toBe(sidecar.sha256); + }); +}); diff --git a/src/client/vendor/ghostty-web/LICENSE b/src/client/vendor/ghostty-web/LICENSE new file mode 100644 index 0000000..bd8d593 --- /dev/null +++ b/src/client/vendor/ghostty-web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Coder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/client/vendor/ghostty-web/LICENSE.ghostty b/src/client/vendor/ghostty-web/LICENSE.ghostty new file mode 100644 index 0000000..0a07a66 --- /dev/null +++ b/src/client/vendor/ghostty-web/LICENSE.ghostty @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md new file mode 100644 index 0000000..d4fa3cd --- /dev/null +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -0,0 +1,11 @@ +# Provenance + +Vendored copy of [coder/ghostty-web](https://github.com/coder/ghostty-web), MIT licence, Copyright (c) 2025 Coder (see [LICENSE](LICENSE)). See [ADR 041](../../../../docs/adrs/041.client.libghostty-vt.md). + +- **Upstream commit:** `1858a5947767a3e1c9e98dbf53b2ff87fedb2aab` (2026-06-28), the `gitHead` of npm `ghostty-web@0.4.0-next.20.g1858a59`. +- **TypeScript:** every non-test file under upstream `lib/` (including `addons/` and `providers/`), copied byte for byte. Upstream's tests (`*.test.ts`, `test-helpers.ts`) and its happy-dom preload were not copied. +- **`ghostty-vt.wasm`:** copied byte for byte from the npm package. Built by Coder from Ghostty `5714ed07a1012573261b7b7e3ed2add9c1504496` plus upstream `patches/ghostty-wasm-api.patch`, so it is Ghostty code under Ghostty's MIT licence, Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors ([LICENSE.ghostty](LICENSE.ghostty), copied from Ghostty at that commit), plus Coder's MIT-licensed patch. Size and sha256 are in [ghostty-vt.wasm.json](ghostty-vt.wasm.json) and checked by `src/client/vendor/ghostty-web.test.ts`. + +`bun run build` writes both notices to `dist/THIRD_PARTY_LICENSES`. + +webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. diff --git a/src/client/vendor/ghostty-web/addons/fit.ts b/src/client/vendor/ghostty-web/addons/fit.ts new file mode 100644 index 0000000..ea53265 --- /dev/null +++ b/src/client/vendor/ghostty-web/addons/fit.ts @@ -0,0 +1,242 @@ +/** + * FitAddon - Auto-resize terminal to fit container + * + * Provides automatic terminal resizing to fit its container element. + * Compatible with xterm.js FitAddon API. + * + * Usage: + * ```typescript + * const fitAddon = new FitAddon(); + * term.loadAddon(fitAddon); + * fitAddon.fit(); // Manual fit + * fitAddon.observeResize(); // Auto-fit on resize + * ``` + */ + +import type { ITerminalAddon, ITerminalCore } from '../interfaces'; + +// ============================================================================ +// Constants +// ============================================================================ + +const MINIMUM_COLS = 2; +const MINIMUM_ROWS = 1; +const DEFAULT_SCROLLBAR_WIDTH = 15; // Reserve space for future scrollback scrollbar +const RESIZE_DEBOUNCE_MS = 100; // Debounce time for ResizeObserver + +// ============================================================================ +// Types +// ============================================================================ + +export interface ITerminalDimensions { + cols: number; + rows: number; +} + +// ============================================================================ +// FitAddon Class +// ============================================================================ + +export class FitAddon implements ITerminalAddon { + private _terminal?: ITerminalCore; + private _resizeObserver?: ResizeObserver; + private _resizeDebounceTimer?: ReturnType; + private _lastCols?: number; + private _lastRows?: number; + private _isResizing: boolean = false; + + /** + * Activate the addon (called by Terminal.loadAddon) + */ + public activate(terminal: ITerminalCore): void { + this._terminal = terminal; + } + + /** + * Dispose the addon and clean up resources + */ + public dispose(): void { + // Disconnect ResizeObserver + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = undefined; + } + + // Clear pending debounce timer + if (this._resizeDebounceTimer) { + clearTimeout(this._resizeDebounceTimer); + this._resizeDebounceTimer = undefined; + } + + // Clear stored dimensions + this._lastCols = undefined; + this._lastRows = undefined; + + this._terminal = undefined; + } + + /** + * Fit the terminal to its container + * + * Calculates optimal dimensions and resizes the terminal. + * Does nothing if dimensions cannot be calculated or haven't changed. + */ + public fit(): void { + // Prevent re-entrant calls during resize + if (this._isResizing) { + return; + } + + const dims = this.proposeDimensions(); + if (!dims || !this._terminal) { + return; + } + + // Access terminal to check current dimensions + const terminal = this._terminal as any; + const currentCols = terminal.cols; + const currentRows = terminal.rows; + + // Check if dimensions actually changed (prevent feedback loops) + // Compare against BOTH proposed dimensions AND current terminal dimensions + if ( + (dims.cols === this._lastCols && dims.rows === this._lastRows) || + (dims.cols === currentCols && dims.rows === currentRows) + ) { + return; + } + + // Store dimensions before resize + this._lastCols = dims.cols; + this._lastRows = dims.rows; + + // Set flag to prevent re-entrant calls + this._isResizing = true; + + try { + // Resize terminal + if (terminal.resize && typeof terminal.resize === 'function') { + terminal.resize(dims.cols, dims.rows); + } + } finally { + // Clear flag after a short delay to allow DOM to settle + setTimeout(() => { + this._isResizing = false; + }, 50); + } + } + + /** + * Propose dimensions to fit the terminal to its container + * + * Calculates cols and rows based on: + * - Terminal container element dimensions (clientWidth/Height) + * - Terminal element padding + * - Font metrics (character cell size) + * - Scrollbar width reservation + * + * @returns Proposed dimensions or undefined if cannot calculate + */ + public proposeDimensions(): ITerminalDimensions | undefined { + // Need terminal and its DOM element + if (!this._terminal?.element) { + return undefined; + } + + // Access terminal internals to get renderer + const terminal = this._terminal as any; + const renderer = terminal.renderer; + + if (!renderer || typeof renderer.getMetrics !== 'function') { + return undefined; + } + + // Get font metrics from renderer + const metrics = renderer.getMetrics(); + if (!metrics || metrics.width === 0 || metrics.height === 0) { + return undefined; + } + + // Get terminal element (container) dimensions + // Use clientWidth/clientHeight to get the INSIDE dimensions (excluding padding) + const terminalElement = this._terminal.element; + + // Check if we have clientWidth/clientHeight (DOM element required) + if (typeof terminalElement.clientWidth === 'undefined') { + return undefined; + } + + const elementStyle = window.getComputedStyle(terminalElement); + + // Get the actual content area (inside padding) + const paddingTop = Number.parseInt(elementStyle.getPropertyValue('padding-top')) || 0; + const paddingBottom = Number.parseInt(elementStyle.getPropertyValue('padding-bottom')) || 0; + const paddingLeft = Number.parseInt(elementStyle.getPropertyValue('padding-left')) || 0; + const paddingRight = Number.parseInt(elementStyle.getPropertyValue('padding-right')) || 0; + + // Use clientWidth/clientHeight which gives us the inside dimensions + // This is stable and doesn't grow with content + const containerWidth = terminalElement.clientWidth; + const containerHeight = terminalElement.clientHeight; + + // Check for invalid dimensions + if (containerWidth === 0 || containerHeight === 0) { + return undefined; + } + + // Calculate available space (subtract padding since clientWidth includes padding) + const availableWidth = containerWidth - paddingLeft - paddingRight - DEFAULT_SCROLLBAR_WIDTH; + const availableHeight = containerHeight - paddingTop - paddingBottom; + + // Calculate dimensions (enforce minimums) + const cols = Math.max(MINIMUM_COLS, Math.floor(availableWidth / metrics.width)); + const rows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / metrics.height)); + + return { cols, rows }; + } + + /** + * Observe the terminal's container for resize events + * + * Sets up a ResizeObserver to automatically call fit() when the + * container size changes. Resize events are debounced to avoid + * excessive calls during window drag operations. + * + * Call dispose() to stop observing. + */ + public observeResize(): void { + if (!this._terminal?.element) { + return; + } + + // Already observing + if (this._resizeObserver) { + return; + } + + // Create ResizeObserver that watches for external size changes + this._resizeObserver = new ResizeObserver((entries) => { + // Ignore resize events while we're actively resizing + if (this._isResizing) { + return; + } + + // Only trigger if the observed element's content rect changed + const entry = entries[0]; + if (!entry) return; + + // Debounce resize events + if (this._resizeDebounceTimer) { + clearTimeout(this._resizeDebounceTimer); + } + + this._resizeDebounceTimer = setTimeout(() => { + this.fit(); + }, RESIZE_DEBOUNCE_MS); + }); + + // Observe the terminal element itself (the container we want to fit into) + // This gives us stable resize events when the CONTAINER changes, not when our canvas changes + this._resizeObserver.observe(this._terminal.element); + } +} diff --git a/src/client/vendor/ghostty-web/buffer.ts b/src/client/vendor/ghostty-web/buffer.ts new file mode 100644 index 0000000..031a493 --- /dev/null +++ b/src/client/vendor/ghostty-web/buffer.ts @@ -0,0 +1,407 @@ +/** + * Buffer API - xterm.js-compatible buffer access + * + * Provides read-only access to terminal buffer contents, cursor state, + * and viewport position. Wraps Ghostty WASM terminal state. + * + * Usage: + * ```typescript + * const cell = term.buffer.active.getLine(0)?.getCell(5); + * console.log(cell?.getChars(), cell?.isBold()); + * + * if (term.buffer.active.type === 'alternate') { + * console.log('Full-screen app running'); + * } + * ``` + */ + +import { EventEmitter } from './event-emitter'; +import type { GhosttyTerminal } from './ghostty'; +import { CellFlags } from './ghostty'; +import type { + IBuffer, + IBufferCell, + IBufferLine, + IBufferNamespace, + IDisposable, + IEvent, +} from './interfaces'; +import type { Terminal } from './terminal'; +import type { GhosttyCell } from './types'; + +// ============================================================================ +// BufferNamespace - Top-level buffer API +// ============================================================================ + +/** + * Top-level buffer API namespace + * Provides access to active, normal, and alternate screen buffers + */ +export class BufferNamespace implements IBufferNamespace { + private terminal: Terminal; + private bufferChangeEmitter = new EventEmitter(); + + // Lazy-initialized buffer wrappers (stateless, so we can cache them) + private _normalBuffer?: Buffer; + private _alternateBuffer?: Buffer; + + constructor(terminal: Terminal) { + this.terminal = terminal; + } + + get active(): IBuffer { + // Query WASM to determine which buffer is active + const wasmTerm = (this.terminal as any).wasmTerm as GhosttyTerminal | undefined; + if (!wasmTerm) { + return this.normal; // Default to normal if not initialized + } + + return wasmTerm.isAlternateScreen() ? this.alternate : this.normal; + } + + get normal(): IBuffer { + if (!this._normalBuffer) { + this._normalBuffer = new Buffer(this.terminal, 'normal'); + } + return this._normalBuffer; + } + + get alternate(): IBuffer { + if (!this._alternateBuffer) { + this._alternateBuffer = new Buffer(this.terminal, 'alternate'); + } + return this._alternateBuffer; + } + + get onBufferChange(): IEvent { + return this.bufferChangeEmitter.event; + } + + /** + * Internal: Fire buffer change event when screen switches + * Should be called by Terminal when detecting screen change + */ + _fireBufferChange(buffer: IBuffer): void { + this.bufferChangeEmitter.fire(buffer); + } +} + +// ============================================================================ +// Buffer - Represents a terminal buffer (normal or alternate) +// ============================================================================ + +/** + * A terminal buffer (normal or alternate screen) + */ +export class Buffer implements IBuffer { + private terminal: Terminal; + private bufferType: 'normal' | 'alternate'; + private nullCell: BufferCell; + + constructor(terminal: Terminal, type: 'normal' | 'alternate') { + this.terminal = terminal; + this.bufferType = type; + + // Create a null cell (codepoint=0, default colors, no flags) + const nullCellData: GhosttyCell = { + codepoint: 0, + fg_r: 204, + fg_g: 204, + fg_b: 204, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; + this.nullCell = new BufferCell(nullCellData, 0); + } + + get type(): 'normal' | 'alternate' { + return this.bufferType; + } + + get cursorX(): number { + const wasmTerm = this.getWasmTerm(); + if (!wasmTerm) return 0; + return wasmTerm.getCursor().x; + } + + get cursorY(): number { + const wasmTerm = this.getWasmTerm(); + if (!wasmTerm) return 0; + return wasmTerm.getCursor().y; + } + + get viewportY(): number { + // Get viewport offset from Terminal + // For now, return 0 (no scrollback navigation implemented yet) + return 0; + } + + get baseY(): number { + // For normal buffer: 0 + // For alternate buffer: 0 (alternate has no scrollback) + return 0; + } + + get length(): number { + const wasmTerm = this.getWasmTerm(); + if (!wasmTerm) return 0; + + if (this.bufferType === 'alternate') { + // Alternate buffer has no scrollback, just visible rows + return wasmTerm.rows; + } else { + // Normal buffer: scrollback + visible rows + const scrollback = wasmTerm.getScrollbackLength(); + return scrollback + wasmTerm.rows; + } + } + + getLine(y: number): IBufferLine | undefined { + const wasmTerm = this.getWasmTerm(); + if (!wasmTerm) return undefined; + + // Check bounds + if (y < 0 || y >= this.length) { + return undefined; + } + + // Determine if accessing scrollback or visible screen + const scrollbackLength = wasmTerm.getScrollbackLength(); + let cells: GhosttyCell[] | null; + let lineNumber: number; + let isWrapped: boolean; + + if (this.bufferType === 'normal' && y < scrollbackLength) { + // Accessing scrollback + // WASM getScrollbackLine: offset 0 = oldest, offset (length-1) = newest + // Buffer coords: y=0 = oldest, y=(length-1) = newest + // So scrollbackOffset = y directly! + const scrollbackOffset = y; + cells = wasmTerm.getScrollbackLine(scrollbackOffset); + // TODO: We'd need WASM API to check if scrollback line is wrapped + // For now, assume not wrapped + isWrapped = false; + } else { + // Accessing visible screen + lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y; + cells = wasmTerm.getLine(lineNumber); + isWrapped = wasmTerm.isRowWrapped(lineNumber); + } + + if (!cells) { + return undefined; + } + + return new BufferLine(cells, isWrapped, wasmTerm.cols); + } + + getNullCell(): IBufferCell { + return this.nullCell; + } + + private getWasmTerm(): GhosttyTerminal | undefined { + return (this.terminal as any).wasmTerm as GhosttyTerminal | undefined; + } +} + +// ============================================================================ +// BufferLine - Represents a single line in the buffer +// ============================================================================ + +/** + * A single line in the buffer + */ +export class BufferLine implements IBufferLine { + private cells: GhosttyCell[]; + private _isWrapped: boolean; + private _length: number; + + constructor(cells: GhosttyCell[], isWrapped: boolean, length: number) { + this.cells = cells; + this._isWrapped = isWrapped; + this._length = length; + } + + get length(): number { + return this._length; + } + + get isWrapped(): boolean { + return this._isWrapped; + } + + getCell(x: number): IBufferCell | undefined { + if (x < 0 || x >= this._length) { + return undefined; + } + + if (x >= this.cells.length) { + // Cell beyond what was returned (empty/null cell) + return new BufferCell( + { + codepoint: 0, + fg_r: 204, + fg_g: 204, + fg_b: 204, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }, + x + ); + } + + return new BufferCell(this.cells[x], x); + } + + translateToString(trimRight = false, startColumn = 0, endColumn = this._length): string { + // Clamp bounds + const start = Math.max(0, Math.min(startColumn, this._length)); + const end = Math.max(start, Math.min(endColumn, this._length)); + + let result = ''; + for (let x = start; x < end; x++) { + const cell = this.getCell(x); + if (cell) { + const chars = cell.getChars(); + result += chars; + } + } + + if (trimRight) { + result = result.trimEnd(); + } + + return result; + } +} + +// ============================================================================ +// BufferCell - Represents a single cell in the buffer +// ============================================================================ + +/** + * A single cell in the buffer + */ +export class BufferCell implements IBufferCell { + private cell: GhosttyCell; + private x: number; + + constructor(cell: GhosttyCell, x: number) { + this.cell = cell; + this.x = x; + } + + getChars(): string { + const codepoint = this.cell.codepoint; + + // Return empty string for null character or invalid codepoints + if (codepoint === 0) { + return ''; + } + + // Validate codepoint is within valid Unicode range + // Valid: 0x0000 to 0x10FFFF, excluding surrogates (0xD800-0xDFFF) + if (codepoint < 0 || codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)) { + // Return replacement character for invalid codepoints + return '\uFFFD'; + } + + return String.fromCodePoint(codepoint); + } + + getCode(): number { + return this.cell.codepoint; + } + + getWidth(): number { + return this.cell.width; + } + + getFgColorMode(): number { + // Return -1 for RGB (we always use RGB in our WASM implementation) + // xterm.js uses different values: + // 0 = default, 1 = palette 16, 2 = palette 256, 3 = RGB + // For simplicity, we return -1 for RGB + return -1; + } + + getBgColorMode(): number { + return -1; + } + + getFgColor(): number { + // Pack RGB into a single number: 0xRRGGBB + return (this.cell.fg_r << 16) | (this.cell.fg_g << 8) | this.cell.fg_b; + } + + getBgColor(): number { + // Pack RGB into a single number: 0xRRGGBB + return (this.cell.bg_r << 16) | (this.cell.bg_g << 8) | this.cell.bg_b; + } + + isBold(): number { + return (this.cell.flags & CellFlags.BOLD) !== 0 ? 1 : 0; + } + + isItalic(): number { + return (this.cell.flags & CellFlags.ITALIC) !== 0 ? 1 : 0; + } + + isUnderline(): number { + return (this.cell.flags & CellFlags.UNDERLINE) !== 0 ? 1 : 0; + } + + isStrikethrough(): number { + return (this.cell.flags & CellFlags.STRIKETHROUGH) !== 0 ? 1 : 0; + } + + isBlink(): number { + return (this.cell.flags & CellFlags.BLINK) !== 0 ? 1 : 0; + } + + isInverse(): number { + return (this.cell.flags & CellFlags.INVERSE) !== 0 ? 1 : 0; + } + + isInvisible(): number { + return (this.cell.flags & CellFlags.INVISIBLE) !== 0 ? 1 : 0; + } + + isFaint(): number { + return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0; + } + + /** + * Get hyperlink ID for this cell (0 = no link) + * Used by link detection system + */ + getHyperlinkId(): number { + return this.cell.hyperlink_id; + } + + /** + * Get the Unicode codepoint for this cell + * Used by link detection system + */ + getCodepoint(): number { + return this.cell.codepoint; + } + + /** + * Check if cell has dim/faint attribute + * Added for IBufferCell compatibility + */ + isDim(): boolean { + return (this.cell.flags & CellFlags.FAINT) !== 0; + } +} diff --git a/src/client/vendor/ghostty-web/event-emitter.ts b/src/client/vendor/ghostty-web/event-emitter.ts new file mode 100644 index 0000000..fa58ab5 --- /dev/null +++ b/src/client/vendor/ghostty-web/event-emitter.ts @@ -0,0 +1,27 @@ +import type { IDisposable, IEvent } from './interfaces'; + +export class EventEmitter { + private listeners: Array<(arg: T) => void> = []; + + fire(arg: T): void { + for (const listener of this.listeners) { + listener(arg); + } + } + + event: IEvent = (listener) => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + }, + }; + }; + + dispose(): void { + this.listeners = []; + } +} diff --git a/src/client/vendor/ghostty-web/ghostty-vt.wasm b/src/client/vendor/ghostty-web/ghostty-vt.wasm new file mode 100755 index 0000000000000000000000000000000000000000..79e560a54154160a2128480eb41ef7f198e900b7 GIT binary patch literal 423939 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℞fM^#9lzyP`B6?7#`Mb5_ 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 z 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 literal 0 HcmV?d00001 diff --git a/src/client/vendor/ghostty-web/ghostty-vt.wasm.json b/src/client/vendor/ghostty-web/ghostty-vt.wasm.json new file mode 100644 index 0000000..67f71ba --- /dev/null +++ b/src/client/vendor/ghostty-web/ghostty-vt.wasm.json @@ -0,0 +1,21 @@ +{ + "file": "ghostty-vt.wasm", + "source": { + "npm": "ghostty-web@0.4.0-next.20.g1858a59", + "path": "ghostty-vt.wasm", + "repo": "https://github.com/coder/ghostty-web", + "commit": "1858a5947767a3e1c9e98dbf53b2ff87fedb2aab" + }, + "ghostty": { + "repo": "https://github.com/ghostty-org/ghostty", + "commit": "5714ed07a1012573261b7b7e3ed2add9c1504496", + "patch": "patches/ghostty-wasm-api.patch", + "patchSha256": "a579f64a371d64d8f139bc76fb76bff77549d406236ca1153a32b3ff0eb87b90" + }, + "license": { + "ghostty": "MIT (Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors; LICENSE.ghostty)", + "patch": "MIT (Copyright (c) 2025 Coder; LICENSE)" + }, + "size": 423939, + "sha256": "794191ccd9f469ed0d49ebcb3e6b326d64279b91752b71ad80a3d89ff1f26416" +} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts new file mode 100644 index 0000000..f079885 --- /dev/null +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -0,0 +1,883 @@ +/** + * TypeScript wrapper for libghostty-vt WASM API + * + * High-performance terminal emulation using Ghostty's battle-tested VT100 parser. + * The key optimization is the RenderState API which provides a pre-computed + * snapshot of all render data in a single update call. + */ + +import { + CellFlags, + type Cursor, + DirtyState, + GHOSTTY_CONFIG_SIZE, + type GhosttyCell, + type GhosttyTerminalConfig, + type GhosttyWasmExports, + KeyEncoderOption, + type KeyEvent, + type KittyKeyFlags, + type RGB, + type RenderStateColors, + type RenderStateCursor, + type TerminalHandle, +} from './types'; + +// Re-export types for convenience +export { + CellFlags, + type Cursor, + DirtyState, + type GhosttyCell, + type GhosttyTerminalConfig, + KeyEncoderOption, + type RGB, + type RenderStateColors, + type RenderStateCursor, +}; + +/** + * Main Ghostty WASM wrapper class + */ +export class Ghostty { + private exports: GhosttyWasmExports; + private memory: WebAssembly.Memory; + + constructor(wasmInstance: WebAssembly.Instance) { + this.exports = wasmInstance.exports as GhosttyWasmExports; + this.memory = this.exports.memory; + } + + createKeyEncoder(): KeyEncoder { + return new KeyEncoder(this.exports); + } + + createTerminal( + cols: number = 80, + rows: number = 24, + config?: GhosttyTerminalConfig + ): GhosttyTerminal { + return new GhosttyTerminal(this.exports, this.memory, cols, rows, config); + } + + static async load(wasmPath?: string): Promise { + // If explicit path provided, use it + if (wasmPath) { + return Ghostty.loadFromPath(wasmPath); + } + + // Resolve path relative to this module + const moduleUrl = new URL('../ghostty-vt.wasm', import.meta.url); + + // Build paths to try, prioritizing file system paths for Node/Bun + const defaultPaths: string[] = []; + + // For Node/Bun: try absolute file path first (strip file:// protocol) + if (moduleUrl.protocol === 'file:') { + let filePath = moduleUrl.pathname; + // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) + if (filePath.match(/^\/[A-Za-z]:\//)) { + filePath = filePath.slice(1); + } + defaultPaths.push(filePath); + } + + // Also try other common paths + defaultPaths.push(moduleUrl.href, './ghostty-vt.wasm', '/ghostty-vt.wasm'); + + let lastError: Error | null = null; + for (const path of defaultPaths) { + try { + return await Ghostty.loadFromPath(path); + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + } + } + throw lastError || new Error('Failed to load Ghostty WASM'); + } + + private static async loadFromPath(path: string): Promise { + let wasmBytes: ArrayBuffer | undefined; + + // Try Bun.file first (for Bun environments) + if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { + try { + const file = Bun.file(path); + if (await file.exists()) { + wasmBytes = await file.arrayBuffer(); + } + } catch { + // Bun.file failed, try next method + } + } + + // Try Node.js fs module if Bun.file didn't work + if (!wasmBytes) { + try { + const fs = await import('fs/promises'); + const buffer = await fs.readFile(path); + wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } catch { + // fs failed, try fetch + } + } + + // Fall back to fetch (for browser environments) + if (!wasmBytes) { + const response = await fetch(path); + if (!response.ok) { + throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); + } + wasmBytes = await response.arrayBuffer(); + if (wasmBytes.byteLength === 0) { + throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); + } + } + + if (!wasmBytes) { + throw new Error(`Could not load WASM from path: ${path}`); + } + + const wasmModule = await WebAssembly.compile(wasmBytes); + const wasmInstance = await WebAssembly.instantiate(wasmModule, { + env: { + log: (ptr: number, len: number) => { + const bytes = new Uint8Array( + (wasmInstance.exports as GhosttyWasmExports).memory.buffer, + ptr, + len + ); + console.log('[ghostty-vt]', new TextDecoder().decode(bytes)); + }, + }, + }); + return new Ghostty(wasmInstance); + } +} + +/** + * Key Encoder - converts keyboard events into terminal escape sequences + */ +export class KeyEncoder { + private exports: GhosttyWasmExports; + private encoder: number = 0; + + constructor(exports: GhosttyWasmExports) { + this.exports = exports; + const encoderPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); + const result = this.exports.ghostty_key_encoder_new(0, encoderPtrPtr); + if (result !== 0) throw new Error(`Failed to create key encoder: ${result}`); + const view = new DataView(this.exports.memory.buffer); + this.encoder = view.getUint32(encoderPtrPtr, true); + this.exports.ghostty_wasm_free_opaque(encoderPtrPtr); + } + + setOption(option: KeyEncoderOption, value: boolean | number): void { + const valuePtr = this.exports.ghostty_wasm_alloc_u8(); + const view = new DataView(this.exports.memory.buffer); + view.setUint8(valuePtr, typeof value === 'boolean' ? (value ? 1 : 0) : value); + this.exports.ghostty_key_encoder_setopt(this.encoder, option, valuePtr); + this.exports.ghostty_wasm_free_u8(valuePtr); + } + + setKittyFlags(flags: KittyKeyFlags): void { + this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags); + } + + encode(event: KeyEvent): Uint8Array { + const eventPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); + const createResult = this.exports.ghostty_key_event_new(0, eventPtrPtr); + if (createResult !== 0) throw new Error(`Failed to create key event: ${createResult}`); + + const view = new DataView(this.exports.memory.buffer); + const eventPtr = view.getUint32(eventPtrPtr, true); + this.exports.ghostty_wasm_free_opaque(eventPtrPtr); + + this.exports.ghostty_key_event_set_action(eventPtr, event.action); + this.exports.ghostty_key_event_set_key(eventPtr, event.key); + this.exports.ghostty_key_event_set_mods(eventPtr, event.mods); + + if (event.utf8) { + const encoder = new TextEncoder(); + const utf8Bytes = encoder.encode(event.utf8); + const utf8Ptr = this.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length); + new Uint8Array(this.exports.memory.buffer).set(utf8Bytes, utf8Ptr); + this.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length); + this.exports.ghostty_wasm_free_u8_array(utf8Ptr, utf8Bytes.length); + } + + const bufferSize = 32; + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufferSize); + const writtenPtr = this.exports.ghostty_wasm_alloc_usize(); + + const encodeResult = this.exports.ghostty_key_encoder_encode( + this.encoder, + eventPtr, + bufPtr, + bufferSize, + writtenPtr + ); + + if (encodeResult !== 0) { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); + this.exports.ghostty_wasm_free_usize(writtenPtr); + this.exports.ghostty_key_event_free(eventPtr); + throw new Error(`Failed to encode key: ${encodeResult}`); + } + + const bytesWritten = view.getUint32(writtenPtr, true); + const encoded = new Uint8Array(this.exports.memory.buffer, bufPtr, bytesWritten).slice(); + + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); + this.exports.ghostty_wasm_free_usize(writtenPtr); + this.exports.ghostty_key_event_free(eventPtr); + + return encoded; + } + + dispose(): void { + if (this.encoder) { + this.exports.ghostty_key_encoder_free(this.encoder); + this.encoder = 0; + } + } +} + +/** + * GhosttyTerminal - High-performance terminal emulator + * + * Uses Ghostty's native RenderState for optimal performance: + * - ONE call to update all state (renderStateUpdate) + * - ONE call to get all cells (getViewport) + * - No per-row WASM boundary crossings! + */ +export class GhosttyTerminal { + private exports: GhosttyWasmExports; + private memory: WebAssembly.Memory; + private handle: TerminalHandle; + private _cols: number; + private _rows: number; + + /** Size of GhosttyCell in WASM (16 bytes) */ + private static readonly CELL_SIZE = 16; + + /** Reusable buffer for viewport operations */ + private viewportBufferPtr: number = 0; + private viewportBufferSize: number = 0; + + /** Cell pool for zero-allocation rendering */ + private cellPool: GhosttyCell[] = []; + + constructor( + exports: GhosttyWasmExports, + memory: WebAssembly.Memory, + cols: number = 80, + rows: number = 24, + config?: GhosttyTerminalConfig + ) { + this.exports = exports; + this.memory = memory; + this._cols = cols; + this._rows = rows; + + if (config) { + // Allocate config struct in WASM memory + const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); + if (configPtr === 0) { + throw new Error('Failed to allocate config (out of memory)'); + } + + try { + // Write config to WASM memory + const view = new DataView(this.memory.buffer); + let offset = configPtr; + + // scrollback_limit (u32) + view.setUint32(offset, config.scrollbackLimit ?? 10000, true); + offset += 4; + + // fg_color (u32) + view.setUint32(offset, config.fgColor ?? 0, true); + offset += 4; + + // bg_color (u32) + view.setUint32(offset, config.bgColor ?? 0, true); + offset += 4; + + // cursor_color (u32) + view.setUint32(offset, config.cursorColor ?? 0, true); + offset += 4; + + // palette[16] (u32 * 16) + for (let i = 0; i < 16; i++) { + view.setUint32(offset, config.palette?.[i] ?? 0, true); + offset += 4; + } + + this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); + } finally { + // Free the config memory + this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); + } + } else { + this.handle = this.exports.ghostty_terminal_new(cols, rows); + } + + if (!this.handle) throw new Error('Failed to create terminal'); + + this.initCellPool(); + } + + get cols(): number { + return this._cols; + } + get rows(): number { + return this._rows; + } + + // ========================================================================== + // Lifecycle + // ========================================================================== + + write(data: string | Uint8Array): void { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + const ptr = this.exports.ghostty_wasm_alloc_u8_array(bytes.length); + new Uint8Array(this.memory.buffer).set(bytes, ptr); + this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); + this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); + } + + resize(cols: number, rows: number): void { + if (cols === this._cols && rows === this._rows) return; + this._cols = cols; + this._rows = rows; + this.exports.ghostty_terminal_resize(this.handle, cols, rows); + this.invalidateBuffers(); + this.initCellPool(); + } + + free(): void { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + this.viewportBufferPtr = 0; + } + this.exports.ghostty_terminal_free(this.handle); + } + + // ========================================================================== + // RenderState API - The key performance optimization + // ========================================================================== + + /** + * Update render state from terminal. + * + * This syncs the RenderState with the current Terminal state. + * The dirty state (full/partial/none) is stored in the WASM RenderState + * and can be queried via isRowDirty(). When dirty==full, isRowDirty() + * returns true for ALL rows. + * + * The WASM layer automatically detects screen switches (normal <-> alternate) + * and returns FULL dirty state when switching screens (e.g., vim exit). + * + * Safe to call multiple times - dirty state persists until markClean(). + */ + update(): DirtyState { + return this.exports.ghostty_render_state_update(this.handle) as DirtyState; + } + + /** + * Get cursor state from render state. + * Ensures render state is fresh by calling update(). + */ + getCursor(): RenderStateCursor { + // Call update() to ensure render state is fresh. + // This is safe to call multiple times - dirty state persists until markClean(). + this.update(); + return { + x: this.exports.ghostty_render_state_get_cursor_x(this.handle), + y: this.exports.ghostty_render_state_get_cursor_y(this.handle), + viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle), + viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle), + visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle), + blinking: false, // TODO: Add blinking support + style: 'block', // TODO: Add style support + }; + } + + /** + * Get default colors from render state + */ + getColors(): RenderStateColors { + const bg = this.exports.ghostty_render_state_get_bg_color(this.handle); + const fg = this.exports.ghostty_render_state_get_fg_color(this.handle); + return { + background: { + r: (bg >> 16) & 0xff, + g: (bg >> 8) & 0xff, + b: bg & 0xff, + }, + foreground: { + r: (fg >> 16) & 0xff, + g: (fg >> 8) & 0xff, + b: fg & 0xff, + }, + cursor: null, // TODO: Add cursor color support + }; + } + + /** + * Check if a specific row is dirty + */ + isRowDirty(y: number): boolean { + return this.exports.ghostty_render_state_is_row_dirty(this.handle, y); + } + + /** + * Mark render state as clean (call after rendering) + */ + markClean(): void { + this.exports.ghostty_render_state_mark_clean(this.handle); + } + + /** + * Get ALL viewport cells in ONE WASM call - the key performance optimization! + * Returns a reusable cell array (zero allocation after warmup). + */ + getViewport(): GhosttyCell[] { + const totalCells = this._cols * this._rows; + const neededSize = totalCells * GhosttyTerminal.CELL_SIZE; + + // Ensure buffer is allocated + if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + } + this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); + this.viewportBufferSize = neededSize; + } + + // Get all cells in one call + const count = this.exports.ghostty_render_state_get_viewport( + this.handle, + this.viewportBufferPtr, + totalCells + ); + + if (count < 0) return this.cellPool; + + // Parse cells into pool (reuses existing objects) + this.parseCellsIntoPool(this.viewportBufferPtr, totalCells); + return this.cellPool; + } + + // ========================================================================== + // Compatibility methods (delegate to render state) + // ========================================================================== + + /** + * Get line - for compatibility, extracts from viewport. + * Ensures render state is fresh by calling update(). + * Returns a COPY of the cells to avoid pool reference issues. + */ + getLine(y: number): GhosttyCell[] | null { + if (y < 0 || y >= this._rows) return null; + // Call update() to ensure render state is fresh. + // This is safe to call multiple times - dirty state persists until markClean(). + this.update(); + const viewport = this.getViewport(); + const start = y * this._cols; + // Return deep copies to avoid cell pool reference issues + return viewport.slice(start, start + this._cols).map((cell) => ({ ...cell })); + } + + /** For compatibility with old API */ + isDirty(): boolean { + return this.update() !== DirtyState.NONE; + } + + /** + * Check if a full redraw is needed (screen change, resize, etc.) + * Note: This calls update() to ensure fresh state. Safe to call multiple times. + */ + needsFullRedraw(): boolean { + return this.update() === DirtyState.FULL; + } + + /** Mark render state as clean after rendering */ + clearDirty(): void { + this.markClean(); + } + + // ========================================================================== + // Terminal modes + // ========================================================================== + + isAlternateScreen(): boolean { + return !!this.exports.ghostty_terminal_is_alternate_screen(this.handle); + } + + hasBracketedPaste(): boolean { + // Mode 2004 = bracketed paste (DEC mode) + return this.getMode(2004, false); + } + + hasFocusEvents(): boolean { + // Mode 1004 = focus events (DEC mode) + return this.getMode(1004, false); + } + + hasMouseTracking(): boolean { + return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; + } + + // ========================================================================== + // Extended API (scrollback, modes, etc.) + // ========================================================================== + + /** Get dimensions - for compatibility */ + getDimensions(): { cols: number; rows: number } { + return { cols: this._cols, rows: this._rows }; + } + + /** Get number of scrollback lines (history, not including active screen) */ + getScrollbackLength(): number { + return this.exports.ghostty_terminal_get_scrollback_length(this.handle); + } + + /** + * Get a line from the scrollback buffer. + * Ensures render state is fresh by calling update(). + * @param offset 0 = oldest line, (length-1) = most recent scrollback line + */ + getScrollbackLine(offset: number): GhosttyCell[] | null { + const neededSize = this._cols * GhosttyTerminal.CELL_SIZE; + + // Ensure buffer is allocated + if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + } + this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); + this.viewportBufferSize = neededSize; + } + + // Call update() to ensure render state is fresh (needed for colors). + // This is safe to call multiple times - dirty state persists until markClean(). + this.update(); + + const count = this.exports.ghostty_terminal_get_scrollback_line( + this.handle, + offset, + this.viewportBufferPtr, + this._cols + ); + + if (count < 0) return null; + + // Parse cells + const cells: GhosttyCell[] = []; + const buffer = this.memory.buffer; + const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); + const view = new DataView(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); + + for (let i = 0; i < count; i++) { + const cellOffset = i * GhosttyTerminal.CELL_SIZE; + cells.push({ + codepoint: view.getUint32(cellOffset, true), + fg_r: u8[cellOffset + 4], + fg_g: u8[cellOffset + 5], + fg_b: u8[cellOffset + 6], + bg_r: u8[cellOffset + 7], + bg_g: u8[cellOffset + 8], + bg_b: u8[cellOffset + 9], + flags: u8[cellOffset + 10], + width: u8[cellOffset + 11], + hyperlink_id: view.getUint16(cellOffset + 12, true), + grapheme_len: u8[cellOffset + 14], + }); + } + + return cells; + } + + /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ + isRowWrapped(row: number): boolean { + return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; + } + + /** + * Get the hyperlink URI for a cell at the given position. + * @param row Row index (0-based, in active viewport) + * @param col Column index (0-based) + * @returns The URI string, or null if no hyperlink at that position + */ + getHyperlinkUri(row: number, col: number): string | null { + // Check if WASM has this function (requires rebuilt WASM with hyperlink support) + if (!this.exports.ghostty_terminal_get_hyperlink_uri) { + return null; + } + + // Try with initial buffer, retry with larger if needed (for very long URLs) + const bufferSizes = [2048, 8192, 32768]; + + for (const bufSize of bufferSizes) { + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + + try { + const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri( + this.handle, + row, + col, + bufPtr, + bufSize + ); + + // 0 means no hyperlink at this position + if (bytesWritten === 0) return null; + + // -1 means buffer too small, try next size + if (bytesWritten === -1) continue; + + // Negative values other than -1 are errors + if (bytesWritten < 0) return null; + + const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + } + } + + // URI too long even for largest buffer + return null; + } + + /** + * Get the hyperlink URI for a cell in the scrollback buffer. + * @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) + * @param col Column index (0-based) + * @returns The URI string, or null if no hyperlink at that position + */ + getScrollbackHyperlinkUri(offset: number, col: number): string | null { + // Check if WASM has this function + if (!this.exports.ghostty_terminal_get_scrollback_hyperlink_uri) { + return null; + } + + // Try with initial buffer, retry with larger if needed (for very long URLs) + const bufferSizes = [2048, 8192, 32768]; + + for (const bufSize of bufferSizes) { + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + + try { + const bytesWritten = this.exports.ghostty_terminal_get_scrollback_hyperlink_uri( + this.handle, + offset, + col, + bufPtr, + bufSize + ); + + // 0 means no hyperlink at this position + if (bytesWritten === 0) return null; + + // -1 means buffer too small, try next size + if (bytesWritten === -1) continue; + + // Negative values other than -1 are errors + if (bytesWritten < 0) return null; + + const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + } + } + + // URI too long even for largest buffer + return null; + } + + /** + * Check if there are pending responses from the terminal. + * Responses are generated by escape sequences like DSR (Device Status Report). + */ + hasResponse(): boolean { + return this.exports.ghostty_terminal_has_response(this.handle); + } + + /** + * Read pending responses from the terminal. + * Returns the response string, or null if no responses pending. + * + * Responses are generated by escape sequences that require replies: + * - DSR 6 (cursor position): Returns \x1b[row;colR + * - DSR 5 (operating status): Returns \x1b[0n + */ + readResponse(): string | null { + if (!this.hasResponse()) return null; + + const bufSize = 256; // Most responses are small + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + + try { + const bytesRead = this.exports.ghostty_terminal_read_response(this.handle, bufPtr, bufSize); + + if (bytesRead <= 0) return null; + + const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesRead); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + } + } + + /** + * Query arbitrary terminal mode by number + * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) + * @param isAnsi True for ANSI modes, false for DEC modes (default: false) + */ + getMode(mode: number, isAnsi: boolean = false): boolean { + return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi) !== 0; + } + + // ========================================================================== + // Private helpers + // ========================================================================== + + private initCellPool(): void { + const total = this._cols * this._rows; + if (this.cellPool.length < total) { + for (let i = this.cellPool.length; i < total; i++) { + this.cellPool.push({ + codepoint: 0, + fg_r: 204, + fg_g: 204, + fg_b: 204, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }); + } + } + } + + private parseCellsIntoPool(ptr: number, count: number): void { + const buffer = this.memory.buffer; + const u8 = new Uint8Array(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); + const view = new DataView(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); + + for (let i = 0; i < count; i++) { + const offset = i * GhosttyTerminal.CELL_SIZE; + const cell = this.cellPool[i]; + cell.codepoint = view.getUint32(offset, true); + cell.fg_r = u8[offset + 4]; + cell.fg_g = u8[offset + 5]; + cell.fg_b = u8[offset + 6]; + cell.bg_r = u8[offset + 7]; + cell.bg_g = u8[offset + 8]; + cell.bg_b = u8[offset + 9]; + cell.flags = u8[offset + 10]; + cell.width = u8[offset + 11]; + cell.hyperlink_id = view.getUint16(offset + 12, true); + cell.grapheme_len = u8[offset + 14]; // grapheme_len is at byte 14 + } + } + + /** Small buffer for grapheme lookups (reused to avoid allocation) */ + private graphemeBuffer: Uint32Array | null = null; + private graphemeBufferPtr: number = 0; + + /** + * Get all codepoints for a grapheme cluster at the given position. + * For most cells this returns a single codepoint, but for complex scripts + * (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints. + * @returns Array of codepoints, or null on error + */ + getGrapheme(row: number, col: number): number[] | null { + // Allocate buffer on first use (16 codepoints should be enough for any grapheme) + if (!this.graphemeBuffer) { + this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); + this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); + } + + const count = this.exports.ghostty_render_state_get_grapheme( + this.handle, + row, + col, + this.graphemeBufferPtr, + 16 + ); + + if (count < 0) return null; + + // Re-create view in case memory grew + const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); + return Array.from(view); + } + + /** + * Get a string representation of the grapheme at the given position. + * This properly handles complex scripts like Hindi, emoji with ZWJ, etc. + */ + getGraphemeString(row: number, col: number): string { + const codepoints = this.getGrapheme(row, col); + if (!codepoints || codepoints.length === 0) return ' '; + return String.fromCodePoint(...codepoints); + } + + /** + * Get all codepoints for a grapheme cluster in the scrollback buffer. + * @param offset Scrollback line offset (0 = oldest) + * @param col Column index + * @returns Array of codepoints, or null on error + */ + getScrollbackGrapheme(offset: number, col: number): number[] | null { + // Reuse the same buffer as getGrapheme + if (!this.graphemeBuffer) { + this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); + this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); + } + + const count = this.exports.ghostty_terminal_get_scrollback_grapheme( + this.handle, + offset, + col, + this.graphemeBufferPtr, + 16 + ); + + if (count < 0) return null; + + // Re-create view in case memory grew + const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); + return Array.from(view); + } + + /** + * Get a string representation of a grapheme in the scrollback buffer. + */ + getScrollbackGraphemeString(offset: number, col: number): string { + const codepoints = this.getScrollbackGrapheme(offset, col); + if (!codepoints || codepoints.length === 0) return ' '; + return String.fromCodePoint(...codepoints); + } + + private invalidateBuffers(): void { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + this.viewportBufferPtr = 0; + this.viewportBufferSize = 0; + } + if (this.graphemeBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.graphemeBufferPtr, 16 * 4); + this.graphemeBufferPtr = 0; + } + this.graphemeBuffer = null; + } +} diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts new file mode 100644 index 0000000..b46e05b --- /dev/null +++ b/src/client/vendor/ghostty-web/index.ts @@ -0,0 +1,101 @@ +/** + * Public API for @cmux/ghostty-terminal + * + * Main entry point following xterm.js conventions + */ + +import { Ghostty } from './ghostty'; + +// Module-level Ghostty instance (initialized by init()) +let ghosttyInstance: Ghostty | null = null; + +/** + * Initialize the ghostty-web library by loading the WASM module. + * Must be called before creating any Terminal instances. + * + * This creates a shared WASM instance that all Terminal instances will use. + * For test isolation, pass a Ghostty instance directly to Terminal constructor. + * + * @example + * ```typescript + * import { init, Terminal } from 'ghostty-web'; + * + * await init(); + * const term = new Terminal(); + * term.open(document.getElementById('terminal')); + * ``` + */ +export async function init(): Promise { + if (ghosttyInstance) { + return; // Already initialized + } + ghosttyInstance = await Ghostty.load(); +} + +/** + * Get the initialized Ghostty instance. + * Throws if init() hasn't been called. + * @internal + */ +export function getGhostty(): Ghostty { + if (!ghosttyInstance) { + throw new Error( + 'ghostty-web not initialized. Call init() before creating Terminal instances.\n' + + 'Example:\n' + + ' import { init, Terminal } from "ghostty-web";\n' + + ' await init();\n' + + ' const term = new Terminal();\n\n' + + 'For tests, pass a Ghostty instance directly:\n' + + ' import { Ghostty, Terminal } from "ghostty-web";\n' + + ' const ghostty = await Ghostty.load();\n' + + ' const term = new Terminal({ ghostty });' + ); + } + return ghosttyInstance; +} + +// Main Terminal class +export { Terminal } from './terminal'; + +// xterm.js-compatible interfaces +export type { + ITerminalOptions, + ITheme, + ITerminalAddon, + ITerminalCore, + IDisposable, + IEvent, + IBufferRange, + IKeyEvent, + IUnicodeVersionProvider, +} from './interfaces'; + +// Ghostty WASM components (for advanced usage) +export { + Ghostty, + GhosttyTerminal, + KeyEncoder, + CellFlags, + DirtyState, + KeyEncoderOption, +} from './ghostty'; +export { Key, KeyAction, Mods } from './types'; +export type { KeyEvent, GhosttyCell, RGB, Cursor, TerminalHandle } from './types'; + +// Low-level components (for custom integrations) +export { CanvasRenderer } from './renderer'; +export type { RendererOptions, FontMetrics, IRenderable } from './renderer'; +export { InputHandler } from './input-handler'; +export { EventEmitter } from './event-emitter'; +export { SelectionManager } from './selection-manager'; +export type { SelectionCoordinates } from './selection-manager'; + +// Addons +export { FitAddon } from './addons/fit'; +export type { ITerminalDimensions } from './addons/fit'; + +// Link providers +export { OSC8LinkProvider } from './providers/osc8-link-provider'; +export { UrlRegexProvider } from './providers/url-regex-provider'; +export { LinkDetector } from './link-detector'; +export type { ILink, ILinkProvider, IBufferCellPosition } from './types'; diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts new file mode 100644 index 0000000..83d6f3f --- /dev/null +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -0,0 +1,1106 @@ +/** + * InputHandler - Converts browser keyboard events to terminal input + * + * Handles: + * - Keyboard event listening on a container element + * - Mapping KeyboardEvent.code to USB HID Key codes + * - Extracting modifier keys (Ctrl, Alt, Shift, Meta) + * - Encoding keys using Ghostty's KeyEncoder + * - Emitting data for Terminal to send to PTY + * + * Limitations: + * - Does not handle IME/composition events (CJK input) - to be added later + * - Captures all keyboard input (preventDefault on everything) + */ + +import type { Ghostty } from './ghostty'; +import type { KeyEncoder } from './ghostty'; +import type { IKeyEvent } from './interfaces'; +import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; + +/** + * Map KeyboardEvent.code values to USB HID Key enum values + * Based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code + */ +const KEY_MAP: Record = { + // Letters + KeyA: Key.A, + KeyB: Key.B, + KeyC: Key.C, + KeyD: Key.D, + KeyE: Key.E, + KeyF: Key.F, + KeyG: Key.G, + KeyH: Key.H, + KeyI: Key.I, + KeyJ: Key.J, + KeyK: Key.K, + KeyL: Key.L, + KeyM: Key.M, + KeyN: Key.N, + KeyO: Key.O, + KeyP: Key.P, + KeyQ: Key.Q, + KeyR: Key.R, + KeyS: Key.S, + KeyT: Key.T, + KeyU: Key.U, + KeyV: Key.V, + KeyW: Key.W, + KeyX: Key.X, + KeyY: Key.Y, + KeyZ: Key.Z, + + // Numbers + Digit1: Key.ONE, + Digit2: Key.TWO, + Digit3: Key.THREE, + Digit4: Key.FOUR, + Digit5: Key.FIVE, + Digit6: Key.SIX, + Digit7: Key.SEVEN, + Digit8: Key.EIGHT, + Digit9: Key.NINE, + Digit0: Key.ZERO, + + // Special keys + Enter: Key.ENTER, + Escape: Key.ESCAPE, + Backspace: Key.BACKSPACE, + Tab: Key.TAB, + Space: Key.SPACE, + + // Punctuation + Minus: Key.MINUS, + Equal: Key.EQUAL, + BracketLeft: Key.BRACKET_LEFT, + BracketRight: Key.BRACKET_RIGHT, + Backslash: Key.BACKSLASH, + Semicolon: Key.SEMICOLON, + Quote: Key.QUOTE, + Backquote: Key.GRAVE, + Comma: Key.COMMA, + Period: Key.PERIOD, + Slash: Key.SLASH, + + // Function keys + CapsLock: Key.CAPS_LOCK, + F1: Key.F1, + F2: Key.F2, + F3: Key.F3, + F4: Key.F4, + F5: Key.F5, + F6: Key.F6, + F7: Key.F7, + F8: Key.F8, + F9: Key.F9, + F10: Key.F10, + F11: Key.F11, + F12: Key.F12, + + // Special function keys + PrintScreen: Key.PRINT_SCREEN, + ScrollLock: Key.SCROLL_LOCK, + Pause: Key.PAUSE, + Insert: Key.INSERT, + Home: Key.HOME, + PageUp: Key.PAGE_UP, + Delete: Key.DELETE, + End: Key.END, + PageDown: Key.PAGE_DOWN, + + // Arrow keys + ArrowRight: Key.RIGHT, + ArrowLeft: Key.LEFT, + ArrowDown: Key.DOWN, + ArrowUp: Key.UP, + + // Keypad + NumLock: Key.NUM_LOCK, + NumpadDivide: Key.KP_DIVIDE, + NumpadMultiply: Key.KP_MULTIPLY, + NumpadSubtract: Key.KP_MINUS, + NumpadAdd: Key.KP_PLUS, + NumpadEnter: Key.KP_ENTER, + Numpad1: Key.KP_1, + Numpad2: Key.KP_2, + Numpad3: Key.KP_3, + Numpad4: Key.KP_4, + Numpad5: Key.KP_5, + Numpad6: Key.KP_6, + Numpad7: Key.KP_7, + Numpad8: Key.KP_8, + Numpad9: Key.KP_9, + Numpad0: Key.KP_0, + NumpadDecimal: Key.KP_PERIOD, + + // International + IntlBackslash: Key.INTL_BACKSLASH, + ContextMenu: Key.CONTEXT_MENU, + + // Additional function keys + F13: Key.F13, + F14: Key.F14, + F15: Key.F15, + F16: Key.F16, + F17: Key.F17, + F18: Key.F18, + F19: Key.F19, + F20: Key.F20, + F21: Key.F21, + F22: Key.F22, + F23: Key.F23, + F24: Key.F24, +}; + +/** + * InputHandler class + * Attaches keyboard event listeners to a container and converts + * keyboard events to terminal input data + */ +/** + * Mouse tracking configuration + */ +export interface MouseTrackingConfig { + /** Check if any mouse tracking mode is enabled */ + hasMouseTracking: () => boolean; + /** Check if SGR extended mouse mode is enabled (mode 1006) */ + hasSgrMouseMode: () => boolean; + /** Get cell dimensions for pixel to cell conversion */ + getCellDimensions: () => { width: number; height: number }; + /** Get canvas/container offset for accurate position calculation */ + getCanvasOffset: () => { left: number; top: number }; +} + +export class InputHandler { + private encoder: KeyEncoder; + private container: HTMLElement; + private inputElement?: HTMLElement; + private onDataCallback: (data: string) => void; + private onBellCallback: () => void; + private onKeyCallback?: (keyEvent: IKeyEvent) => void; + private customKeyEventHandler?: (event: KeyboardEvent) => boolean; + private getModeCallback?: (mode: number) => boolean; + private onCopyCallback?: () => boolean; + private mouseConfig?: MouseTrackingConfig; + private keydownListener: ((e: KeyboardEvent) => void) | null = null; + private keypressListener: ((e: KeyboardEvent) => void) | null = null; + private pasteListener: ((e: ClipboardEvent) => void) | null = null; + private beforeInputListener: ((e: InputEvent) => void) | null = null; + private compositionStartListener: ((e: CompositionEvent) => void) | null = null; + private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null; + private compositionEndListener: ((e: CompositionEvent) => void) | null = null; + private mousedownListener: ((e: MouseEvent) => void) | null = null; + private mouseupListener: ((e: MouseEvent) => void) | null = null; + private mousemoveListener: ((e: MouseEvent) => void) | null = null; + private wheelListener: ((e: WheelEvent) => void) | null = null; + private isComposing = false; + private isDisposed = false; + private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting + private lastKeyDownData: string | null = null; + private lastKeyDownTime = 0; + private lastPasteData: string | null = null; + private lastPasteTime = 0; + private lastPasteSource: 'paste' | 'beforeinput' | null = null; + private lastCompositionData: string | null = null; + private lastCompositionTime = 0; + private lastBeforeInputData: string | null = null; + private lastBeforeInputTime = 0; + private static readonly BEFORE_INPUT_IGNORE_MS = 100; + + /** + * Create a new InputHandler + * @param ghostty - Ghostty instance (for creating KeyEncoder) + * @param container - DOM element to attach listeners to + * @param onData - Callback for terminal data (escape sequences to send to PTY) + * @param onBell - Callback for bell/beep event + * @param onKey - Optional callback for raw key events + * @param customKeyEventHandler - Optional custom key event handler + * @param getMode - Optional callback to query terminal mode state (for application cursor mode) + * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) + * @param inputElement - Optional input element for beforeinput events + * @param mouseConfig - Optional mouse tracking configuration + */ + constructor( + ghostty: Ghostty, + container: HTMLElement, + onData: (data: string) => void, + onBell: () => void, + onKey?: (keyEvent: IKeyEvent) => void, + customKeyEventHandler?: (event: KeyboardEvent) => boolean, + getMode?: (mode: number) => boolean, + onCopy?: () => boolean, + inputElement?: HTMLElement, + mouseConfig?: MouseTrackingConfig + ) { + this.encoder = ghostty.createKeyEncoder(); + this.container = container; + this.inputElement = inputElement; + this.onDataCallback = onData; + this.onBellCallback = onBell; + this.onKeyCallback = onKey; + this.customKeyEventHandler = customKeyEventHandler; + this.getModeCallback = getMode; + this.onCopyCallback = onCopy; + this.mouseConfig = mouseConfig; + + // Attach event listeners + this.attach(); + } + + /** + * Set custom key event handler (for runtime updates) + */ + setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void { + this.customKeyEventHandler = handler; + } + + /** + * Attach keyboard event listeners to container + */ + private attach(): void { + // Make container focusable so it can receive keyboard events (browser only) + if ( + typeof this.container.hasAttribute === 'function' && + typeof this.container.setAttribute === 'function' + ) { + if (!this.container.hasAttribute('tabindex')) { + this.container.setAttribute('tabindex', '0'); + } + + // Add visual focus indication (only if style exists - for browser environments) + if (this.container.style) { + this.container.style.outline = 'none'; // Remove default outline + } + } + + this.keydownListener = this.handleKeyDown.bind(this); + this.container.addEventListener('keydown', this.keydownListener); + + this.pasteListener = this.handlePaste.bind(this); + this.container.addEventListener('paste', this.pasteListener); + if (this.inputElement && this.inputElement !== this.container) { + this.inputElement.addEventListener('paste', this.pasteListener); + } + + if (this.inputElement) { + this.beforeInputListener = this.handleBeforeInput.bind(this); + this.inputElement.addEventListener('beforeinput', this.beforeInputListener); + } + + this.compositionStartListener = this.handleCompositionStart.bind(this); + this.container.addEventListener('compositionstart', this.compositionStartListener); + + this.compositionUpdateListener = this.handleCompositionUpdate.bind(this); + this.container.addEventListener('compositionupdate', this.compositionUpdateListener); + + this.compositionEndListener = this.handleCompositionEnd.bind(this); + this.container.addEventListener('compositionend', this.compositionEndListener); + + // Mouse event listeners (for terminal mouse tracking) + this.mousedownListener = this.handleMouseDown.bind(this); + this.container.addEventListener('mousedown', this.mousedownListener); + + this.mouseupListener = this.handleMouseUp.bind(this); + this.container.addEventListener('mouseup', this.mouseupListener); + + this.mousemoveListener = this.handleMouseMove.bind(this); + this.container.addEventListener('mousemove', this.mousemoveListener); + + this.wheelListener = this.handleWheel.bind(this); + this.container.addEventListener('wheel', this.wheelListener, { passive: false }); + } + + /** + * Map KeyboardEvent.code to USB HID Key enum value + * @param code - KeyboardEvent.code value + * @returns Key enum value or null if unmapped + */ + private mapKeyCode(code: string): Key | null { + return KEY_MAP[code] ?? null; + } + + /** + * Extract modifier flags from KeyboardEvent + * @param event - KeyboardEvent + * @returns Mods flags + */ + private extractModifiers(event: KeyboardEvent): Mods { + let mods = Mods.NONE; + + if (event.shiftKey) mods |= Mods.SHIFT; + if (event.ctrlKey) mods |= Mods.CTRL; + if (event.altKey) mods |= Mods.ALT; + if (event.metaKey) mods |= Mods.SUPER; + + // Note: CapsLock and NumLock are not in KeyboardEvent modifiers + // They would need to be tracked separately if needed + // For now, we don't set CAPSLOCK or NUMLOCK flags + + return mods; + } + + /** + * Check if this is a printable character with no special modifiers + * @param event - KeyboardEvent + * @returns true if printable character + */ + private isPrintableCharacter(event: KeyboardEvent): boolean { + // If Ctrl, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character + // Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters + if (event.ctrlKey && !event.altKey) return false; + if (event.altKey && !event.ctrlKey) return false; + if (event.metaKey) return false; // Cmd key on Mac + + // If key produces a single printable character + return event.key.length === 1; + } + + /** + * Handle keydown event + * @param event - KeyboardEvent + */ + private handleKeyDown(event: KeyboardEvent): void { + if (this.isDisposed) return; + + // Ignore keydown events during composition + // Note: Some browsers send keyCode 229 for all keys during composition + if (this.isComposing || event.isComposing || event.keyCode === 229) { + return; + } + + // Emit onKey event first (before any processing) + if (this.onKeyCallback) { + this.onKeyCallback({ key: event.key, domEvent: event }); + } + + // Check custom key event handler + if (this.customKeyEventHandler) { + const handled = this.customKeyEventHandler(event); + if (handled) { + // Custom handler consumed the event + event.preventDefault(); + return; + } + } + + // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault) + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { + // Let the browser's native paste event fire + return; + } + + // Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) + // Note: Ctrl+C on all platforms sends interrupt signal (0x03) + if (event.metaKey && event.code === 'KeyC') { + // Try to copy selection via callback + // If there's a selection and copy succeeds, prevent default + // If no selection, let it fall through (browser may have other text selected) + if (this.onCopyCallback && this.onCopyCallback()) { + event.preventDefault(); + } + return; + } + + // For printable characters without modifiers, send the character directly + // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc. + if (this.isPrintableCharacter(event)) { + event.preventDefault(); + this.onDataCallback(event.key); + this.recordKeyDownData(event.key); + return; + } + + // Map the physical key code + const key = this.mapKeyCode(event.code); + if (key === null) { + // Unknown key - ignore it + return; + } + + // Extract modifiers + const mods = this.extractModifiers(event); + + // Handle simple special keys that produce standard sequences + if (mods === Mods.NONE || mods === Mods.SHIFT) { + let simpleOutput: string | null = null; + + switch (key) { + case Key.ENTER: + simpleOutput = '\r'; // Carriage return + break; + case Key.TAB: + if (mods === Mods.SHIFT) { + simpleOutput = '\x1b[Z'; // Backtab + } else { + simpleOutput = '\t'; // Tab + } + break; + case Key.BACKSPACE: + simpleOutput = '\x7F'; // DEL (most terminals use 0x7F for backspace) + break; + case Key.ESCAPE: + simpleOutput = '\x1B'; // ESC + break; + // Arrow keys are handled by the encoder (respects application cursor mode) + // Navigation keys + case Key.HOME: + simpleOutput = '\x1B[H'; + break; + case Key.END: + simpleOutput = '\x1B[F'; + break; + case Key.INSERT: + simpleOutput = '\x1B[2~'; + break; + case Key.DELETE: + simpleOutput = '\x1B[3~'; + break; + case Key.PAGE_UP: + simpleOutput = '\x1B[5~'; + break; + case Key.PAGE_DOWN: + simpleOutput = '\x1B[6~'; + break; + // Function keys + case Key.F1: + simpleOutput = '\x1BOP'; + break; + case Key.F2: + simpleOutput = '\x1BOQ'; + break; + case Key.F3: + simpleOutput = '\x1BOR'; + break; + case Key.F4: + simpleOutput = '\x1BOS'; + break; + case Key.F5: + simpleOutput = '\x1B[15~'; + break; + case Key.F6: + simpleOutput = '\x1B[17~'; + break; + case Key.F7: + simpleOutput = '\x1B[18~'; + break; + case Key.F8: + simpleOutput = '\x1B[19~'; + break; + case Key.F9: + simpleOutput = '\x1B[20~'; + break; + case Key.F10: + simpleOutput = '\x1B[21~'; + break; + case Key.F11: + simpleOutput = '\x1B[23~'; + break; + case Key.F12: + simpleOutput = '\x1B[24~'; + break; + } + + if (simpleOutput !== null) { + event.preventDefault(); + this.onDataCallback(simpleOutput); + this.recordKeyDownData(simpleOutput); + return; + } + } + + // Determine action (we only care about PRESS for now, not RELEASE or REPEAT) + const action = KeyAction.PRESS; + + // For non-printable keys or keys with modifiers, encode using Ghostty + try { + // Sync encoder options with terminal mode state + // Mode 1 (DECCKM) controls whether arrow keys send CSI or SS3 sequences + if (this.getModeCallback) { + const appCursorMode = this.getModeCallback(1); + this.encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursorMode); + } + + // For letter/number keys, even with modifiers, pass the base character + // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01) + // For special keys (Enter, Arrow keys, etc.), don't pass utf8 + const utf8 = + event.key.length === 1 && event.key.charCodeAt(0) < 128 + ? event.key.toLowerCase() // Use lowercase for consistency + : undefined; + + const encoded = this.encoder.encode({ + action, + key, + mods, + utf8, + }); + + // Convert Uint8Array to string + const decoder = new TextDecoder(); + const data = decoder.decode(encoded); + + // Prevent default browser behavior + event.preventDefault(); + event.stopPropagation(); + + // Emit the data + if (data.length > 0) { + this.onDataCallback(data); + this.recordKeyDownData(data); + } + } catch (error) { + // Encoding failed - log but don't crash + console.warn('Failed to encode key:', event.code, error); + } + } + + /** + * Handle paste event from clipboard + * @param event - ClipboardEvent + */ + private handlePaste(event: ClipboardEvent): void { + if (this.isDisposed) return; + + // Prevent default paste behavior + event.preventDefault(); + event.stopPropagation(); + + // Get clipboard data + const clipboardData = event.clipboardData; + if (!clipboardData) { + console.warn('No clipboard data available'); + return; + } + + // Get text from clipboard + const text = clipboardData.getData('text/plain'); + if (!text) { + console.warn('No text in clipboard'); + return; + } + + if (this.shouldIgnorePasteEvent(text, 'paste')) { + return; + } + + this.emitPasteData(text); + this.recordPasteData(text, 'paste'); + } + + /** + * Handle beforeinput event (mobile/IME input) + * @param event - InputEvent + */ + private handleBeforeInput(event: InputEvent): void { + if (this.isDisposed) return; + + if (this.isComposing || event.isComposing) { + return; + } + + const inputType = event.inputType; + const data = event.data ?? ''; + let output: string | null = null; + + switch (inputType) { + case 'insertText': + case 'insertReplacementText': + output = data.length > 0 ? data.replace(/\n/g, '\r') : null; + break; + case 'insertLineBreak': + case 'insertParagraph': + output = '\r'; + break; + case 'deleteContentBackward': + output = '\x7F'; + break; + case 'deleteContentForward': + output = '\x1B[3~'; + break; + case 'insertFromPaste': + if (!data) { + return; + } + if (this.shouldIgnorePasteEvent(data, 'beforeinput')) { + event.preventDefault(); + event.stopPropagation(); + return; + } + event.preventDefault(); + event.stopPropagation(); + this.emitPasteData(data); + this.recordPasteData(data, 'beforeinput'); + return; + default: + return; + } + + if (!output) { + return; + } + + if (this.shouldIgnoreBeforeInput(output)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + if (data && this.shouldIgnoreBeforeInputFromComposition(data)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.onDataCallback(output); + if (data) { + this.recordBeforeInputData(data); + } + } + + /** + * Handle compositionstart event + */ + private handleCompositionStart(_event: CompositionEvent): void { + if (this.isDisposed) return; + this.isComposing = true; + } + + /** + * Handle compositionupdate event + */ + private handleCompositionUpdate(_event: CompositionEvent): void { + if (this.isDisposed) return; + // We could track the current composition string here if we wanted to + // display it in a custom way, but for now we rely on the browser's + // input method editor UI. + } + + /** + * Handle compositionend event + */ + private handleCompositionEnd(event: CompositionEvent): void { + if (this.isDisposed) return; + this.isComposing = false; + + const data = event.data; + if (data && data.length > 0) { + if (this.shouldIgnoreCompositionEnd(data)) { + this.cleanupCompositionTextNodes(); + return; + } + this.onDataCallback(data); + this.recordCompositionData(data); + } + + this.cleanupCompositionTextNodes(); + } + + /** + * Cleanup text nodes in container after composition + */ + private cleanupCompositionTextNodes(): void { + // Cleanup text nodes in container (fix for duplicate text display) + // When the container is contenteditable, the browser might insert text nodes + // upon composition end. We need to remove them to prevent duplicate display. + if (this.container && this.container.childNodes) { + for (let i = this.container.childNodes.length - 1; i >= 0; i--) { + const node = this.container.childNodes[i]; + // Node.TEXT_NODE === 3 + if (node.nodeType === 3) { + this.container.removeChild(node); + } + } + } + } + + // ========================================================================== + // Mouse Event Handling (for terminal mouse tracking) + // ========================================================================== + + /** + * Convert pixel coordinates to terminal cell coordinates + */ + private pixelToCell(event: MouseEvent): { col: number; row: number } | null { + if (!this.mouseConfig) return null; + + const dims = this.mouseConfig.getCellDimensions(); + const offset = this.mouseConfig.getCanvasOffset(); + + if (dims.width <= 0 || dims.height <= 0) return null; + + const x = event.clientX - offset.left; + const y = event.clientY - offset.top; + + // Convert to 1-based cell coordinates (terminal uses 1-based) + const col = Math.floor(x / dims.width) + 1; + const row = Math.floor(y / dims.height) + 1; + + // Clamp to valid range (at least 1) + return { + col: Math.max(1, col), + row: Math.max(1, row), + }; + } + + /** + * Get modifier flags for mouse event + */ + private getMouseModifiers(event: MouseEvent): number { + let mods = 0; + if (event.shiftKey) mods |= 4; + if (event.metaKey) mods |= 8; // Meta (Cmd on Mac) + if (event.ctrlKey) mods |= 16; + return mods; + } + + /** + * Encode mouse event as SGR sequence + * SGR format: \x1b[ + */ + private encodeMouseX10(button: number, col: number, row: number, modifiers: number): string { + // X10 format adds 32 to all values and encodes as characters + // Button encoding: 0=left, 1=middle, 2=right, 3=release + const btn = button + modifiers + 32; + const colChar = String.fromCharCode(Math.min(col + 32, 255)); + const rowChar = String.fromCharCode(Math.min(row + 32, 255)); + return `\x1b[M${String.fromCharCode(btn)}${colChar}${rowChar}`; + } + + /** + * Send mouse event to terminal + */ + private sendMouseEvent( + button: number, + col: number, + row: number, + isRelease: boolean, + event: MouseEvent + ): void { + const modifiers = this.getMouseModifiers(event); + + // Check if SGR extended mode is enabled (mode 1006) + const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true; + + let sequence: string; + if (useSGR) { + sequence = this.encodeMouseSGR(button, col, row, isRelease, modifiers); + } else { + // X10/normal mode doesn't support release events directly + // Button 3 means release in X10 mode + const x10Button = isRelease ? 3 : button; + sequence = this.encodeMouseX10(x10Button, col, row, modifiers); + } + + this.onDataCallback(sequence); + } + + /** + * Handle mousedown event + */ + private handleMouseDown(event: MouseEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + // Map browser button to terminal button + // event.button: 0=left, 1=middle, 2=right + // Terminal: 0=left, 1=middle, 2=right + const button = event.button; + + // Track pressed buttons for motion events + this.mouseButtonsPressed |= 1 << button; + + this.sendMouseEvent(button, cell.col, cell.row, false, event); + + // Don't prevent default - let SelectionManager handle selection + // Only prevent if we actually handled the event + // event.preventDefault(); + } + + /** + * Handle mouseup event + */ + private handleMouseUp(event: MouseEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + const button = event.button; + + // Clear pressed button + this.mouseButtonsPressed &= ~(1 << button); + + this.sendMouseEvent(button, cell.col, cell.row, true, event); + } + + /** + * Handle mousemove event + */ + private handleMouseMove(event: MouseEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + // Check if button motion mode or any-event tracking is enabled + // Mode 1002 = button motion, Mode 1003 = any motion + const hasButtonMotion = this.getModeCallback?.(1002) ?? false; + const hasAnyMotion = this.getModeCallback?.(1003) ?? false; + + if (!hasButtonMotion && !hasAnyMotion) return; + + // In button motion mode, only report if a button is pressed + if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + // Determine which button to report (or 32 for motion with no button) + let button = 32; // Motion flag + if (this.mouseButtonsPressed & 1) + button += 0; // Left + else if (this.mouseButtonsPressed & 2) + button += 1; // Middle + else if (this.mouseButtonsPressed & 4) button += 2; // Right + + this.sendMouseEvent(button, cell.col, cell.row, false, event); + } + + /** + * Handle wheel event (scroll) + */ + private handleWheel(event: WheelEvent): void { + if (this.isDisposed) return; + if (!this.mouseConfig?.hasMouseTracking()) return; + + const cell = this.pixelToCell(event); + if (!cell) return; + + // Wheel events: button 64 = scroll up, button 65 = scroll down + const button = event.deltaY < 0 ? 64 : 65; + + this.sendMouseEvent(button, cell.col, cell.row, false, event); + + // Prevent default scrolling when mouse tracking is active + event.preventDefault(); + } + + /** + * Emit paste data with bracketed paste support + */ + private emitPasteData(text: string): void { + const hasBracketedPaste = this.getModeCallback?.(2004) ?? false; + + if (hasBracketedPaste) { + this.onDataCallback('\x1b[200~' + text + '\x1b[201~'); + } else { + this.onDataCallback(text); + } + } + + /** + * Record keydown data for beforeinput de-duplication + */ + private recordKeyDownData(data: string): void { + this.lastKeyDownData = data; + this.lastKeyDownTime = this.getNow(); + } + + /** + * Record paste data for beforeinput de-duplication + */ + private recordPasteData(data: string, source: 'paste' | 'beforeinput'): void { + this.lastPasteData = data; + this.lastPasteTime = this.getNow(); + this.lastPasteSource = source; + } + + /** + * Check if beforeinput should be ignored due to a recent keydown + */ + private shouldIgnoreBeforeInput(data: string): boolean { + if (!this.lastKeyDownData) { + return false; + } + const now = this.getNow(); + const isDuplicate = + now - this.lastKeyDownTime < InputHandler.BEFORE_INPUT_IGNORE_MS && + this.lastKeyDownData === data; + this.lastKeyDownData = null; + return isDuplicate; + } + + /** + * Check if beforeinput text should be ignored due to a recent composition end + */ + private shouldIgnoreBeforeInputFromComposition(data: string): boolean { + if (!this.lastCompositionData) { + return false; + } + const now = this.getNow(); + const isDuplicate = + now - this.lastCompositionTime < InputHandler.BEFORE_INPUT_IGNORE_MS && + this.lastCompositionData === data; + if (isDuplicate) { + this.lastCompositionData = null; + } + return isDuplicate; + } + + /** + * Check if composition end should be ignored due to a recent beforeinput text + */ + private shouldIgnoreCompositionEnd(data: string): boolean { + if (!this.lastBeforeInputData) { + return false; + } + const now = this.getNow(); + const isDuplicate = + now - this.lastBeforeInputTime < InputHandler.BEFORE_INPUT_IGNORE_MS && + this.lastBeforeInputData === data; + if (isDuplicate) { + this.lastBeforeInputData = null; + } + return isDuplicate; + } + + /** + * Record beforeinput text for composition de-duplication + */ + private recordBeforeInputData(data: string): void { + this.lastBeforeInputData = data; + this.lastBeforeInputTime = this.getNow(); + } + + /** + * Record composition end data for beforeinput de-duplication + */ + private recordCompositionData(data: string): void { + this.lastCompositionData = data; + this.lastCompositionTime = this.getNow(); + } + + /** + * Check if paste should be ignored due to a recent paste event from another source + */ + private shouldIgnorePasteEvent(data: string, source: 'paste' | 'beforeinput'): boolean { + if (!this.lastPasteData) { + return false; + } + if (this.lastPasteSource === source) { + return false; + } + const now = this.getNow(); + const isDuplicate = + now - this.lastPasteTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastPasteData === data; + if (isDuplicate) { + this.lastPasteData = null; + this.lastPasteSource = null; + } + return isDuplicate; + } + + /** + * Get current time in milliseconds + */ + private getNow(): number { + return typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + } + + /** + * Dispose the InputHandler and remove event listeners + */ + dispose(): void { + if (this.isDisposed) return; + + if (this.keydownListener) { + this.container.removeEventListener('keydown', this.keydownListener); + this.keydownListener = null; + } + + if (this.keypressListener) { + this.container.removeEventListener('keypress', this.keypressListener); + this.keypressListener = null; + } + + if (this.pasteListener) { + this.container.removeEventListener('paste', this.pasteListener); + if (this.inputElement && this.inputElement !== this.container) { + this.inputElement.removeEventListener('paste', this.pasteListener); + } + this.pasteListener = null; + } + + if (this.beforeInputListener && this.inputElement) { + this.inputElement.removeEventListener('beforeinput', this.beforeInputListener); + this.beforeInputListener = null; + } + + if (this.compositionStartListener) { + this.container.removeEventListener('compositionstart', this.compositionStartListener); + this.compositionStartListener = null; + } + + if (this.compositionUpdateListener) { + this.container.removeEventListener('compositionupdate', this.compositionUpdateListener); + this.compositionUpdateListener = null; + } + + if (this.compositionEndListener) { + this.container.removeEventListener('compositionend', this.compositionEndListener); + this.compositionEndListener = null; + } + + if (this.mousedownListener) { + this.container.removeEventListener('mousedown', this.mousedownListener); + this.mousedownListener = null; + } + + if (this.mouseupListener) { + this.container.removeEventListener('mouseup', this.mouseupListener); + this.mouseupListener = null; + } + + if (this.mousemoveListener) { + this.container.removeEventListener('mousemove', this.mousemoveListener); + this.mousemoveListener = null; + } + + if (this.wheelListener) { + this.container.removeEventListener('wheel', this.wheelListener); + this.wheelListener = null; + } + + this.isDisposed = true; + } + + /** + * Check if handler is disposed + */ + isActive(): boolean { + return !this.isDisposed; + } +} diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts new file mode 100644 index 0000000..4c6fbe4 --- /dev/null +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -0,0 +1,218 @@ +/** + * xterm.js-compatible interfaces + */ + +import type { Ghostty } from './ghostty'; + +export interface ITerminalOptions { + cols?: number; // Default: 80 + rows?: number; // Default: 24 + cursorBlink?: boolean; // Default: false + cursorStyle?: 'block' | 'underline' | 'bar'; + theme?: ITheme; + scrollback?: number; // Default: 10000 + fontSize?: number; // Default: 15 + fontFamily?: string; // Default: 'monospace' + allowTransparency?: boolean; + + // Phase 1 additions + convertEol?: boolean; // Convert \n to \r\n (default: false) + disableStdin?: boolean; // Disable keyboard input (default: false) + + // Scrolling options + smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant) + + // Internal: Ghostty WASM instance (optional, for test isolation) + // If not provided, uses the module-level instance from init() + ghostty?: Ghostty; +} + +export interface ITheme { + foreground?: string; + background?: string; + cursor?: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + + // ANSI colors (0-15) + black?: string; + red?: string; + green?: string; + yellow?: string; + blue?: string; + magenta?: string; + cyan?: string; + white?: string; + brightBlack?: string; + brightRed?: string; + brightGreen?: string; + brightYellow?: string; + brightBlue?: string; + brightMagenta?: string; + brightCyan?: string; + brightWhite?: string; +} + +export interface IDisposable { + dispose(): void; +} + +export type IEvent = (listener: (arg: T) => void) => IDisposable; + +export interface ITerminalAddon { + activate(terminal: ITerminalCore): void; + dispose(): void; +} + +export interface ITerminalCore { + cols: number; + rows: number; + element?: HTMLElement; + textarea?: HTMLTextAreaElement; +} + +/** + * Buffer range for selection coordinates + */ +export interface IBufferRange { + start: { x: number; y: number }; + end: { x: number; y: number }; +} + +/** + * Keyboard event with key and DOM event + */ +export interface IKeyEvent { + key: string; + domEvent: KeyboardEvent; +} + +/** + * Unicode version provider (xterm.js compatibility) + */ +export interface IUnicodeVersionProvider { + readonly activeVersion: string; +} + +// ============================================================================ +// Buffer API Interfaces (xterm.js compatibility) +// ============================================================================ + +/** + * Top-level buffer API namespace + * Provides access to active, normal, and alternate screen buffers + */ +export interface IBufferNamespace { + /** The currently active buffer (normal or alternate) */ + readonly active: IBuffer; + /** The normal buffer (primary screen) */ + readonly normal: IBuffer; + /** The alternate buffer (used by full-screen apps like vim) */ + readonly alternate: IBuffer; + + /** Event fired when buffer changes (normal ↔ alternate) */ + readonly onBufferChange: IEvent; +} + +/** + * A terminal buffer (normal or alternate screen) + */ +export interface IBuffer { + /** Buffer type: 'normal' or 'alternate' */ + readonly type: 'normal' | 'alternate'; + /** Cursor X position (0-indexed) */ + readonly cursorX: number; + /** Cursor Y position (0-indexed, relative to viewport) */ + readonly cursorY: number; + /** Viewport Y position (scroll offset, 0 = bottom of scrollback) */ + readonly viewportY: number; + /** Base Y position (always 0 for normal buffer, may vary for alternate) */ + readonly baseY: number; + /** Total buffer length (rows + scrollback for normal, just rows for alternate) */ + readonly length: number; + + /** + * Get a line from the buffer + * @param y Line index (0 = top of scrollback for normal buffer) + * @returns Line object or undefined if out of bounds + */ + getLine(y: number): IBufferLine | undefined; + + /** + * Get the null cell (used for empty/uninitialized cells) + */ + getNullCell(): IBufferCell; +} + +/** + * A single line in the buffer + */ +export interface IBufferLine { + /** Length of the line (in columns) */ + readonly length: number; + /** Whether this line wraps to the next line */ + readonly isWrapped: boolean; + + /** + * Get a cell from this line + * @param x Column index (0-indexed) + * @returns Cell object or undefined if out of bounds + */ + getCell(x: number): IBufferCell | undefined; + + /** + * Translate the line to a string + * @param trimRight Whether to trim trailing whitespace (default: false) + * @param startColumn Start column (default: 0) + * @param endColumn End column (default: length) + * @returns String representation of the line + */ + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} + +/** + * A single cell in the buffer + */ +export interface IBufferCell { + /** Character(s) in this cell (may be empty, single char, or emoji) */ + getChars(): string; + /** Unicode codepoint (0 for null cell) */ + getCode(): number; + /** Character width (1 = normal, 2 = wide/emoji, 0 = combining) */ + getWidth(): number; + + /** Foreground color index (for palette colors) or -1 for RGB */ + getFgColorMode(): number; + /** Background color index (for palette colors) or -1 for RGB */ + getBgColorMode(): number; + /** Foreground RGB color (or 0 for default) */ + getFgColor(): number; + /** Background RGB color (or 0 for default) */ + getBgColor(): number; + + /** Whether cell has bold style */ + isBold(): number; + /** Whether cell has italic style */ + isItalic(): number; + /** Whether cell has underline style */ + isUnderline(): number; + /** Whether cell has strikethrough style */ + isStrikethrough(): number; + /** Whether cell has blink style */ + isBlink(): number; + /** Whether cell has inverse video style */ + isInverse(): number; + /** Whether cell has invisible style */ + isInvisible(): number; + /** Whether cell has faint/dim style */ + isFaint(): number; + + // Link detection support + /** Get hyperlink ID for this cell (0 = no link) */ + getHyperlinkId(): number; + /** Get the Unicode codepoint for this cell */ + getCodepoint(): number; + /** Whether cell has dim/faint attribute (boolean version) */ + isDim(): boolean; +} diff --git a/src/client/vendor/ghostty-web/link-detector.ts b/src/client/vendor/ghostty-web/link-detector.ts new file mode 100644 index 0000000..3c5b66a --- /dev/null +++ b/src/client/vendor/ghostty-web/link-detector.ts @@ -0,0 +1,215 @@ +/** + * Link detection and caching system + * + * The LinkDetector coordinates between multiple link providers and caches + * results for performance. Links are cached by position range, and earlier + * providers (e.g. OSC8) take precedence over later ones (e.g. regex). + */ + +import type { IBufferCellPosition, ILink, ILinkProvider } from './types'; + +/** + * Manages link detection across multiple providers with intelligent caching + */ +export class LinkDetector { + private providers: ILinkProvider[] = []; + + // Cache links by position range: `r${row}:${startX}-${endX}` + private linkCache = new Map(); + + // Track which rows have been scanned to avoid redundant provider calls + private scannedRows = new Set(); + + // Terminal instance for buffer access + constructor(private terminal: ITerminalForLinkDetector) {} + + /** + * Register a link provider + */ + registerProvider(provider: ILinkProvider): void { + this.providers.push(provider); + this.invalidateCache(); // New provider may detect different links + } + + /** + * Get link at the specified buffer position + * @param col Column (0-based) + * @param row Absolute row in buffer (0-based) + * @returns Link at position, or undefined if none + */ + async getLinkAt(col: number, row: number): Promise { + const line = this.terminal.buffer.active.getLine(row); + if (!line || col < 0 || col >= line.length) { + return undefined; + } + + const cell = line.getCell(col); + if (!cell) { + return undefined; + } + + // Check if any cached link contains this position (fast path) + for (const link of this.linkCache.values()) { + if (this.isPositionInLink(col, row, link)) { + return link; + } + } + + // Slow path: scan this row if not already scanned + if (!this.scannedRows.has(row)) { + await this.scanRow(row); + } + + // Check cache again after scanning + for (const link of this.linkCache.values()) { + if (this.isPositionInLink(col, row, link)) { + return link; + } + } + + return undefined; + } + + /** + * Scan a row for links using all registered providers + */ + private async scanRow(row: number): Promise { + this.scannedRows.add(row); + + const allLinks: ILink[] = []; + + // Query all providers + for (const provider of this.providers) { + const links = await new Promise((resolve) => { + provider.provideLinks(row, resolve); + }); + + if (links) { + allLinks.push(...links); + } + } + + // Cache all discovered links + for (const link of allLinks) { + this.cacheLink(link); + } + } + + /** + * Cache a link for fast lookup + * + * Note: We cache by position range, not hyperlink_id, because the WASM + * returns hyperlink_id as a boolean (0 or 1), not a unique identifier. + * The actual unique identifier is the URI which is retrieved separately. + */ + private cacheLink(link: ILink): void { + // Cache by position range - this uniquely identifies links even when + // multiple OSC 8 links exist on the same line + const { start: s, end: e } = link.range; + const cacheKey = `r${s.y}:${s.x}-${e.x}`; + // Don't overwrite existing entries - earlier providers (OSC8) take + // precedence over later ones (regex) for the same range + if (!this.linkCache.has(cacheKey)) { + this.linkCache.set(cacheKey, link); + } + } + + /** + * Check if a position is within a link's range + */ + private isPositionInLink(col: number, row: number, link: ILink): boolean { + const { start, end } = link.range; + + // Check if row is in range + if (row < start.y || row > end.y) { + return false; + } + + // Single-line link + if (start.y === end.y) { + return col >= start.x && col <= end.x; + } + + // Multi-line link + if (row === start.y) { + return col >= start.x; // First line: from start.x to end of line + } else if (row === end.y) { + return col <= end.x; // Last line: from start of line to end.x + } else { + return true; // Middle line: entire line is part of link + } + } + + /** + * Invalidate cache when terminal content changes + * Should be called on terminal write, resize, or clear + */ + invalidateCache(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + } + + /** + * Invalidate cache for specific rows + * Used when only part of the terminal changed + */ + invalidateRows(startRow: number, endRow: number): void { + // Remove scanned markers + for (let row = startRow; row <= endRow; row++) { + this.scannedRows.delete(row); + } + + // Remove cached links in this range + // This is conservative - we remove any link that touches these rows + const toDelete: string[] = []; + for (const [key, link] of this.linkCache.entries()) { + const { start, end } = link.range; + if ( + (start.y >= startRow && start.y <= endRow) || + (end.y >= startRow && end.y <= endRow) || + (start.y < startRow && end.y > endRow) + ) { + toDelete.push(key); + } + } + + for (const key of toDelete) { + this.linkCache.delete(key); + } + } + + /** + * Dispose and cleanup + */ + dispose(): void { + this.linkCache.clear(); + this.scannedRows.clear(); + + // Dispose all providers + for (const provider of this.providers) { + provider.dispose?.(); + } + this.providers = []; + } +} + +/** + * Minimal terminal interface required by LinkDetector + * Keeps coupling low and testing easy + */ +export interface ITerminalForLinkDetector { + buffer: { + active: { + getLine(y: number): + | { + length: number; + getCell(x: number): + | { + getHyperlinkId(): number; + } + | undefined; + } + | undefined; + }; + }; +} diff --git a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts new file mode 100644 index 0000000..aa4e8d2 --- /dev/null +++ b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts @@ -0,0 +1,256 @@ +/** + * OSC 8 Hyperlink Provider + * + * Detects hyperlinks created with OSC 8 escape sequences. + * Supports multi-line links that wrap across lines. + * + * OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07 + * + * The Ghostty WASM automatically assigns hyperlink_id to cells, + * so we just need to scan for contiguous regions with the same ID. + */ + +import type { IBufferRange, ILink, ILinkProvider } from '../types'; + +/** + * OSC 8 Hyperlink Provider + * + * Detects OSC 8 hyperlinks by scanning for hyperlink_id in cells. + * Automatically handles multi-line links since Ghostty WASM preserves + * hyperlink_id across wrapped lines. + */ +export class OSC8LinkProvider implements ILinkProvider { + constructor(private terminal: ITerminalForOSC8Provider) {} + + /** + * Provide all OSC 8 links on the given row + * Note: This may return links that span multiple rows + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const links: ILink[] = []; + const visitedPositions = new Set(); // Track which columns we've already processed + + const line = this.terminal.buffer.active.getLine(y); + if (!line) { + callback(undefined); + return; + } + + // Scan through this line looking for hyperlink_id + for (let x = 0; x < line.length; x++) { + // Skip already processed positions + if (visitedPositions.has(x)) continue; + + const cell = line.getCell(x); + if (!cell) continue; + + const hyperlinkId = cell.getHyperlinkId(); + + // Skip cells without links + if (hyperlinkId === 0) { + continue; + } + + // Get the URI from WASM + // The y parameter is a buffer row - we need to determine if it's in + // scrollback or the active viewport and use the appropriate API + if (!this.terminal.wasmTerm) continue; + const scrollbackLength = this.terminal.wasmTerm.getScrollbackLength(); + const viewportRow = y - scrollbackLength; + + let uri: string | null; + if (viewportRow < 0) { + // Row is in scrollback - use scrollback API + // y is the buffer row (0 = oldest), scrollback offset is also 0 = oldest + uri = this.terminal.wasmTerm.getScrollbackHyperlinkUri(y, x); + } else { + // Row is in active viewport + uri = this.terminal.wasmTerm.getHyperlinkUri(viewportRow, x); + } + + if (uri) { + // Find the end of this link by scanning forward until we hit a cell + // without a hyperlink or with a different URI + let endX = x; + for (let col = x + 1; col < line.length; col++) { + const nextCell = line.getCell(col); + if (!nextCell || nextCell.getHyperlinkId() === 0) break; + + // Check if this cell has the same URI (use appropriate API for scrollback vs viewport) + const nextUri = + viewportRow < 0 + ? this.terminal.wasmTerm!.getScrollbackHyperlinkUri(y, col) + : this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col); + if (nextUri !== uri) break; + + endX = col; + } + + // Mark all columns in this link as visited + for (let col = x; col <= endX; col++) { + visitedPositions.add(col); + } + + const range: IBufferRange = { + start: { x, y }, + end: { x: endX, y }, + }; + + links.push({ + text: uri, + range, + activate: (event) => { + // Open link if Ctrl/Cmd is pressed + if (event.ctrlKey || event.metaKey) { + window.open(uri, '_blank', 'noopener,noreferrer'); + } + }, + }); + } + } + + callback(links.length > 0 ? links : undefined); + } + + /** + * Find the full extent of a link by scanning for contiguous cells + * with the same hyperlink_id. Handles multi-line links. + */ + private findLinkRange(hyperlinkId: number, startY: number, startX: number): IBufferRange { + const buffer = this.terminal.buffer.active; + + // Find the start of the link (scan backwards) + let minY = startY; + let minX = startX; + + // Scan backwards on current line + while (minX > 0) { + const line = buffer.getLine(minY); + if (!line) break; + + const cell = line.getCell(minX - 1); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; + minX--; + } + + // If at start of line, check if link continues from previous line + if (minX === 0 && minY > 0) { + let prevY = minY - 1; + + while (prevY >= 0) { + const prevLine = buffer.getLine(prevY); + if (!prevLine || prevLine.length === 0) break; + + // Check if last cell of previous line has same hyperlink_id + const lastCell = prevLine.getCell(prevLine.length - 1); + if (!lastCell || lastCell.getHyperlinkId() !== hyperlinkId) break; + + // Link continues from previous line - find where it starts + minY = prevY; + minX = 0; + + // Scan backwards on this line + for (let x = prevLine.length - 1; x >= 0; x--) { + const cell = prevLine.getCell(x); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) { + minX = x + 1; + break; + } + } + + // If entire line is part of link, continue to previous line + if (minX === 0) { + prevY--; + } else { + break; + } + } + } + + // Find the end of the link (scan forwards) + let maxY = startY; + let maxX = startX; + + // Scan forwards on current line + const currentLine = buffer.getLine(maxY); + if (currentLine) { + while (maxX < currentLine.length - 1) { + const cell = currentLine.getCell(maxX + 1); + if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; + maxX++; + } + + // If at end of line, check if link continues to next line + if (maxX === currentLine.length - 1) { + let nextY = maxY + 1; + const maxBuffer = buffer.length; + + while (nextY < maxBuffer) { + const nextLine = buffer.getLine(nextY); + if (!nextLine || nextLine.length === 0) break; + + // Check if first cell of next line has same hyperlink_id + const firstCell = nextLine.getCell(0); + if (!firstCell || firstCell.getHyperlinkId() !== hyperlinkId) break; + + // Link continues to next line - find where it ends + maxY = nextY; + maxX = 0; + + // Scan forwards on this line + for (let x = 0; x < nextLine.length; x++) { + const cell = nextLine.getCell(x); + if (!cell) break; + if (cell.getHyperlinkId() !== hyperlinkId) { + maxX = x - 1; + break; + } + maxX = x; + } + + // If entire line is part of link, continue to next line + if (maxX === nextLine.length - 1) { + nextY++; + } else { + break; + } + } + } + } + + return { + start: { x: minX, y: minY }, + end: { x: maxX, y: maxY }, + }; + } + + dispose(): void { + // No resources to clean up + } +} + +/** + * Minimal terminal interface required by OSC8LinkProvider + */ +export interface ITerminalForOSC8Provider { + buffer: { + active: { + length: number; + getLine(y: number): + | { + length: number; + getCell(x: number): + | { + getHyperlinkId(): number; + } + | undefined; + } + | undefined; + }; + }; + wasmTerm?: { + getHyperlinkUri(row: number, col: number): string | null; + getScrollbackHyperlinkUri(offset: number, col: number): string | null; + getScrollbackLength(): number; + }; +} diff --git a/src/client/vendor/ghostty-web/providers/url-regex-provider.ts b/src/client/vendor/ghostty-web/providers/url-regex-provider.ts new file mode 100644 index 0000000..82dad11 --- /dev/null +++ b/src/client/vendor/ghostty-web/providers/url-regex-provider.ts @@ -0,0 +1,150 @@ +/** + * URL Regex Link Provider + * + * Detects plain text URLs using regex pattern matching. + * Supports common protocols but excludes file paths. + * + * This provider runs after OSC8LinkProvider, so explicit hyperlinks + * take precedence over regex-detected URLs. + */ + +import type { IBufferRange, ILink, ILinkProvider } from '../types'; + +/** + * URL Regex Provider + * + * Detects plain text URLs on a single line using regex. + * Does not support multi-line URLs or file paths. + * + * Supported protocols: + * - https://, http:// + * - mailto: + * - ftp://, ssh://, git:// + * - tel:, magnet: + * - gemini://, gopher://, news: + */ +export class UrlRegexProvider implements ILinkProvider { + /** + * URL regex pattern + * Matches common protocols followed by valid URL characters + * Excludes file paths (no ./ or ../ or bare /) + */ + private static readonly URL_REGEX = + /(?:https?:\/\/|mailto:|ftp:\/\/|ssh:\/\/|git:\/\/|tel:|magnet:|gemini:\/\/|gopher:\/\/|news:)[\w\-.~:\/?#@!$&*+,;=%]+/gi; + + /** + * Characters to strip from end of URLs + * Common punctuation that's unlikely to be part of the URL + */ + private static readonly TRAILING_PUNCTUATION = /[.,;!?)\]]+$/; + + constructor(private terminal: ITerminalForUrlProvider) {} + + /** + * Provide all regex-detected URLs on the given row + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const links: ILink[] = []; + + const line = this.terminal.buffer.active.getLine(y); + if (!line) { + callback(undefined); + return; + } + + // Convert line cells to text + const lineText = this.lineToText(line); + + // Reset regex state (global flag maintains state) + UrlRegexProvider.URL_REGEX.lastIndex = 0; + + // Find all URL matches in the line + let match: RegExpExecArray | null = UrlRegexProvider.URL_REGEX.exec(lineText); + while (match !== null) { + let url = match[0]; + const startX = match.index; + let endX = match.index + url.length - 1; // Inclusive end + + // Strip trailing punctuation + const stripped = url.replace(UrlRegexProvider.TRAILING_PUNCTUATION, ''); + if (stripped.length < url.length) { + url = stripped; + endX = startX + url.length - 1; + } + + // Skip if URL is too short (e.g., just "http://") + if (url.length > 8) { + links.push({ + text: url, + range: { + start: { x: startX, y }, + end: { x: endX, y }, + }, + activate: (event) => { + // Open link if Ctrl/Cmd is pressed + if (event.ctrlKey || event.metaKey) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }, + }); + } + + // Get next match + match = UrlRegexProvider.URL_REGEX.exec(lineText); + } + + callback(links.length > 0 ? links : undefined); + } + + /** + * Convert a buffer line to plain text string + */ + private lineToText(line: IBufferLineForUrlProvider): string { + const chars: string[] = []; + + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) { + chars.push(' '); + continue; + } + + const codepoint = cell.getCodepoint(); + // Skip null characters and control characters + if (codepoint === 0 || codepoint < 32) { + chars.push(' '); + } else { + chars.push(String.fromCodePoint(codepoint)); + } + } + + return chars.join(''); + } + + dispose(): void { + // No resources to clean up + } +} + +/** + * Minimal terminal interface required by UrlRegexProvider + */ +export interface ITerminalForUrlProvider { + buffer: { + active: { + getLine(y: number): IBufferLineForUrlProvider | undefined; + }; + }; +} + +/** + * Minimal buffer line interface for URL detection + */ +interface IBufferLineForUrlProvider { + length: number; + getCell(x: number): + | { + getCodepoint(): number; + } + | undefined; +} diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts new file mode 100644 index 0000000..3b51bfd --- /dev/null +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -0,0 +1,1001 @@ +/** + * Canvas Renderer for Terminal Display + * + * High-performance canvas-based renderer that draws the terminal using + * Ghostty's WASM terminal emulator. Features: + * - Font metrics measurement with DPI scaling + * - Full color support (256-color palette + RGB) + * - All text styles (bold, italic, underline, strikethrough, etc.) + * - Multiple cursor styles (block, underline, bar) + * - Dirty line optimization for 60 FPS + */ + +import type { ITheme } from './interfaces'; +import type { SelectionManager } from './selection-manager'; +import type { GhosttyCell, ILink } from './types'; +import { CellFlags } from './types'; + +// Interface for objects that can be rendered +export interface IRenderable { + getLine(y: number): GhosttyCell[] | null; + getCursor(): { x: number; y: number; visible: boolean }; + getDimensions(): { cols: number; rows: number }; + isRowDirty(y: number): boolean; + /** Returns true if a full redraw is needed (e.g., screen change) */ + needsFullRedraw?(): boolean; + clearDirty(): void; + /** + * Get the full grapheme string for a cell at (row, col). + * For cells with grapheme_len > 0, this returns all codepoints combined. + * For simple cells, returns the single character. + */ + getGraphemeString?(row: number, col: number): string; +} + +export interface IScrollbackProvider { + getScrollbackLine(offset: number): GhosttyCell[] | null; + getScrollbackLength(): number; +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +export interface RendererOptions { + fontSize?: number; // Default: 15 + fontFamily?: string; // Default: 'monospace' + cursorStyle?: 'block' | 'underline' | 'bar'; // Default: 'block' + cursorBlink?: boolean; // Default: false + theme?: ITheme; + devicePixelRatio?: number; // Default: window.devicePixelRatio +} + +export interface FontMetrics { + width: number; // Character cell width in CSS pixels + height: number; // Character cell height in CSS pixels + baseline: number; // Distance from top to text baseline +} + +// ============================================================================ +// Default Theme +// ============================================================================ + +export const DEFAULT_THEME: Required = { + foreground: '#d4d4d4', + background: '#1e1e1e', + cursor: '#ffffff', + cursorAccent: '#1e1e1e', + // Selection colors: solid colors that replace cell bg/fg when selected + // Using Ghostty's approach: selection bg = default fg, selection fg = default bg + selectionBackground: '#d4d4d4', + selectionForeground: '#1e1e1e', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff', +}; + +// ============================================================================ +// CanvasRenderer Class +// ============================================================================ + +export class CanvasRenderer { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private fontSize: number; + private fontFamily: string; + private cursorStyle: 'block' | 'underline' | 'bar'; + private cursorBlink: boolean; + private theme: Required; + private devicePixelRatio: number; + private metrics: FontMetrics; + private palette: string[]; + + // Cursor blinking state + private cursorVisible: boolean = true; + private cursorBlinkInterval?: number; + private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 }; + + // Viewport tracking (for scrolling) + private lastViewportY: number = 0; + + // Current buffer being rendered (for grapheme lookups) + private currentBuffer: IRenderable | null = null; + + // Selection manager (for rendering selection) + private selectionManager?: SelectionManager; + // Cached selection coordinates for current render pass (viewport-relative) + private currentSelectionCoords: { + startCol: number; + startRow: number; + endCol: number; + endRow: number; + } | null = null; + + // Link rendering state + private hoveredHyperlinkId: number = 0; + private previousHoveredHyperlinkId: number = 0; + + // Regex link hover tracking (for links without hyperlink_id) + private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null = + null; + private previousHoveredLinkRange: { + startX: number; + startY: number; + endX: number; + endY: number; + } | null = null; + + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { + this.canvas = canvas; + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) { + throw new Error('Failed to get 2D rendering context'); + } + this.ctx = ctx; + + // Apply options + this.fontSize = options.fontSize ?? 15; + this.fontFamily = options.fontFamily ?? 'monospace'; + this.cursorStyle = options.cursorStyle ?? 'block'; + this.cursorBlink = options.cursorBlink ?? false; + this.theme = { ...DEFAULT_THEME, ...options.theme }; + this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1; + + // Build color palette (16 ANSI colors) + this.palette = [ + this.theme.black, + this.theme.red, + this.theme.green, + this.theme.yellow, + this.theme.blue, + this.theme.magenta, + this.theme.cyan, + this.theme.white, + this.theme.brightBlack, + this.theme.brightRed, + this.theme.brightGreen, + this.theme.brightYellow, + this.theme.brightBlue, + this.theme.brightMagenta, + this.theme.brightCyan, + this.theme.brightWhite, + ]; + + // Measure font metrics + this.metrics = this.measureFont(); + + // Setup cursor blinking if enabled + if (this.cursorBlink) { + this.startCursorBlink(); + } + } + + // ========================================================================== + // Font Metrics Measurement + // ========================================================================== + + private measureFont(): FontMetrics { + // Use an offscreen canvas for measurement + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + // Set font (use actual pixel size for accurate measurement) + ctx.font = `${this.fontSize}px ${this.fontFamily}`; + + // Measure width using 'M' (typically widest character) + const widthMetrics = ctx.measureText('M'); + const width = Math.ceil(widthMetrics.width); + + // Measure height using ascent + descent with padding for glyph overflow + const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8; + const descent = widthMetrics.actualBoundingBoxDescent || this.fontSize * 0.2; + + // Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p') + // and anti-aliasing pixels + const height = Math.ceil(ascent + descent) + 2; + const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding + + return { width, height, baseline }; + } + + /** + * Remeasure font metrics (call after font loads or changes) + */ + public remeasureFont(): void { + this.metrics = this.measureFont(); + } + + // ========================================================================== + // Color Conversion + // ========================================================================== + + private rgbToCSS(r: number, g: number, b: number): string { + return `rgb(${r}, ${g}, ${b})`; + } + + // ========================================================================== + // Canvas Sizing + // ========================================================================== + + /** + * Resize canvas to fit terminal dimensions + */ + public resize(cols: number, rows: number): void { + const cssWidth = cols * this.metrics.width; + const cssHeight = rows * this.metrics.height; + + // Set CSS size (what user sees) + this.canvas.style.width = `${cssWidth}px`; + this.canvas.style.height = `${cssHeight}px`; + + // Set actual canvas size (scaled for DPI) + this.canvas.width = cssWidth * this.devicePixelRatio; + this.canvas.height = cssHeight * this.devicePixelRatio; + + // Scale context to match DPI (setting canvas.width/height resets the context) + this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); + + // Set text rendering properties for crisp text + this.ctx.textBaseline = 'alphabetic'; + this.ctx.textAlign = 'left'; + + // Fill background after resize + this.ctx.fillStyle = this.theme.background; + this.ctx.fillRect(0, 0, cssWidth, cssHeight); + } + + // ========================================================================== + // Main Rendering + // ========================================================================== + + /** + * Render the terminal buffer to canvas + */ + public render( + buffer: IRenderable, + forceAll: boolean = false, + viewportY: number = 0, + scrollbackProvider?: IScrollbackProvider, + scrollbarOpacity: number = 1 + ): void { + // Store buffer reference for grapheme lookups in renderCell + this.currentBuffer = buffer; + + // getCursor() calls update() internally to ensure fresh state. + // Multiple update() calls are safe - dirty state persists until clearDirty(). + const cursor = buffer.getCursor(); + const dims = buffer.getDimensions(); + const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; + + // Check if buffer needs full redraw (e.g., screen change between normal/alternate) + if (buffer.needsFullRedraw?.()) { + forceAll = true; + } + + // Resize canvas if dimensions changed + const needsResize = + this.canvas.width !== dims.cols * this.metrics.width * this.devicePixelRatio || + this.canvas.height !== dims.rows * this.metrics.height * this.devicePixelRatio; + + if (needsResize) { + this.resize(dims.cols, dims.rows); + forceAll = true; // Force full render after resize + } + + // Force re-render when viewport changes (scrolling) + if (viewportY !== this.lastViewportY) { + forceAll = true; + this.lastViewportY = viewportY; + } + + // Check if cursor position changed or if blinking (need to redraw cursor line) + const cursorMoved = + cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y; + if (cursorMoved || this.cursorBlink) { + // Mark cursor lines as needing redraw + if (!forceAll && !buffer.isRowDirty(cursor.y)) { + // Need to redraw cursor line + const line = buffer.getLine(cursor.y); + if (line) { + this.renderLine(line, cursor.y, dims.cols); + } + } + if (cursorMoved && this.lastCursorPosition.y !== cursor.y) { + // Also redraw old cursor line if cursor moved to different line + if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) { + const line = buffer.getLine(this.lastCursorPosition.y); + if (line) { + this.renderLine(line, this.lastCursorPosition.y, dims.cols); + } + } + } + } + + // Check if we need to redraw selection-related lines + const hasSelection = this.selectionManager && this.selectionManager.hasSelection(); + const selectionRows = new Set(); + + // Cache selection coordinates for use during cell rendering + // This is used by isInSelection() to determine if a cell needs selection colors + this.currentSelectionCoords = hasSelection ? this.selectionManager!.getSelectionCoords() : null; + + // Mark current selection rows for redraw (includes programmatic selections) + if (this.currentSelectionCoords) { + const coords = this.currentSelectionCoords; + for (let row = coords.startRow; row <= coords.endRow; row++) { + selectionRows.add(row); + } + } + + // Always mark dirty selection rows for redraw (to clear old overlay) + if (this.selectionManager) { + const dirtyRows = this.selectionManager.getDirtySelectionRows(); + if (dirtyRows.size > 0) { + for (const row of dirtyRows) { + selectionRows.add(row); + } + // Clear the dirty rows tracking after marking for redraw + this.selectionManager.clearDirtySelectionRows(); + } + } + + // Track rows with hyperlinks that need redraw when hover changes + const hyperlinkRows = new Set(); + const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; + const linkRangeChanged = + JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange); + + if (hyperlinkChanged) { + // Find rows containing the old or new hovered hyperlink + // Must check the correct buffer based on viewportY (scrollback vs screen) + for (let y = 0; y < dims.rows; y++) { + let line: GhosttyCell[] | null = null; + + // Same logic as rendering: fetch from scrollback or screen + if (viewportY > 0) { + if (y < viewportY && scrollbackProvider) { + // This row is from scrollback + // Floor viewportY for array access (handles fractional values during smooth scroll) + const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; + line = scrollbackProvider.getScrollbackLine(scrollbackOffset); + } else { + // This row is from visible screen + const screenRow = y - Math.floor(viewportY); + line = buffer.getLine(screenRow); + } + } else { + // At bottom - fetch from visible screen + line = buffer.getLine(y); + } + + if (line) { + for (const cell of line) { + if ( + cell.hyperlink_id === this.hoveredHyperlinkId || + cell.hyperlink_id === this.previousHoveredHyperlinkId + ) { + hyperlinkRows.add(y); + break; // Found hyperlink in this row + } + } + } + } + // Update previous state + this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; + } + + // Track rows affected by link range changes (for regex URLs) + if (linkRangeChanged) { + // Add rows from old range + if (this.previousHoveredLinkRange) { + for ( + let y = this.previousHoveredLinkRange.startY; + y <= this.previousHoveredLinkRange.endY; + y++ + ) { + hyperlinkRows.add(y); + } + } + // Add rows from new range + if (this.hoveredLinkRange) { + for (let y = this.hoveredLinkRange.startY; y <= this.hoveredLinkRange.endY; y++) { + hyperlinkRows.add(y); + } + } + this.previousHoveredLinkRange = this.hoveredLinkRange; + } + + // Track if anything was actually rendered + let anyLinesRendered = false; + + // Determine which rows need rendering. + // We also include adjacent rows (above and below) for each dirty row to handle + // glyph overflow - tall glyphs like Devanagari vowel signs can extend into + // adjacent rows' visual space. + const rowsToRender = new Set(); + for (let y = 0; y < dims.rows; y++) { + // When scrolled, always force render all lines since we're showing scrollback + const needsRender = + viewportY > 0 + ? true + : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); + + if (needsRender) { + rowsToRender.add(y); + // Include adjacent rows to handle glyph overflow + if (y > 0) rowsToRender.add(y - 1); + if (y < dims.rows - 1) rowsToRender.add(y + 1); + } + } + + // Render each line + for (let y = 0; y < dims.rows; y++) { + if (!rowsToRender.has(y)) { + continue; + } + + anyLinesRendered = true; + + // Fetch line from scrollback or visible screen + let line: GhosttyCell[] | null = null; + if (viewportY > 0) { + // Scrolled up - need to fetch from scrollback + visible screen + // When scrolled up N lines, we want to show: + // - Scrollback lines (from the end) + visible screen lines + + // Check if this row should come from scrollback or visible screen + if (y < viewportY && scrollbackProvider) { + // This row is from scrollback (upper part of viewport) + // Get from end of scrollback buffer + // Floor viewportY for array access (handles fractional values during smooth scroll) + const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; + line = scrollbackProvider.getScrollbackLine(scrollbackOffset); + } else { + // This row is from visible screen (lower part of viewport) + const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y; + line = buffer.getLine(screenRow); + } + } else { + // At bottom - fetch from visible screen + line = buffer.getLine(y); + } + + if (line) { + this.renderLine(line, y, dims.cols); + } + } + + // Selection highlighting is now integrated into renderCellBackground/renderCellText + // No separate overlay pass needed - this fixes z-order issues with complex glyphs + + // Link underlines are drawn during cell rendering (see renderCell) + + // Render cursor (only if we're at the bottom, not scrolled) + if (viewportY === 0 && cursor.visible && this.cursorVisible) { + this.renderCursor(cursor.x, cursor.y); + } + + // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) + if (scrollbackProvider && scrollbarOpacity > 0) { + this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity); + } + + // Update last cursor position + this.lastCursorPosition = { x: cursor.x, y: cursor.y }; + + // ALWAYS clear dirty flags after rendering, regardless of forceAll. + // This is critical - if we don't clear after a full redraw, the dirty + // state persists and the next frame might not detect new changes properly. + buffer.clearDirty(); + } + + /** + * Render a single line using two-pass approach: + * 1. First pass: Draw all cell backgrounds + * 2. Second pass: Draw all cell text and decorations + * + * This two-pass approach is necessary for proper rendering of complex scripts + * like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the + * base character into the previous cell's visual area. If we draw backgrounds + * and text in a single pass (cell by cell), the background of cell N would + * cover any left-extending portions of graphemes from cell N-1. + */ + private renderLine(line: GhosttyCell[], y: number, cols: number): void { + const lineY = y * this.metrics.height; + const lineWidth = cols * this.metrics.width; + + // Clear line background then fill with theme color. + // We clear just the cell area - glyph overflow is handled by also + // redrawing adjacent rows (see render() method). + // clearRect is needed because fillRect composites rather than replaces, + // so transparent/translucent backgrounds wouldn't clear previous content. + this.ctx.clearRect(0, lineY, lineWidth, this.metrics.height); + this.ctx.fillStyle = this.theme.background; + this.ctx.fillRect(0, lineY, lineWidth, this.metrics.height); + + // PASS 1: Draw all cell backgrounds first + // This ensures all backgrounds are painted before any text, allowing text + // to "bleed" across cell boundaries without being covered by adjacent backgrounds + for (let x = 0; x < line.length; x++) { + const cell = line[x]; + if (cell.width === 0) continue; // Skip spacer cells for wide characters + this.renderCellBackground(cell, x, y); + } + + // PASS 2: Draw all cell text and decorations + // Now text can safely extend beyond cell boundaries (for complex scripts) + for (let x = 0; x < line.length; x++) { + const cell = line[x]; + if (cell.width === 0) continue; // Skip spacer cells for wide characters + this.renderCellText(cell, x, y); + } + } + + /** + * Render a cell's background only (Pass 1 of two-pass rendering) + * Selection highlighting is integrated here to avoid z-order issues with + * complex glyphs (like Devanagari) that extend outside their cell bounds. + */ + private renderCellBackground(cell: GhosttyCell, x: number, y: number): void { + const cellX = x * this.metrics.width; + const cellY = y * this.metrics.height; + const cellWidth = this.metrics.width * cell.width; + + // Check if this cell is selected + const isSelected = this.isInSelection(x, y); + + if (isSelected) { + // Draw selection background (solid color, not overlay) + this.ctx.fillStyle = this.theme.selectionBackground; + this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + return; // Selection background replaces cell background + } + + // Extract background color and handle inverse + let bg_r = cell.bg_r, + bg_g = cell.bg_g, + bg_b = cell.bg_b; + + if (cell.flags & CellFlags.INVERSE) { + // When inverted, background becomes foreground + bg_r = cell.fg_r; + bg_g = cell.fg_g; + bg_b = cell.fg_b; + } + + // Only draw cell background if it's different from the default (black) + // This lets the theme background (drawn earlier) show through for default cells + const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; + if (!isDefaultBg) { + this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); + this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + } + } + + /** + * Render a cell's text and decorations (Pass 2 of two-pass rendering) + * Selection foreground color is applied here to match the selection background. + */ + private renderCellText(cell: GhosttyCell, x: number, y: number, colorOverride?: string): void { + const cellX = x * this.metrics.width; + const cellY = y * this.metrics.height; + const cellWidth = this.metrics.width * cell.width; + + // Skip rendering if invisible + if (cell.flags & CellFlags.INVISIBLE) { + return; + } + + // Check if this cell is selected + const isSelected = this.isInSelection(x, y); + + // Set text style + let fontStyle = ''; + if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic '; + if (cell.flags & CellFlags.BOLD) fontStyle += 'bold '; + this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`; + + // Set text color - use override, selection foreground, or normal color + if (colorOverride) { + this.ctx.fillStyle = colorOverride; + } else if (isSelected) { + this.ctx.fillStyle = this.theme.selectionForeground; + } else { + // Extract colors and handle inverse + let fg_r = cell.fg_r, + fg_g = cell.fg_g, + fg_b = cell.fg_b; + + if (cell.flags & CellFlags.INVERSE) { + // When inverted, foreground becomes background + fg_r = cell.bg_r; + fg_g = cell.bg_g; + fg_b = cell.bg_b; + } + + this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b); + } + + // Apply faint effect + if (cell.flags & CellFlags.FAINT) { + this.ctx.globalAlpha = 0.5; + } + + // Draw text + const textX = cellX; + const textY = cellY + this.metrics.baseline; + + // Get the character to render - use grapheme lookup for complex scripts + let char: string; + if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) { + // Cell has additional codepoints - get full grapheme cluster + char = this.currentBuffer.getGraphemeString(y, x); + } else { + // Simple cell - single codepoint + char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null + } + this.ctx.fillText(char, textX, textY); + + // Reset alpha + if (cell.flags & CellFlags.FAINT) { + this.ctx.globalAlpha = 1.0; + } + + // Draw underline + if (cell.flags & CellFlags.UNDERLINE) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = this.ctx.fillStyle; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); + } + + // Draw strikethrough + if (cell.flags & CellFlags.STRIKETHROUGH) { + const strikeY = cellY + this.metrics.height / 2; + this.ctx.strokeStyle = this.ctx.fillStyle; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, strikeY); + this.ctx.lineTo(cellX + cellWidth, strikeY); + this.ctx.stroke(); + } + + // Draw hyperlink underline (for OSC8 hyperlinks) + if (cell.hyperlink_id > 0) { + const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId; + + // Only show underline when hovered (cleaner look) + if (isHovered) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); + } + } + + // Draw regex link underline (for plain text URLs) + if (this.hoveredLinkRange) { + const range = this.hoveredLinkRange; + // Check if this cell is within the hovered link range + const isInRange = + (y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) || + (y > range.startY && y < range.endY) || + (y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX)); + + if (isInRange) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); + } + } + } + + /** + * Render cursor + */ + private renderCursor(x: number, y: number): void { + const cursorX = x * this.metrics.width; + const cursorY = y * this.metrics.height; + + this.ctx.fillStyle = this.theme.cursor; + + switch (this.cursorStyle) { + case 'block': + // Full cell block + this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); + // Re-draw character under cursor with cursorAccent color + { + const line = this.currentBuffer?.getLine(y); + if (line?.[x]) { + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height); + this.ctx.clip(); + this.renderCellText(line[x], x, y, this.theme.cursorAccent); + this.ctx.restore(); + } + } + break; + + case 'underline': + // Underline at bottom of cell + const underlineHeight = Math.max(2, Math.floor(this.metrics.height * 0.15)); + this.ctx.fillRect( + cursorX, + cursorY + this.metrics.height - underlineHeight, + this.metrics.width, + underlineHeight + ); + break; + + case 'bar': + // Vertical bar at left of cell + const barWidth = Math.max(2, Math.floor(this.metrics.width * 0.15)); + this.ctx.fillRect(cursorX, cursorY, barWidth, this.metrics.height); + break; + } + } + + // ========================================================================== + // Cursor Blinking + // ========================================================================== + + private startCursorBlink(): void { + // xterm.js uses ~530ms blink interval + this.cursorBlinkInterval = window.setInterval(() => { + this.cursorVisible = !this.cursorVisible; + // Note: Render loop should redraw cursor line automatically + }, 530); + } + + private stopCursorBlink(): void { + if (this.cursorBlinkInterval !== undefined) { + clearInterval(this.cursorBlinkInterval); + this.cursorBlinkInterval = undefined; + } + this.cursorVisible = true; + } + + // ========================================================================== + // Public API + // ========================================================================== + + /** + * Update theme colors + */ + public setTheme(theme: ITheme): void { + this.theme = { ...DEFAULT_THEME, ...theme }; + + // Rebuild palette + this.palette = [ + this.theme.black, + this.theme.red, + this.theme.green, + this.theme.yellow, + this.theme.blue, + this.theme.magenta, + this.theme.cyan, + this.theme.white, + this.theme.brightBlack, + this.theme.brightRed, + this.theme.brightGreen, + this.theme.brightYellow, + this.theme.brightBlue, + this.theme.brightMagenta, + this.theme.brightCyan, + this.theme.brightWhite, + ]; + } + + /** + * Update font size + */ + public setFontSize(size: number): void { + this.fontSize = size; + this.metrics = this.measureFont(); + } + + /** + * Update font family + */ + public setFontFamily(family: string): void { + this.fontFamily = family; + this.metrics = this.measureFont(); + } + + /** + * Update cursor style + */ + public setCursorStyle(style: 'block' | 'underline' | 'bar'): void { + this.cursorStyle = style; + } + + /** + * Enable/disable cursor blinking + */ + public setCursorBlink(enabled: boolean): void { + if (enabled && !this.cursorBlink) { + this.cursorBlink = true; + this.startCursorBlink(); + } else if (!enabled && this.cursorBlink) { + this.cursorBlink = false; + this.stopCursorBlink(); + } + } + + /** + * Get current font metrics + */ + + /** + * Render scrollbar (Phase 2) + * Shows scroll position and allows click/drag interaction + * @param opacity Opacity level (0-1) for fade in/out effect + */ + private renderScrollbar( + viewportY: number, + scrollbackLength: number, + visibleRows: number, + opacity: number = 1 + ): void { + const ctx = this.ctx; + const canvasHeight = this.canvas.height / this.devicePixelRatio; + const canvasWidth = this.canvas.width / this.devicePixelRatio; + + // Scrollbar dimensions + const scrollbarWidth = 8; + const scrollbarX = canvasWidth - scrollbarWidth - 4; + const scrollbarPadding = 4; + const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; + + // Always clear the scrollbar area first (fixes ghosting when fading out) + ctx.clearRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight); + ctx.fillStyle = this.theme.background; + ctx.fillRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight); + + // Don't draw scrollbar if fully transparent or no scrollback + if (opacity <= 0 || scrollbackLength === 0) return; + + // Calculate scrollbar thumb size and position + const totalLines = scrollbackLength + visibleRows; + const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); + + // Position: 0 = at bottom, scrollbackLength = at top + const scrollPosition = viewportY / scrollbackLength; // 0 to 1 + const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); + + // Draw scrollbar track (subtle background) with opacity + ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; + ctx.fillRect(scrollbarX, scrollbarPadding, scrollbarWidth, scrollbarTrackHeight); + + // Draw scrollbar thumb with opacity + const isScrolled = viewportY > 0; + const baseOpacity = isScrolled ? 0.5 : 0.3; + ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; + ctx.fillRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight); + } + public getMetrics(): FontMetrics { + return { ...this.metrics }; + } + + /** + * Get canvas element (needed by SelectionManager) + */ + public getCanvas(): HTMLCanvasElement { + return this.canvas; + } + + /** + * Set selection manager (for rendering selection) + */ + public setSelectionManager(manager: SelectionManager): void { + this.selectionManager = manager; + } + + /** + * Check if a cell at (x, y) is within the current selection. + * Uses cached selection coordinates for performance. + */ + private isInSelection(x: number, y: number): boolean { + const sel = this.currentSelectionCoords; + if (!sel) return false; + + const { startCol, startRow, endCol, endRow } = sel; + + // Single line selection + if (startRow === endRow) { + return y === startRow && x >= startCol && x <= endCol; + } + + // Multi-line selection + if (y === startRow) { + // First line: from startCol to end of line + return x >= startCol; + } else if (y === endRow) { + // Last line: from start of line to endCol + return x <= endCol; + } else if (y > startRow && y < endRow) { + // Middle lines: entire line is selected + return true; + } + + return false; + } + + /** + * Set the currently hovered hyperlink ID for rendering underlines + */ + public setHoveredHyperlinkId(hyperlinkId: number): void { + this.hoveredHyperlinkId = hyperlinkId; + } + + /** + * Set the currently hovered link range for rendering underlines (for regex-detected URLs) + * Pass null to clear the hover state + */ + public setHoveredLinkRange( + range: { + startX: number; + startY: number; + endX: number; + endY: number; + } | null + ): void { + this.hoveredLinkRange = range; + } + + /** + * Get character cell width (for coordinate conversion) + */ + public get charWidth(): number { + return this.metrics.width; + } + + /** + * Get character cell height (for coordinate conversion) + */ + public get charHeight(): number { + return this.metrics.height; + } + + /** + * Clear entire canvas + */ + public clear(): void { + // clearRect first because fillRect composites rather than replaces, + // so transparent/translucent backgrounds wouldn't clear previous content. + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.fillStyle = this.theme.background; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + /** + * Cleanup resources + */ + public dispose(): void { + this.stopCursorBlink(); + } +} diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts new file mode 100644 index 0000000..56d4605 --- /dev/null +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -0,0 +1,1056 @@ +/** + * Selection Manager - Handles text selection in the terminal + * + * Features: + * - Mouse drag selection + * - Double-click word selection + * - Text extraction from terminal buffer + * - Automatic clipboard copy + * - Visual selection highlighting (integrated into CanvasRenderer cell rendering) + * - Auto-scroll during drag selection + */ + +import { EventEmitter } from './event-emitter'; +import type { GhosttyTerminal } from './ghostty'; +import type { IEvent } from './interfaces'; +import type { CanvasRenderer } from './renderer'; +import type { Terminal } from './terminal'; +import type { GhosttyCell } from './types'; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +export interface SelectionCoordinates { + startCol: number; + startRow: number; + endCol: number; + endRow: number; +} + +// ============================================================================ +// SelectionManager Class +// ============================================================================ + +export class SelectionManager { + private terminal: Terminal; + private renderer: CanvasRenderer; + private wasmTerm: GhosttyTerminal; + private textarea: HTMLTextAreaElement; + + // Selection state - coordinates are in ABSOLUTE buffer space (viewportY + viewportRow) + // This ensures selection persists correctly when scrolling + private selectionStart: { col: number; absoluteRow: number } | null = null; + private selectionEnd: { col: number; absoluteRow: number } | null = null; + private isSelecting: boolean = false; + private mouseDownX: number = 0; + private mouseDownY: number = 0; + private dragThresholdMet: boolean = false; + private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred + + // Track rows that need redraw for clearing old selection + // Using a Set prevents the overwrite bug where mousemove would clobber + // the rows marked by clearSelection() + private dirtySelectionRows: Set = new Set(); + + // Event emitter + private selectionChangedEmitter = new EventEmitter(); + + // Store bound event handlers for cleanup + private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null; + private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null; + private boundClickHandler: ((e: MouseEvent) => void) | null = null; + private boundDocumentMouseMoveHandler: ((e: MouseEvent) => void) | null = null; + + // Auto-scroll state for drag selection + private autoScrollInterval: ReturnType | null = null; + private autoScrollDirection: number = 0; // -1 = up, 0 = none, 1 = down + private static readonly AUTO_SCROLL_EDGE_SIZE = 30; // pixels from edge to trigger scroll + + /** + * Get current viewport Y position (how many lines scrolled into history) + */ + private getViewportY(): number { + const rawViewportY = + typeof (this.terminal as any).getViewportY === 'function' + ? (this.terminal as any).getViewportY() + : (this.terminal as any).viewportY || 0; + return Math.max(0, Math.floor(rawViewportY)); + } + + /** + * Convert viewport row to absolute buffer row + * Absolute row is an index into combined buffer: scrollback (0 to len-1) + screen (len to len+rows-1) + */ + private viewportRowToAbsolute(viewportRow: number): number { + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + const viewportY = this.getViewportY(); + return scrollbackLength + viewportRow - viewportY; + } + + /** + * Convert absolute buffer row to viewport row (may be outside visible range) + */ + private absoluteRowToViewport(absoluteRow: number): number { + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + const viewportY = this.getViewportY(); + return absoluteRow - scrollbackLength + viewportY; + } + private static readonly AUTO_SCROLL_SPEED = 3; // lines per interval + private static readonly AUTO_SCROLL_INTERVAL = 50; // ms between scroll steps + + constructor( + terminal: Terminal, + renderer: CanvasRenderer, + wasmTerm: GhosttyTerminal, + textarea: HTMLTextAreaElement + ) { + this.terminal = terminal; + this.renderer = renderer; + this.wasmTerm = wasmTerm; + this.textarea = textarea; + + // Attach mouse event listeners + this.attachEventListeners(); + } + + // ========================================================================== + // Public API + // ========================================================================== + + /** + * Get the selected text as a string + */ + getSelection(): string { + if (!this.selectionStart || !this.selectionEnd) return ''; + + // Get absolute row coordinates (not clamped to viewport) + let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; + let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; + + // Swap if selection goes backwards + if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { + [startCol, endCol] = [endCol, startCol]; + [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; + } + + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let text = ''; + + for (let absRow = startAbsRow; absRow <= endAbsRow; absRow++) { + // Fetch line based on absolute row position + // Absolute row < scrollbackLength means it's in scrollback + // Absolute row >= scrollbackLength means it's in the screen buffer + let line: GhosttyCell[] | null = null; + + if (absRow < scrollbackLength) { + // Row is in scrollback + line = this.wasmTerm.getScrollbackLine(absRow); + } else { + // Row is in screen buffer + const screenRow = absRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } + + if (!line) continue; + + // Track the last non-empty column for trimming trailing spaces + let lastNonEmpty = -1; + + // Determine column range for this row + const colStart = absRow === startAbsRow ? startCol : 0; + const colEnd = absRow === endAbsRow ? endCol : line.length - 1; + + // Build the line text + let lineText = ''; + for (let col = colStart; col <= colEnd; col++) { + const cell = line[col]; + if (cell && cell.codepoint !== 0) { + // Use grapheme lookup for cells with multi-codepoint characters + let char: string; + if (cell.grapheme_len > 0) { + // Row is in scrollback or screen - determine which and use appropriate method + if (absRow < scrollbackLength) { + char = this.wasmTerm.getScrollbackGraphemeString(absRow, col); + } else { + const screenRow = absRow - scrollbackLength; + char = this.wasmTerm.getGraphemeString(screenRow, col); + } + } else { + char = String.fromCodePoint(cell.codepoint); + } + lineText += char; + if (char.trim()) { + lastNonEmpty = lineText.length; + } + } else { + lineText += ' '; + } + } + + // Trim trailing spaces from each line + if (lastNonEmpty >= 0) { + lineText = lineText.substring(0, lastNonEmpty); + } else { + lineText = ''; + } + + text += lineText; + + // Add newline between rows (but not after the last row) + if (absRow < endAbsRow) { + text += '\n'; + } + } + + return text; + } + + /** + * Check if there's an active selection + */ + hasSelection(): boolean { + if (!this.selectionStart || !this.selectionEnd) return false; + + // Don't report selection until drag threshold is met (prevents flash on click) + if (this.isSelecting && !this.dragThresholdMet) return false; + + return true; + } + + /** + * Copy the current selection to clipboard + * @returns true if there was text to copy, false otherwise + */ + copySelection(): boolean { + if (!this.hasSelection()) return false; + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + return true; + } + return false; + } + + /** + * Clear the selection + */ + clearSelection(): void { + if (!this.hasSelection()) return; + + // Mark current selection rows as dirty for redraw + const coords = this.normalizeSelection(); + if (coords) { + for (let row = coords.startRow; row <= coords.endRow; row++) { + this.dirtySelectionRows.add(row); + } + } + + this.selectionStart = null; + this.selectionEnd = null; + this.isSelecting = false; + + // Force redraw of previously selected lines to clear the overlay + this.requestRender(); + } + + /** + * Select all text in the terminal + */ + selectAll(): void { + const dims = this.wasmTerm.getDimensions(); + const viewportY = this.getViewportY(); + this.selectionStart = { col: 0, absoluteRow: viewportY }; + this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + dims.rows - 1 }; + this.requestRender(); + this.selectionChangedEmitter.fire(); + } + + /** + * Select text at specific column and row with length + * xterm.js compatible API + */ + select(column: number, row: number, length: number): void { + // Clamp to valid ranges + const dims = this.wasmTerm.getDimensions(); + row = Math.max(0, Math.min(row, dims.rows - 1)); + column = Math.max(0, Math.min(column, dims.cols - 1)); + + // Calculate end position + let endRow = row; + let endCol = column + length - 1; + + // Handle wrapping if selection extends past end of line + while (endCol >= dims.cols) { + endCol -= dims.cols; + endRow++; + } + + // Clamp end row + endRow = Math.min(endRow, dims.rows - 1); + + // Convert viewport rows to absolute rows + const viewportY = this.getViewportY(); + this.selectionStart = { col: column, absoluteRow: viewportY + row }; + this.selectionEnd = { col: endCol, absoluteRow: viewportY + endRow }; + this.requestRender(); + this.selectionChangedEmitter.fire(); + } + + /** + * Select entire lines from start to end + * xterm.js compatible API + */ + selectLines(start: number, end: number): void { + const dims = this.wasmTerm.getDimensions(); + + // Clamp to valid row ranges + start = Math.max(0, Math.min(start, dims.rows - 1)); + end = Math.max(0, Math.min(end, dims.rows - 1)); + + // Ensure start <= end + if (start > end) { + [start, end] = [end, start]; + } + + // Convert viewport rows to absolute rows + this.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) }; + this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) }; + this.requestRender(); + this.selectionChangedEmitter.fire(); + } + + /** + * Get selection position as buffer range + * xterm.js compatible API + */ + getSelectionPosition(): + | { start: { x: number; y: number }; end: { x: number; y: number } } + | undefined { + const coords = this.normalizeSelection(); + if (!coords) return undefined; + + return { + start: { x: coords.startCol, y: coords.startRow }, + end: { x: coords.endCol, y: coords.endRow }, + }; + } + + /** + * Deselect all text + * xterm.js compatible API + */ + deselect(): void { + this.clearSelection(); + this.selectionChangedEmitter.fire(); + } + + /** + * Focus the terminal (make it receive keyboard input) + */ + focus(): void { + const canvas = this.renderer.getCanvas(); + if (canvas.parentElement) { + canvas.parentElement.focus(); + } + } + + /** + * Get current selection coordinates (for rendering) + */ + getSelectionCoords(): SelectionCoordinates | null { + return this.normalizeSelection(); + } + + /** + * Get dirty selection rows that need redraw (for clearing old highlight) + */ + getDirtySelectionRows(): Set { + return this.dirtySelectionRows; + } + + /** + * Clear the dirty selection rows tracking (after redraw) + */ + clearDirtySelectionRows(): void { + this.dirtySelectionRows.clear(); + } + + /** + * Get selection change event accessor + */ + get onSelectionChange(): IEvent { + return this.selectionChangedEmitter.event; + } + + /** + * Cleanup resources + */ + dispose(): void { + this.selectionChangedEmitter.dispose(); + + // Stop auto-scroll if active + this.stopAutoScroll(); + + // Clean up document event listener + if (this.boundMouseUpHandler) { + document.removeEventListener('mouseup', this.boundMouseUpHandler); + this.boundMouseUpHandler = null; + } + + // Clean up document mousemove listener + if (this.boundDocumentMouseMoveHandler) { + document.removeEventListener('mousemove', this.boundDocumentMouseMoveHandler); + this.boundDocumentMouseMoveHandler = null; + } + + // Clean up context menu event listener + if (this.boundContextMenuHandler) { + const canvas = this.renderer.getCanvas(); + canvas.removeEventListener('contextmenu', this.boundContextMenuHandler); + this.boundContextMenuHandler = null; + } + + // Clean up document click listener + if (this.boundClickHandler) { + document.removeEventListener('click', this.boundClickHandler); + this.boundClickHandler = null; + } + + // Canvas event listeners will be cleaned up when canvas is removed from DOM + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + /** + * Attach mouse event listeners to canvas + */ + private attachEventListeners(): void { + const canvas = this.renderer.getCanvas(); + + // Mouse down - start selection or clear existing + canvas.addEventListener('mousedown', (e: MouseEvent) => { + if (e.button === 0) { + // Left click only + + // CRITICAL: Focus the terminal so it can receive keyboard input + // The canvas doesn't have tabindex, but the parent container does + if (canvas.parentElement) { + canvas.parentElement.focus(); + } + + const cell = this.pixelToCell(e.offsetX, e.offsetY); + + // Always clear previous selection on new click + const hadSelection = this.hasSelection(); + if (hadSelection) { + this.clearSelection(); + } + + // Start new selection (convert to absolute coordinates) + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionStart = { col: cell.col, absoluteRow }; + this.selectionEnd = { col: cell.col, absoluteRow }; + this.isSelecting = true; + this.mouseDownX = e.offsetX; + this.mouseDownY = e.offsetY; + this.dragThresholdMet = false; + } + }); + + // Mouse move on canvas - update selection + canvas.addEventListener('mousemove', (e: MouseEvent) => { + if (this.isSelecting) { + // Check if drag threshold has been met + if (!this.dragThresholdMet) { + const dx = e.offsetX - this.mouseDownX; + const dy = e.offsetY - this.mouseDownY; + // Use 50% of cell width as threshold to scale with font size + const threshold = this.renderer.getMetrics().width * 0.5; + if (dx * dx + dy * dy < threshold * threshold) { + return; // Below threshold, ignore + } + this.dragThresholdMet = true; + } + + // Mark current selection rows as dirty before updating + this.markCurrentSelectionDirty(); + + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionEnd = { col: cell.col, absoluteRow }; + this.requestRender(); + + // Check if near edges for auto-scroll + this.updateAutoScroll(e.offsetY, canvas.clientHeight); + } + }); + + // Mouse leave - check for auto-scroll when leaving canvas during drag + canvas.addEventListener('mouseleave', (e: MouseEvent) => { + if (this.isSelecting) { + // Determine scroll direction based on where mouse left + const rect = canvas.getBoundingClientRect(); + if (e.clientY < rect.top) { + this.startAutoScroll(-1); // Scroll up + } else if (e.clientY > rect.bottom) { + this.startAutoScroll(1); // Scroll down + } + } + }); + + // Mouse enter - stop auto-scroll when mouse returns to canvas + canvas.addEventListener('mouseenter', () => { + if (this.isSelecting) { + this.stopAutoScroll(); + } + }); + + // Document-level mousemove for tracking mouse position during drag outside canvas + this.boundDocumentMouseMoveHandler = (e: MouseEvent) => { + if (this.isSelecting) { + // Check drag threshold (same as canvas mousemove) + if (!this.dragThresholdMet) { + const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX); + const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY); + const threshold = this.renderer.getMetrics().width * 0.5; + if (dx * dx + dy * dy < threshold * threshold) { + return; + } + this.dragThresholdMet = true; + } + + const rect = canvas.getBoundingClientRect(); + + // Update selection based on clamped position + const clampedX = Math.max(rect.left, Math.min(e.clientX, rect.right)); + const clampedY = Math.max(rect.top, Math.min(e.clientY, rect.bottom)); + + // Convert to canvas-relative coordinates + const offsetX = clampedX - rect.left; + const offsetY = clampedY - rect.top; + + // Only update if mouse is outside the canvas + if ( + e.clientX < rect.left || + e.clientX > rect.right || + e.clientY < rect.top || + e.clientY > rect.bottom + ) { + // Update auto-scroll direction based on mouse position + if (e.clientY < rect.top) { + this.startAutoScroll(-1); + } else if (e.clientY > rect.bottom) { + this.startAutoScroll(1); + } else { + this.stopAutoScroll(); + } + + // Only update selection position if NOT auto-scrolling + // During auto-scroll, the scroll handler extends the selection + if (this.autoScrollDirection === 0) { + // Mark current selection rows as dirty before updating + this.markCurrentSelectionDirty(); + + const cell = this.pixelToCell(offsetX, offsetY); + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionEnd = { col: cell.col, absoluteRow }; + this.requestRender(); + } + } + } + }; + document.addEventListener('mousemove', this.boundDocumentMouseMoveHandler); + + // Track mousedown on document to know if a click started inside the canvas + document.addEventListener('mousedown', (e: MouseEvent) => { + this.mouseDownTarget = e.target; + }); + + // CRITICAL FIX: Listen for mouseup on DOCUMENT, not just canvas + // This catches mouseup events that happen outside the canvas (common during drag) + this.boundMouseUpHandler = (e: MouseEvent) => { + if (this.isSelecting) { + this.isSelecting = false; + this.stopAutoScroll(); + + // Check if this was a click without drag (threshold never met). + if (!this.dragThresholdMet) { + this.clearSelection(); + return; + } + + if (this.hasSelection()) { + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } + } + } + }; + document.addEventListener('mouseup', this.boundMouseUpHandler); + + // Handle click events for double-click (word) and triple-click (line) selection + // Use event.detail which browsers set to click count (1, 2, 3, etc.) + canvas.addEventListener('click', (e: MouseEvent) => { + // event.detail: 1 = single, 2 = double, 3 = triple click + if (e.detail === 2) { + // Double-click - select word + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const word = this.getWordAtCell(cell.col, cell.row); + + if (word) { + const absoluteRow = this.viewportRowToAbsolute(cell.row); + this.selectionStart = { col: word.startCol, absoluteRow }; + this.selectionEnd = { col: word.endCol, absoluteRow }; + this.requestRender(); + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } + } + } else if (e.detail >= 3) { + // Triple-click (or more) - select line content (like native Ghostty) + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const absoluteRow = this.viewportRowToAbsolute(cell.row); + + // Find actual line length (exclude trailing empty cells) + // Use scrollback-aware line retrieval (like getSelection does) + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let line: GhosttyCell[] | null = null; + if (absoluteRow < scrollbackLength) { + // Row is in scrollback + line = this.wasmTerm.getScrollbackLine(absoluteRow); + } else { + // Row is in screen buffer + const screenRow = absoluteRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } + // Find last non-empty cell (-1 means empty line) + let endCol = -1; + if (line) { + for (let i = line.length - 1; i >= 0; i--) { + if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) { + endCol = i; + break; + } + } + } + + // Only select if line has content (endCol >= 0) + if (endCol >= 0) { + // Select line content only (not trailing whitespace) + this.selectionStart = { col: 0, absoluteRow }; + this.selectionEnd = { col: endCol, absoluteRow }; + this.requestRender(); + + const text = this.getSelection(); + if (text) { + this.copyToClipboard(text); + this.selectionChangedEmitter.fire(); + } + } + } + }); + + // Right-click (context menu) - position textarea to show browser's native menu + // This allows Copy/Paste options to appear in the context menu + this.boundContextMenuHandler = (e: MouseEvent) => { + // Position textarea at mouse cursor + const canvas = this.renderer.getCanvas(); + const rect = canvas.getBoundingClientRect(); + + this.textarea.style.position = 'fixed'; + this.textarea.style.left = `${e.clientX}px`; + this.textarea.style.top = `${e.clientY}px`; + this.textarea.style.width = '1px'; + this.textarea.style.height = '1px'; + this.textarea.style.zIndex = '1000'; + this.textarea.style.opacity = '0'; + + // Enable pointer events temporarily so context menu targets the textarea + this.textarea.style.pointerEvents = 'auto'; + + // If there's a selection, populate textarea with it and select the text + if (this.hasSelection()) { + const text = this.getSelection(); + this.textarea.value = text; + this.textarea.select(); + this.textarea.setSelectionRange(0, text.length); + } else { + // No selection - clear textarea but still show menu (for paste) + this.textarea.value = ''; + } + + // Focus the textarea so the context menu appears on it + this.textarea.focus(); + + // After a short delay, restore the textarea to its hidden state + // This allows the context menu to appear first + setTimeout(() => { + // Listen for when the context menu closes (user clicks away or selects an option) + const resetTextarea = () => { + this.textarea.style.pointerEvents = 'none'; + this.textarea.style.zIndex = '-10'; + this.textarea.style.width = '0'; + this.textarea.style.height = '0'; + this.textarea.style.left = '0'; + this.textarea.style.top = '0'; + this.textarea.value = ''; + + // Remove the one-time listeners + document.removeEventListener('click', resetTextarea); + document.removeEventListener('contextmenu', resetTextarea); + this.textarea.removeEventListener('blur', resetTextarea); + }; + + // Reset on any of these events (menu closed) + document.addEventListener('click', resetTextarea, { once: true }); + document.addEventListener('contextmenu', resetTextarea, { once: true }); + this.textarea.addEventListener('blur', resetTextarea, { once: true }); + }, 10); + + // Don't prevent default - let browser show the context menu on the textarea + }; + + canvas.addEventListener('contextmenu', this.boundContextMenuHandler); + + // Click outside canvas - clear selection + // This allows users to deselect by clicking anywhere outside the terminal + this.boundClickHandler = (e: MouseEvent) => { + // Don't clear selection if we're actively selecting + if (this.isSelecting) { + return; + } + + // A click is only valid for clearing selection if BOTH mousedown and mouseup + // happened outside the canvas. If mousedown was inside (drag selection), + // don't clear even if mouseup/click is outside. + const mouseDownWasInCanvas = + this.mouseDownTarget && canvas.contains(this.mouseDownTarget as Node); + if (mouseDownWasInCanvas) { + return; + } + + // Check if the click is outside the canvas + const target = e.target as Node; + if (!canvas.contains(target)) { + // Clicked outside the canvas - clear selection + if (this.hasSelection()) { + this.clearSelection(); + } + } + }; + + document.addEventListener('click', this.boundClickHandler); + } + + /** + * Mark current selection rows as dirty for redraw + */ + private markCurrentSelectionDirty(): void { + const coords = this.normalizeSelection(); + if (coords) { + for (let row = coords.startRow; row <= coords.endRow; row++) { + this.dirtySelectionRows.add(row); + } + } + } + + /** + * Update auto-scroll based on mouse Y position within canvas + */ + private updateAutoScroll(offsetY: number, canvasHeight: number): void { + const edgeSize = SelectionManager.AUTO_SCROLL_EDGE_SIZE; + + if (offsetY < edgeSize) { + // Near top edge - scroll up + this.startAutoScroll(-1); + } else if (offsetY > canvasHeight - edgeSize) { + // Near bottom edge - scroll down + this.startAutoScroll(1); + } else { + // In middle - stop scrolling + this.stopAutoScroll(); + } + } + + /** + * Start auto-scrolling in the given direction + */ + private startAutoScroll(direction: number): void { + // Don't restart if already scrolling in same direction + if (this.autoScrollInterval !== null && this.autoScrollDirection === direction) { + return; + } + + // Stop any existing scroll + this.stopAutoScroll(); + + this.autoScrollDirection = direction; + + // Start scrolling interval + this.autoScrollInterval = setInterval(() => { + if (!this.isSelecting) { + this.stopAutoScroll(); + return; + } + + // Scroll the terminal to reveal more content in the direction user is dragging + // autoScrollDirection: -1 = dragging up (wants to see history), 1 = dragging down (wants to see newer) + // scrollLines convention: negative = scroll up into history, positive = scroll down to newer + // So direction maps directly to scrollLines sign + const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection; + (this.terminal as any).scrollLines(scrollAmount); + + // Extend selection in the scroll direction + // Key insight: we need to EXTEND the selection, not reset it to viewport edge + if (this.selectionEnd) { + const dims = this.wasmTerm.getDimensions(); + if (this.autoScrollDirection < 0) { + // Scrolling up - extend selection upward (decrease absoluteRow) + // Set to top of viewport, but only if it extends the selection + const topAbsoluteRow = this.viewportRowToAbsolute(0); + if (topAbsoluteRow < this.selectionEnd.absoluteRow) { + this.selectionEnd = { col: 0, absoluteRow: topAbsoluteRow }; + } + } else { + // Scrolling down - extend selection downward (increase absoluteRow) + // Set to bottom of viewport, but only if it extends the selection + const bottomAbsoluteRow = this.viewportRowToAbsolute(dims.rows - 1); + if (bottomAbsoluteRow > this.selectionEnd.absoluteRow) { + this.selectionEnd = { col: dims.cols - 1, absoluteRow: bottomAbsoluteRow }; + } + } + } + + this.requestRender(); + }, SelectionManager.AUTO_SCROLL_INTERVAL); + } + + /** + * Stop auto-scrolling + */ + private stopAutoScroll(): void { + if (this.autoScrollInterval !== null) { + clearInterval(this.autoScrollInterval); + this.autoScrollInterval = null; + } + this.autoScrollDirection = 0; + } + + /** + * Convert pixel coordinates to terminal cell coordinates + */ + private pixelToCell(x: number, y: number): { col: number; row: number } { + const metrics = this.renderer.getMetrics(); + + const col = Math.floor(x / metrics.width); + const row = Math.floor(y / metrics.height); + + // Clamp to terminal bounds + return { + col: Math.max(0, Math.min(col, this.terminal.cols - 1)), + row: Math.max(0, Math.min(row, this.terminal.rows - 1)), + }; + } + + /** + * Normalize selection coordinates (handle backward selection) + * Returns coordinates in VIEWPORT space for rendering, clamped to visible area + */ + private normalizeSelection(): SelectionCoordinates | null { + if (!this.selectionStart || !this.selectionEnd) return null; + + let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; + let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; + + // Swap if selection goes backwards + if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { + [startCol, endCol] = [endCol, startCol]; + [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; + } + + // Convert to viewport coordinates + let startRow = this.absoluteRowToViewport(startAbsRow); + let endRow = this.absoluteRowToViewport(endAbsRow); + + // Clamp to visible viewport range + const dims = this.wasmTerm.getDimensions(); + const maxRow = dims.rows - 1; + + // If entire selection is outside viewport, return null + if (endRow < 0 || startRow > maxRow) { + return null; + } + + // Clamp rows to visible range, adjusting columns for partial rows + if (startRow < 0) { + startRow = 0; + startCol = 0; // Selection starts from beginning of first visible row + } + if (endRow > maxRow) { + endRow = maxRow; + endCol = dims.cols - 1; // Selection extends to end of last visible row + } + + return { startCol, startRow, endCol, endRow }; + } + + /** + * Get word boundaries at a cell position + */ + private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null { + const absoluteRow = this.viewportRowToAbsolute(row); + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let line: GhosttyCell[] | null; + if (absoluteRow < scrollbackLength) { + line = this.wasmTerm.getScrollbackLine(absoluteRow); + } else { + const screenRow = absoluteRow - scrollbackLength; + line = this.wasmTerm.getLine(screenRow); + } + if (!line) return null; + + // Word characters: letters, numbers, and common path/URL characters + // Matches native Ghostty behavior where double-click selects entire paths + // Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings) + const isWordChar = (cell: GhosttyCell) => { + if (!cell || cell.codepoint === 0) return false; + const char = String.fromCodePoint(cell.codepoint); + return /[\w\-./~@+]/.test(char); + }; + + // Only return if we're actually on a word character + if (!isWordChar(line[col])) return null; + + // Find start of word + let startCol = col; + while (startCol > 0 && isWordChar(line[startCol - 1])) { + startCol--; + } + + // Find end of word + let endCol = col; + while (endCol < line.length - 1 && isWordChar(line[endCol + 1])) { + endCol++; + } + + return { startCol, endCol }; + } + + /** + * Copy text to clipboard + * + * Strategy (modern APIs first): + * 1. Try ClipboardItem API (works in Safari and modern browsers) + * - Safari requires the ClipboardItem to be created synchronously within user gesture + * 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari) + * 3. Fall back to execCommand (legacy, for older browsers) + */ + private copyToClipboard(text: string): void { + // First try: ClipboardItem API (modern, Safari-compatible) + // Safari allows this because we create the ClipboardItem synchronously + // within the user gesture, even though the write is async + if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { + try { + const blob = new Blob([text], { type: 'text/plain' }); + const clipboardItem = new ClipboardItem({ + 'text/plain': blob, + }); + navigator.clipboard.write([clipboardItem]).catch((err) => { + console.warn('ClipboardItem write failed, trying writeText:', err); + // Try writeText as fallback + this.copyWithWriteText(text); + }); + return; + } catch (err) { + // ClipboardItem not supported or failed, fall through + } + } + + // Second try: basic async writeText (works in Chrome, may fail in Safari) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn('Clipboard writeText failed, trying execCommand:', err); + // Fall back to execCommand + this.copyWithExecCommand(text); + }); + return; + } + + // Third try: legacy execCommand fallback + this.copyWithExecCommand(text); + } + + /** + * Copy using navigator.clipboard.writeText + */ + private copyWithWriteText(text: string): void { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn('Clipboard writeText failed, trying execCommand:', err); + this.copyWithExecCommand(text); + }); + } else { + this.copyWithExecCommand(text); + } + } + + /** + * Copy using legacy execCommand (fallback for older browsers) + */ + private copyWithExecCommand(text: string): void { + const previouslyFocused = document.activeElement as HTMLElement; + try { + // Position textarea offscreen but in a way that allows selection + const textarea = this.textarea; + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.style.width = '1px'; + textarea.style.height = '1px'; + textarea.style.opacity = '0'; + + // Select all text and copy + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + const success = document.execCommand('copy'); + + // Restore focus + if (previouslyFocused) { + previouslyFocused.focus(); + } + + if (!success) { + console.warn('execCommand copy failed'); + } + } catch (err) { + console.warn('execCommand copy threw:', err); + // Restore focus on error + if (previouslyFocused) { + previouslyFocused.focus(); + } + } + } + + /** + * Request a render update (triggers selection overlay redraw) + */ + private requestRender(): void { + // The render loop will automatically pick up the new selection state + // and redraw the affected lines. This happens at 60fps. + // + // Note: When clearSelection() is called, it adds dirty rows to dirtySelectionRows + // which the renderer can use to know which lines to redraw. + } +} diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts new file mode 100644 index 0000000..92d5f3d --- /dev/null +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -0,0 +1,1916 @@ +/** + * Terminal - Main terminal emulator class + * + * Provides an xterm.js-compatible API wrapping Ghostty's WASM terminal emulator. + * + * Usage: + * ```typescript + * import { init, Terminal } from 'ghostty-web'; + * + * await init(); + * const term = new Terminal(); + * term.open(document.getElementById('container')); + * term.write('Hello, World!\n'); + * term.onData(data => console.log('User typed:', data)); + * ``` + */ + +import { BufferNamespace } from './buffer'; +import { EventEmitter } from './event-emitter'; +import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; +import { getGhostty } from './index'; +import { InputHandler, type MouseTrackingConfig } from './input-handler'; +import type { + IBufferNamespace, + IBufferRange, + IDisposable, + IEvent, + IKeyEvent, + ITerminalAddon, + ITerminalCore, + ITerminalOptions, + IUnicodeVersionProvider, +} from './interfaces'; +import { LinkDetector } from './link-detector'; +import { OSC8LinkProvider } from './providers/osc8-link-provider'; +import { UrlRegexProvider } from './providers/url-regex-provider'; +import { CanvasRenderer } from './renderer'; +import { SelectionManager } from './selection-manager'; +import type { ILink, ILinkProvider } from './types'; + +// ============================================================================ +// Terminal Class +// ============================================================================ + +export class Terminal implements ITerminalCore { + // Public properties (xterm.js compatibility) + public cols: number; + public rows: number; + public element?: HTMLElement; + public textarea?: HTMLTextAreaElement; + + // Buffer API (xterm.js compatibility) + public readonly buffer: IBufferNamespace; + + // Unicode API (xterm.js compatibility) + public readonly unicode: IUnicodeVersionProvider = { + get activeVersion(): string { + return '15.1'; // Ghostty supports Unicode 15.1 + }, + }; + + // Options (public for xterm.js compatibility) + public readonly options!: Required; + + // Components (created on open()) + private ghostty?: Ghostty; + public wasmTerm?: GhosttyTerminal; // Made public for link providers + public renderer?: CanvasRenderer; // Made public for FitAddon + private inputHandler?: InputHandler; + private selectionManager?: SelectionManager; + private canvas?: HTMLCanvasElement; + + // Link detection system + private linkDetector?: LinkDetector; + private currentHoveredLink?: ILink; + private mouseMoveThrottleTimeout?: number; + private pendingMouseMove?: MouseEvent; + + // Event emitters + private dataEmitter = new EventEmitter(); + private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); + private bellEmitter = new EventEmitter(); + private selectionChangeEmitter = new EventEmitter(); + private keyEmitter = new EventEmitter(); + private titleChangeEmitter = new EventEmitter(); + private scrollEmitter = new EventEmitter(); + private renderEmitter = new EventEmitter<{ start: number; end: number }>(); + private cursorMoveEmitter = new EventEmitter(); + // Public event accessors (xterm.js compatibility) + public readonly onData: IEvent = this.dataEmitter.event; + public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; + public readonly onBell: IEvent = this.bellEmitter.event; + public readonly onSelectionChange: IEvent = this.selectionChangeEmitter.event; + public readonly onKey: IEvent = this.keyEmitter.event; + public readonly onTitleChange: IEvent = this.titleChangeEmitter.event; + public readonly onScroll: IEvent = this.scrollEmitter.event; + public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event; + public readonly onCursorMove: IEvent = this.cursorMoveEmitter.event; + + // Lifecycle state + private isOpen = false; + private isDisposed = false; + private animationFrameId?: number; + private writeQueue: Uint8Array[] = []; + private awaitingEcho = false; + + // Addons + private addons: ITerminalAddon[] = []; + + // Phase 1: Custom event handlers + private customKeyEventHandler?: (event: KeyboardEvent) => boolean; + + // Phase 1: Title tracking + private currentTitle: string = ''; + + // Phase 2: Viewport and scrolling state + public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll) + private targetViewportY: number = 0; // Target viewport position for smooth scrolling + private scrollAnimationStartTime?: number; + private scrollAnimationStartY?: number; + private scrollAnimationFrame?: number; + private customWheelEventHandler?: (event: WheelEvent) => boolean; + private lastCursorY: number = 0; // Track cursor position for onCursorMove + + // Scrollbar interaction state + private isDraggingScrollbar: boolean = false; + private scrollbarDragStart: number | null = null; + private scrollbarDragStartViewportY: number = 0; + + // Scrollbar visibility/auto-hide state + private scrollbarVisible: boolean = false; + private scrollbarOpacity: number = 0; + private scrollbarHideTimeout?: number; + private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds + private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation + + constructor(options: ITerminalOptions = {}) { + // Use provided Ghostty instance (for test isolation) or get module-level instance + this.ghostty = options.ghostty ?? getGhostty(); + + // Create base options object with all defaults (excluding ghostty) + const baseOptions = { + cols: options.cols ?? 80, + rows: options.rows ?? 24, + cursorBlink: options.cursorBlink ?? false, + cursorStyle: options.cursorStyle ?? 'block', + theme: options.theme ?? {}, + scrollback: options.scrollback ?? 10000, + fontSize: options.fontSize ?? 15, + fontFamily: options.fontFamily ?? 'monospace', + allowTransparency: options.allowTransparency ?? false, + convertEol: options.convertEol ?? false, + disableStdin: options.disableStdin ?? false, + smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll + }; + + // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) + (this.options as any) = new Proxy(baseOptions, { + set: (target: any, prop: string, value: any) => { + const oldValue = target[prop]; + target[prop] = value; + + // Apply runtime changes if terminal is open + if (this.isOpen) { + this.handleOptionChange(prop, value, oldValue); + } + + return true; + }, + }); + + this.cols = this.options.cols; + this.rows = this.options.rows; + + // Initialize buffer API + this.buffer = new BufferNamespace(this); + } + + // ========================================================================== + // Option Change Handling (for mutable options) + // ========================================================================== + + /** + * Handle runtime option changes (called when options are modified after terminal is open) + * This enables xterm.js compatibility where options can be changed at runtime + */ + private handleOptionChange(key: string, newValue: any, oldValue: any): void { + if (newValue === oldValue) return; + + switch (key) { + case 'disableStdin': + // Input handler already checks this.options.disableStdin dynamically + // No action needed + break; + + case 'cursorBlink': + case 'cursorStyle': + if (this.renderer) { + this.renderer.setCursorStyle(this.options.cursorStyle); + this.renderer.setCursorBlink(this.options.cursorBlink); + } + break; + + case 'theme': + if (this.renderer) { + console.warn('ghostty-web: theme changes after open() are not yet fully supported'); + } + break; + + case 'fontSize': + if (this.renderer) { + this.renderer.setFontSize(this.options.fontSize); + this.handleFontChange(); + } + break; + + case 'fontFamily': + if (this.renderer) { + this.renderer.setFontFamily(this.options.fontFamily); + this.handleFontChange(); + } + break; + + case 'cols': + case 'rows': + // Redirect to resize method + this.resize(this.options.cols, this.options.rows); + break; + } + } + + /** + * Handle font changes (fontSize or fontFamily) + * Updates canvas size to match new font metrics and forces a full re-render + */ + private handleFontChange(): void { + if (!this.renderer || !this.wasmTerm || !this.canvas) return; + + // Clear any active selection since pixel positions have changed + if (this.selectionManager) { + this.selectionManager.clearSelection(); + } + + // Resize canvas to match new font metrics + this.renderer.resize(this.cols, this.rows); + + // Update canvas element dimensions to match renderer + const metrics = this.renderer.getMetrics(); + this.canvas.width = metrics.width * this.cols; + this.canvas.height = metrics.height * this.rows; + this.canvas.style.width = `${metrics.width * this.cols}px`; + this.canvas.style.height = `${metrics.height * this.rows}px`; + + // Force full re-render with new font + this.renderer.render(this.wasmTerm, true, this.viewportY, this); + } + + /** + * Parse a CSS color string to 0xRRGGBB format. + * Returns 0 if the color is undefined or invalid. + */ + private parseColorToHex(color?: string): number { + if (!color) return 0; + + // Handle hex colors (#RGB, #RRGGBB) + if (color.startsWith('#')) { + let hex = color.slice(1); + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + const value = Number.parseInt(hex, 16); + return Number.isNaN(value) ? 0 : value; + } + + // Handle rgb(r, g, b) format + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (match) { + const r = Number.parseInt(match[1], 10); + const g = Number.parseInt(match[2], 10); + const b = Number.parseInt(match[3], 10); + return (r << 16) | (g << 8) | b; + } + + return 0; + } + + /** + * Convert terminal options to WASM terminal config. + */ + private buildWasmConfig(): GhosttyTerminalConfig | undefined { + const theme = this.options.theme; + const scrollback = this.options.scrollback; + + // If no theme and default scrollback, use defaults + if (!theme && scrollback === 10000) { + return undefined; + } + + // Build palette array from theme colors + // Order: black, red, green, yellow, blue, magenta, cyan, white, + // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite + const palette: number[] = [ + this.parseColorToHex(theme?.black), + this.parseColorToHex(theme?.red), + this.parseColorToHex(theme?.green), + this.parseColorToHex(theme?.yellow), + this.parseColorToHex(theme?.blue), + this.parseColorToHex(theme?.magenta), + this.parseColorToHex(theme?.cyan), + this.parseColorToHex(theme?.white), + this.parseColorToHex(theme?.brightBlack), + this.parseColorToHex(theme?.brightRed), + this.parseColorToHex(theme?.brightGreen), + this.parseColorToHex(theme?.brightYellow), + this.parseColorToHex(theme?.brightBlue), + this.parseColorToHex(theme?.brightMagenta), + this.parseColorToHex(theme?.brightCyan), + this.parseColorToHex(theme?.brightWhite), + ]; + + return { + scrollbackLimit: scrollback, + fgColor: this.parseColorToHex(theme?.foreground), + bgColor: this.parseColorToHex(theme?.background), + cursorColor: this.parseColorToHex(theme?.cursor), + palette, + }; + } + + // ========================================================================== + // Lifecycle Methods + // ========================================================================== + + /** + * Open terminal in a parent element + * + * Initializes all components and starts rendering. + * Requires a pre-loaded Ghostty instance passed to the constructor. + */ + open(parent: HTMLElement): void { + if (this.isOpen) { + throw new Error('Terminal is already open'); + } + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + + // Store parent element + this.element = parent; + this.isOpen = true; + + try { + // Make parent focusable if it isn't already + if (!parent.hasAttribute('tabindex')) { + parent.setAttribute('tabindex', '0'); + } + + // Mark as contenteditable so browser extensions (Vimium, etc.) recognize + // this as an input element and don't intercept keyboard events. + parent.setAttribute('contenteditable', 'true'); + // Prevent actual content editing - we handle input ourselves + parent.addEventListener('beforeinput', (e) => { + if (e.target === parent) { + e.preventDefault(); + } + }); + + // Add accessibility attributes for screen readers and extensions + parent.setAttribute('role', 'textbox'); + parent.setAttribute('aria-label', 'Terminal input'); + parent.setAttribute('aria-multiline', 'true'); + + // Create WASM terminal with current dimensions and config + const config = this.buildWasmConfig(); + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + + // Create canvas element + this.canvas = document.createElement('canvas'); + this.canvas.style.display = 'block'; + this.canvas.style.cursor = 'text'; + + parent.appendChild(this.canvas); + + // Create hidden textarea for keyboard input (must be inside parent for event bubbling) + this.textarea = document.createElement('textarea'); + this.textarea.setAttribute('autocorrect', 'off'); + this.textarea.setAttribute('autocapitalize', 'off'); + this.textarea.setAttribute('spellcheck', 'false'); + this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard + this.textarea.setAttribute('aria-label', 'Terminal input'); + // Use clip-path to completely hide the textarea and its caret + this.textarea.style.position = 'absolute'; + this.textarea.style.left = '0'; + this.textarea.style.top = '0'; + this.textarea.style.width = '1px'; + this.textarea.style.height = '1px'; + this.textarea.style.padding = '0'; + this.textarea.style.border = 'none'; + this.textarea.style.margin = '0'; + this.textarea.style.opacity = '0'; + this.textarea.style.clipPath = 'inset(50%)'; // Clip everything including caret + this.textarea.style.overflow = 'hidden'; + this.textarea.style.whiteSpace = 'nowrap'; + this.textarea.style.resize = 'none'; + parent.appendChild(this.textarea); + + // Focus textarea on interaction - preventDefault before focus + const textarea = this.textarea; + // Desktop: mousedown + this.canvas.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + textarea.focus(); + }); + // Mobile: touchend with preventDefault to suppress iOS caret + this.canvas.addEventListener('touchend', (ev) => { + ev.preventDefault(); + textarea.focus(); + }); + + // Create renderer + this.renderer = new CanvasRenderer(this.canvas, { + fontSize: this.options.fontSize, + fontFamily: this.options.fontFamily, + cursorStyle: this.options.cursorStyle, + cursorBlink: this.options.cursorBlink, + theme: this.options.theme, + }); + + // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) + this.renderer.resize(this.cols, this.rows); + + // Create mouse tracking configuration + const canvas = this.canvas; + const renderer = this.renderer; + const wasmTerm = this.wasmTerm; + const mouseConfig: MouseTrackingConfig = { + hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false, + hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode + getCellDimensions: () => ({ + width: renderer.charWidth, + height: renderer.charHeight, + }), + getCanvasOffset: () => { + const rect = canvas.getBoundingClientRect(); + return { left: rect.left, top: rect.top }; + }, + }; + + // Create input handler + this.inputHandler = new InputHandler( + this.ghostty!, + parent, + (data: string) => { + // Check if stdin is disabled + if (this.options.disableStdin) { + return; + } + // Clear selection when user types + this.selectionManager?.clearSelection(); + this.awaitingEcho = true; + // Input handler fires data events + this.dataEmitter.fire(data); + }, + () => { + // Input handler can also fire bell + this.bellEmitter.fire(); + }, + (keyEvent: IKeyEvent) => { + // Forward key events + this.keyEmitter.fire(keyEvent); + }, + this.customKeyEventHandler, + (mode: number) => { + // Query terminal mode state (e.g., mode 1 for application cursor mode) + return this.wasmTerm?.getMode(mode, false) ?? false; + }, + () => { + // Handle Cmd+C copy - returns true if there was a selection to copy + return this.copySelection(); + }, + this.textarea, + mouseConfig + ); + + // Create selection manager (pass textarea for context menu positioning) + this.selectionManager = new SelectionManager( + this, + this.renderer, + this.wasmTerm, + this.textarea + ); + + // Connect selection manager to renderer + this.renderer.setSelectionManager(this.selectionManager); + + // Forward selection change events + this.selectionManager.onSelectionChange(() => { + this.selectionChangeEmitter.fire(); + }); + + // Initialize link detection system + this.linkDetector = new LinkDetector(this); + + // Register link providers + // OSC8 first (explicit hyperlinks take precedence) + this.linkDetector.registerProvider(new OSC8LinkProvider(this)); + // URL regex second (fallback for plain text URLs) + this.linkDetector.registerProvider(new UrlRegexProvider(this)); + + // Setup mouse event handling for links and scrollbar + // Use capture phase to intercept scrollbar clicks before SelectionManager + parent.addEventListener('mousedown', this.handleMouseDown, { capture: true }); + parent.addEventListener('mousemove', this.handleMouseMove); + parent.addEventListener('mouseleave', this.handleMouseLeave); + parent.addEventListener('click', this.handleClick); + + // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) + document.addEventListener('mouseup', this.handleMouseUp); + + // Setup wheel event handling for scrolling (Phase 2) + // Use capture phase to ensure we get the event before browser scrolling + parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); + + // Render initial blank screen (force full redraw) + this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); + + // Start render loop + this.startRenderLoop(); + + // Focus input (auto-focus so user can start typing immediately) + this.focus(); + } catch (error) { + // Clean up on error + this.isOpen = false; + this.cleanupComponents(); + throw new Error(`Failed to open terminal: ${error}`); + } + } + + /** + * Write data to terminal + */ + write(data: string | Uint8Array, callback?: () => void): void { + this.assertOpen(); + + // Handle convertEol option + if (this.options.convertEol && typeof data === 'string') { + data = data.replace(/\n/g, '\r\n'); + } + + this.writeInternal(data, callback); + } + + /** + * Internal write implementation (extracted from write()) + */ + private writeInternal(data: string | Uint8Array, callback?: () => void): void { + // Note: We intentionally do NOT clear selection on write - most modern terminals + // preserve selection when new data arrives. Selection is cleared by user actions + // like clicking or typing, not by incoming data. + + // Write directly to WASM terminal (handles VT parsing internally) + this.wasmTerm!.write(data); + + // Process any responses generated by the terminal (e.g., DSR cursor position) + // These need to be sent back to the PTY via onData + this.processTerminalResponses(); + + // Check for bell character (BEL, \x07) + // WASM doesn't expose bell events, so we detect it in the data stream + if (typeof data === 'string' && data.includes('\x07')) { + this.bellEmitter.fire(); + } else if (data instanceof Uint8Array && data.includes(0x07)) { + this.bellEmitter.fire(); + } + + // Invalidate link cache (content changed) + this.linkDetector?.invalidateCache(); + + // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) + if (this.viewportY !== 0) { + this.scrollToBottom(); + } + + // Check for title changes (OSC 0, 1, 2 sequences) + // This is a simplified implementation - Ghostty WASM may provide this + if (typeof data === 'string' && data.includes('\x1b]')) { + this.checkForTitleChange(data); + } + + // Call callback if provided + if (callback) { + // Queue callback after next render + requestAnimationFrame(callback); + } + + if (this.awaitingEcho) { + this.awaitingEcho = false; + if (this.renderer && this.wasmTerm) { + this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); + } + } + + // Render will happen on next animation frame + } + + /** + * Write data with newline + */ + writeln(data: string | Uint8Array, callback?: () => void): void { + if (typeof data === 'string') { + this.write(data + '\r\n', callback); + } else { + // Append \r\n to Uint8Array + const newData = new Uint8Array(data.length + 2); + newData.set(data); + newData[data.length] = 0x0d; // \r + newData[data.length + 1] = 0x0a; // \n + this.write(newData, callback); + } + } + + /** + * Paste text into terminal (triggers bracketed paste if supported) + */ + paste(data: string): void { + this.assertOpen(); + + // Don't paste if stdin is disabled + if (this.options.disableStdin) { + return; + } + + this.awaitingEcho = true; + + // Check if terminal has bracketed paste mode enabled + if (this.wasmTerm!.hasBracketedPaste()) { + // Wrap with bracketed paste sequences (DEC mode 2004) + this.dataEmitter.fire('\x1b[200~' + data + '\x1b[201~'); + } else { + // Send data directly + this.dataEmitter.fire(data); + } + } + + /** + * Input data into terminal (as if typed by user) + * + * @param data - Data to input + * @param wasUserInput - If true, triggers onData event (default: false for compat with some apps) + */ + input(data: string, wasUserInput: boolean = false): void { + this.assertOpen(); + + // Don't input if stdin is disabled + if (this.options.disableStdin) { + return; + } + + if (wasUserInput) { + this.awaitingEcho = true; + // Trigger onData event as if user typed it + this.dataEmitter.fire(data); + } else { + // Just write to terminal without triggering onData + this.write(data); + } + } + + /** + * Resize terminal + */ + resize(cols: number, rows: number): void { + this.assertOpen(); + + if (cols === this.cols && rows === this.rows) { + return; // No change + } + + // Cancel render loop before resize to prevent accessing detached TypedArray + // views while WASM reallocates buffers. We restart it after resize completes. + // This avoids the background-tab regression of using an isResizing flag + // cleared via requestAnimationFrame (rAF is throttled/paused in background tabs). + this.cancelRenderLoop(); + + try { + // Update dimensions + this.cols = cols; + this.rows = rows; + + // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views) + this.wasmTerm!.resize(cols, rows); + + // Resize renderer + this.renderer!.resize(cols, rows); + + // Update canvas dimensions + const metrics = this.renderer!.getMetrics(); + this.canvas!.width = metrics.width * cols; + this.canvas!.height = metrics.height * rows; + this.canvas!.style.width = `${metrics.width * cols}px`; + this.canvas!.style.height = `${metrics.height * rows}px`; + + // Fire resize event + this.resizeEmitter.fire({ cols, rows }); + + // Force full render + this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); + } catch (e) { + console.error('Terminal resize failed:', e); + } + + // Flush any writes that were queued during resize, then restart render loop + this.flushWriteQueue(); + this.startRenderLoop(); + } + + /** + * Clear terminal screen + */ + clear(): void { + this.assertOpen(); + // Send ANSI clear screen and cursor home sequences + this.wasmTerm!.write('\x1b[2J\x1b[H'); + } + + /** + * Reset terminal state + */ + reset(): void { + this.assertOpen(); + + // Free old WASM terminal and create new one + if (this.wasmTerm) { + this.wasmTerm.free(); + } + const config = this.buildWasmConfig(); + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + + // Clear renderer + this.renderer!.clear(); + + // Reset title + this.currentTitle = ''; + } + + /** + * Focus terminal input + */ + focus(): void { + if (this.isOpen && this.element) { + // Focus immediately for immediate keyboard/wheel event handling + this.element.focus(); + + // Also schedule a delayed focus as backup to ensure it sticks + // (some browsers may need this if DOM isn't fully settled) + setTimeout(() => { + this.element?.focus(); + }, 0); + } + } + + /** + * Blur terminal (remove focus) + */ + blur(): void { + if (this.isOpen && this.element) { + this.element.blur(); + } + } + + /** + * Load an addon + */ + loadAddon(addon: ITerminalAddon): void { + addon.activate(this); + this.addons.push(addon); + } + + // ========================================================================== + // Selection API (xterm.js compatible) + // ========================================================================== + + /** + * Get the selected text as a string + */ + public getSelection(): string { + return this.selectionManager?.getSelection() || ''; + } + + /** + * Check if there's an active selection + */ + public hasSelection(): boolean { + return this.selectionManager?.hasSelection() || false; + } + + /** + * Clear the current selection + */ + public clearSelection(): void { + this.selectionManager?.clearSelection(); + } + + /** + * Copy the current selection to clipboard + * @returns true if there was text to copy, false otherwise + */ + public copySelection(): boolean { + return this.selectionManager?.copySelection() || false; + } + + /** + * Select all text in the terminal + */ + public selectAll(): void { + this.selectionManager?.selectAll(); + } + + /** + * Select text at specific column and row with length + */ + public select(column: number, row: number, length: number): void { + this.selectionManager?.select(column, row, length); + } + + /** + * Select entire lines from start to end + */ + public selectLines(start: number, end: number): void { + this.selectionManager?.selectLines(start, end); + } + + /** + * Get selection position as buffer range + */ + /** + * Get the current viewport Y position. + * + * This is the number of lines scrolled back from the bottom of the + * scrollback buffer. It may be fractional during smooth scrolling. + */ + public getViewportY(): number { + return this.viewportY; + } + + public getSelectionPosition(): IBufferRange | undefined { + return this.selectionManager?.getSelectionPosition(); + } + + // ========================================================================== + // Phase 1: Custom Event Handlers + // ========================================================================== + + /** + * Attach a custom keyboard event handler + * Returns true to prevent default handling + */ + public attachCustomKeyEventHandler( + customKeyEventHandler: (event: KeyboardEvent) => boolean + ): void { + this.customKeyEventHandler = customKeyEventHandler; + // Update input handler if already created + if (this.inputHandler) { + this.inputHandler.setCustomKeyEventHandler(customKeyEventHandler); + } + } + + /** + * Attach a custom wheel event handler (Phase 2) + * Returns true to prevent default handling + */ + public attachCustomWheelEventHandler( + customWheelEventHandler?: (event: WheelEvent) => boolean + ): void { + this.customWheelEventHandler = customWheelEventHandler; + } + + // ========================================================================== + // Link Detection Methods + // ========================================================================== + + /** + * Register a custom link provider + * Multiple providers can be registered to detect different types of links + * + * @example + * ```typescript + * term.registerLinkProvider({ + * provideLinks(y, callback) { + * // Detect URLs, file paths, etc. + * callback(detectedLinks); + * } + * }); + * ``` + */ + public registerLinkProvider(provider: ILinkProvider): void { + if (!this.linkDetector) { + throw new Error('Terminal must be opened before registering link providers'); + } + this.linkDetector.registerProvider(provider); + } + + // ========================================================================== + // Phase 2: Scrolling Methods + // ========================================================================== + + /** + * Scroll viewport by a number of lines + * @param amount Number of lines to scroll (positive = down, negative = up) + */ + public scrollLines(amount: number): void { + if (!this.wasmTerm) { + throw new Error('Terminal not open'); + } + + const scrollbackLength = this.getScrollbackLength(); + const maxScroll = scrollbackLength; + + // Calculate new viewport position + // viewportY = 0 means at bottom (no scroll) + // viewportY > 0 means scrolled up into history + // amount < 0 (scroll up) should INCREASE viewportY + // amount > 0 (scroll down) should DECREASE viewportY + // So we SUBTRACT amount (negative amount becomes positive change) + const newViewportY = Math.max(0, Math.min(maxScroll, this.viewportY - amount)); + + if (newViewportY !== this.viewportY) { + this.viewportY = newViewportY; + this.scrollEmitter.fire(this.viewportY); + + // Show scrollbar when scrolling (with auto-hide) + if (scrollbackLength > 0) { + this.showScrollbar(); + } + } + } + + /** + * Scroll viewport by a number of pages + * @param amount Number of pages to scroll (positive = down, negative = up) + */ + public scrollPages(amount: number): void { + this.scrollLines(amount * this.rows); + } + + /** + * Scroll viewport to the top of the scrollback buffer + */ + public scrollToTop(): void { + const scrollbackLength = this.getScrollbackLength(); + if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) { + this.viewportY = scrollbackLength; + this.scrollEmitter.fire(this.viewportY); + this.showScrollbar(); + } + } + + /** + * Scroll viewport to the bottom (current output) + */ + public scrollToBottom(): void { + if (this.viewportY !== 0) { + this.viewportY = 0; + this.scrollEmitter.fire(this.viewportY); + // Show scrollbar briefly when scrolling to bottom + if (this.getScrollbackLength() > 0) { + this.showScrollbar(); + } + } + } + + /** + * Scroll viewport to a specific line in the buffer + * @param line Line number (0 = top of scrollback, scrollbackLength = bottom) + */ + public scrollToLine(line: number): void { + const scrollbackLength = this.getScrollbackLength(); + const newViewportY = Math.max(0, Math.min(scrollbackLength, line)); + + if (newViewportY !== this.viewportY) { + this.viewportY = newViewportY; + this.scrollEmitter.fire(this.viewportY); + + // Show scrollbar when scrolling to specific line + if (scrollbackLength > 0) { + this.showScrollbar(); + } + } + } + + /** + * Smoothly scroll to a target viewport position + * @param targetY Target viewport Y position (in lines, can be fractional) + */ + private smoothScrollTo(targetY: number): void { + if (!this.wasmTerm) return; + + const scrollbackLength = this.getScrollbackLength(); + const maxScroll = scrollbackLength; + + // Clamp target to valid range + const newTarget = Math.max(0, Math.min(maxScroll, targetY)); + + // If smooth scrolling is disabled (duration = 0), jump immediately + const duration = this.options.smoothScrollDuration ?? 100; + if (duration === 0) { + this.viewportY = newTarget; + this.targetViewportY = newTarget; + this.scrollEmitter.fire(Math.floor(this.viewportY)); + + if (scrollbackLength > 0) { + this.showScrollbar(); + } + return; + } + + // Update target (accumulate if animation running) + this.targetViewportY = newTarget; + + // If animation is already running, don't restart it + // Just let it continue toward the updated target + // This prevents choppy restarts during continuous scrolling + if (this.scrollAnimationFrame) { + return; + } + + // Start new animation + this.scrollAnimationStartTime = Date.now(); + this.scrollAnimationStartY = this.viewportY; + this.animateScroll(); + } + + /** + * Animation loop for smooth scrolling + * Uses asymptotic approach - moves a fraction of remaining distance each frame + */ + private animateScroll = (): void => { + if (!this.wasmTerm || this.scrollAnimationStartTime === undefined) { + return; + } + + const duration = this.options.smoothScrollDuration ?? 100; + + // Calculate distance to target + const distance = this.targetViewportY - this.viewportY; + const absDistance = Math.abs(distance); + + // If very close, snap to target + if (absDistance < 0.01) { + this.viewportY = this.targetViewportY; + this.scrollEmitter.fire(Math.floor(this.viewportY)); + + const scrollbackLength = this.getScrollbackLength(); + if (scrollbackLength > 0) { + this.showScrollbar(); + } + + // Animation complete + this.scrollAnimationFrame = undefined; + this.scrollAnimationStartTime = undefined; + this.scrollAnimationStartY = undefined; + return; + } + + // Move a fraction of the remaining distance + // At 60fps, move ~1/6 of distance per frame for ~100ms total duration + // This creates smooth deceleration toward target + const framesForDuration = (duration / 1000) * 60; // Convert ms to frame count + const moveRatio = 1 - (1 / framesForDuration) ** 2; // Ease-out + this.viewportY += distance * moveRatio; + + // Fire scroll event (use floor to convert fractional to integer for API) + const intViewportY = Math.floor(this.viewportY); + this.scrollEmitter.fire(intViewportY); + + // Show scrollbar during animation + const scrollbackLength = this.getScrollbackLength(); + if (scrollbackLength > 0) { + this.showScrollbar(); + } + + // Continue animation + this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll); + }; + + // ========================================================================== + // Lifecycle + // ========================================================================== + + /** + * Dispose terminal and clean up resources + */ + dispose(): void { + if (this.isDisposed) { + return; + } + + this.isDisposed = true; + this.isOpen = false; + + // Stop render loop and clear write queue + this.cancelRenderLoop(); + this.writeQueue.length = 0; + + // Stop smooth scroll animation + if (this.scrollAnimationFrame) { + cancelAnimationFrame(this.scrollAnimationFrame); + this.scrollAnimationFrame = undefined; + } + + // Clear mouse move throttle timeout + if (this.mouseMoveThrottleTimeout) { + clearTimeout(this.mouseMoveThrottleTimeout); + this.mouseMoveThrottleTimeout = undefined; + } + this.pendingMouseMove = undefined; + + // Dispose addons + for (const addon of this.addons) { + addon.dispose(); + } + this.addons = []; + + // Clean up components + this.cleanupComponents(); + + // Dispose event emitters + this.dataEmitter.dispose(); + this.resizeEmitter.dispose(); + this.bellEmitter.dispose(); + this.selectionChangeEmitter.dispose(); + this.keyEmitter.dispose(); + this.titleChangeEmitter.dispose(); + this.scrollEmitter.dispose(); + this.renderEmitter.dispose(); + this.cursorMoveEmitter.dispose(); + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + /** + * Cancel the render loop + */ + private cancelRenderLoop(): void { + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + } + + /** + * Flush any writes that were queued during resize + */ + private flushWriteQueue(): void { + while (this.writeQueue.length > 0) { + const data = this.writeQueue.shift()!; + this.wasmTerm!.write(data); + } + } + + /** + * Start the render loop + */ + private startRenderLoop(): void { + if (this.animationFrameId) return; // already running + const loop = () => { + if (!this.isDisposed && this.isOpen) { + // Render using WASM's native dirty tracking + // The render() method: + // 1. Calls update() once to sync state and check dirty flags + // 2. Only redraws dirty rows when forceAll=false + // 3. Always calls clearDirty() at the end + this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); + + // Check for cursor movement (Phase 2: onCursorMove event) + // Note: getCursor() reads from already-updated render state (from render() above) + const cursor = this.wasmTerm!.getCursor(); + if (cursor.y !== this.lastCursorY) { + this.lastCursorY = cursor.y; + this.cursorMoveEmitter.fire(); + } + + // Note: onRender event is intentionally not fired in the render loop + // to avoid performance issues. For now, consumers can use requestAnimationFrame + // if they need frame-by-frame updates. + + this.animationFrameId = requestAnimationFrame(loop); + } + }; + loop(); + } + + /** + * Get a line from native WASM scrollback buffer + * Implements IScrollbackProvider + */ + public getScrollbackLine(offset: number): GhosttyCell[] | null { + if (!this.wasmTerm) return null; + return this.wasmTerm.getScrollbackLine(offset); + } + + /** + * Get scrollback length from native WASM + * Implements IScrollbackProvider + */ + public getScrollbackLength(): number { + if (!this.wasmTerm) return 0; + return this.wasmTerm.getScrollbackLength(); + } + + /** + * Clean up components (called on dispose or error) + */ + private cleanupComponents(): void { + // Dispose selection manager + if (this.selectionManager) { + this.selectionManager.dispose(); + this.selectionManager = undefined; + } + + // Dispose input handler + if (this.inputHandler) { + this.inputHandler.dispose(); + this.inputHandler = undefined; + } + + // Dispose renderer + if (this.renderer) { + this.renderer.dispose(); + this.renderer = undefined; + } + + // Remove canvas from DOM + if (this.canvas && this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + this.canvas = undefined; + } + + // Remove textarea from DOM + if (this.textarea && this.textarea.parentNode) { + this.textarea.parentNode.removeChild(this.textarea); + this.textarea = undefined; + } + + // Remove event listeners + if (this.element) { + this.element.removeEventListener('wheel', this.handleWheel); + this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true }); + this.element.removeEventListener('mousemove', this.handleMouseMove); + this.element.removeEventListener('mouseleave', this.handleMouseLeave); + this.element.removeEventListener('click', this.handleClick); + + // Remove contenteditable and accessibility attributes added in open() + this.element.removeAttribute('contenteditable'); + this.element.removeAttribute('role'); + this.element.removeAttribute('aria-label'); + this.element.removeAttribute('aria-multiline'); + } + + // Remove document-level listeners (only if opened) + if (this.isOpen && typeof document !== 'undefined') { + document.removeEventListener('mouseup', this.handleMouseUp); + } + + // Clean up scrollbar timers + if (this.scrollbarHideTimeout) { + window.clearTimeout(this.scrollbarHideTimeout); + this.scrollbarHideTimeout = undefined; + } + + // Dispose link detector + if (this.linkDetector) { + this.linkDetector.dispose(); + this.linkDetector = undefined; + } + + // Free WASM terminal + if (this.wasmTerm) { + this.wasmTerm.free(); + this.wasmTerm = undefined; + } + + // Clear references + this.ghostty = undefined; + this.element = undefined; + this.textarea = undefined; + } + + /** + * Assert terminal is open (throw if not) + */ + private assertOpen(): void { + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + if (!this.isOpen) { + throw new Error('Terminal must be opened before use. Call terminal.open(parent) first.'); + } + } + + /** + * Handle mouse move for link hover detection and scrollbar dragging + * Throttled to avoid blocking scroll events (except when dragging scrollbar) + */ + private handleMouseMove = (e: MouseEvent): void => { + if (!this.canvas || !this.renderer || !this.wasmTerm) return; + + // If dragging scrollbar, handle immediately without throttling + if (this.isDraggingScrollbar) { + this.processScrollbarDrag(e); + return; + } + + if (!this.linkDetector) return; + + // Throttle to ~60fps (16ms) to avoid blocking scroll/other events + if (this.mouseMoveThrottleTimeout) { + this.pendingMouseMove = e; + return; + } + + this.processMouseMove(e); + + this.mouseMoveThrottleTimeout = window.setTimeout(() => { + this.mouseMoveThrottleTimeout = undefined; + if (this.pendingMouseMove) { + const pending = this.pendingMouseMove; + this.pendingMouseMove = undefined; + this.processMouseMove(pending); + } + }, 16); + }; + + /** + * Process mouse move for link detection (internal, called by throttled handler) + */ + private processMouseMove(e: MouseEvent): void { + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Convert mouse coordinates to terminal cell position + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); + const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + + // Get hyperlink_id directly from the cell at this position + // Must account for viewportY (scrollback position) + const viewportRow = y; // Row in the viewport (0 to rows-1) + let hyperlinkId = 0; + + // When scrolled, fetch from scrollback or screen based on position + // NOTE: viewportY may be fractional during smooth scrolling. The renderer + // uses Math.floor(viewportY) when mapping viewport rows to scrollback vs + // screen; we mirror that logic here so link hit-testing matches what the + // user sees on screen. + let line: GhosttyCell[] | null = null; + const rawViewportY = this.getViewportY(); + const viewportY = Math.max(0, Math.floor(rawViewportY)); + if (viewportY > 0) { + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (viewportRow < viewportY) { + // Mouse is over scrollback content + const scrollbackOffset = scrollbackLength - viewportY + viewportRow; + line = this.wasmTerm.getScrollbackLine(scrollbackOffset); + } else { + // Mouse is over screen content (bottom part of viewport) + const screenRow = viewportRow - viewportY; + line = this.wasmTerm.getLine(screenRow); + } + } else { + // At bottom - just use screen buffer + line = this.wasmTerm.getLine(viewportRow); + } + + if (line && x >= 0 && x < line.length) { + hyperlinkId = line[x].hyperlink_id; + } + + // Update renderer for underline rendering + const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; + if (hyperlinkId !== previousHyperlinkId) { + this.renderer.setHoveredHyperlinkId(hyperlinkId); + + // The 60fps render loop will pick up the change automatically + // No need to force a render - this keeps performance smooth + } + + // Check if there's a link at this position (for click handling and cursor) + // Buffer API expects absolute buffer coordinates (including scrollback) + // When scrolled, we need to adjust the buffer row based on viewportY + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let bufferRow: number; + + // Use floored viewportY for buffer mapping (must match renderer & selection) + const rawViewportYForBuffer = this.getViewportY(); + const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer)); + + if (viewportYForBuffer > 0) { + // When scrolled, the buffer row depends on where in the viewport we are + if (viewportRow < viewportYForBuffer) { + // Mouse is over scrollback content + bufferRow = scrollbackLength - viewportYForBuffer + viewportRow; + } else { + // Mouse is over screen content (bottom part of viewport) + const screenRow = viewportRow - viewportYForBuffer; + bufferRow = scrollbackLength + screenRow; + } + } else { + // At bottom - buffer row is scrollback + screen row + bufferRow = scrollbackLength + viewportRow; + } + + // Make async call non-blocking - don't await + this.linkDetector + .getLinkAt(x, bufferRow) + .then((link) => { + // Update hover state for cursor changes and click handling + if (link !== this.currentHoveredLink) { + // Notify old link we're leaving + this.currentHoveredLink?.hover?.(false); + + // Update current link + this.currentHoveredLink = link; + + // Notify new link we're entering + link?.hover?.(true); + + // Update cursor style on both container and canvas + const cursorStyle = link ? 'pointer' : 'text'; + if (this.element) { + this.element.style.cursor = cursorStyle; + } + if (this.canvas) { + this.canvas.style.cursor = cursorStyle; + } + + // Update renderer for underline (for regex URLs without hyperlink_id) + if (this.renderer) { + if (link) { + // Convert buffer coordinates to viewport coordinates + const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0; + + // Calculate viewport Y for start and end positions + // Use floored viewportY so overlay rows match renderer & selection + const rawViewportYForLinks = this.getViewportY(); + const viewportYForLinks = Math.max(0, Math.floor(rawViewportYForLinks)); + const startViewportY = link.range.start.y - scrollbackLength + viewportYForLinks; + const endViewportY = link.range.end.y - scrollbackLength + viewportYForLinks; + + // Only show underline if link is visible in viewport + if (startViewportY < this.rows && endViewportY >= 0) { + this.renderer.setHoveredLinkRange({ + startX: link.range.start.x, + startY: Math.max(0, startViewportY), + endX: link.range.end.x, + endY: Math.min(this.rows - 1, endViewportY), + }); + } else { + this.renderer.setHoveredLinkRange(null); + } + } else { + this.renderer.setHoveredLinkRange(null); + } + } + } + }) + .catch((err) => { + console.warn('Link detection error:', err); + }); + } + + /** + * Handle mouse leave to clear link hover + */ + private handleMouseLeave = (): void => { + // Clear hyperlink underline + if (this.renderer && this.wasmTerm) { + const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; + if (previousHyperlinkId > 0) { + this.renderer.setHoveredHyperlinkId(0); + + // The 60fps render loop will pick up the change automatically + } + // Clear regex link underline + this.renderer.setHoveredLinkRange(null); + } + + if (this.currentHoveredLink) { + // Notify link we're leaving + this.currentHoveredLink.hover?.(false); + + // Clear hovered link + this.currentHoveredLink = undefined; + + // Reset cursor + if (this.element) { + this.element.style.cursor = 'text'; + if (this.canvas) { + this.canvas.style.cursor = 'text'; + } + } + } + }; + + /** + * Handle mouse click for link activation + */ + private handleClick = async (e: MouseEvent): Promise => { + // For more reliable clicking, detect the link at click time + // rather than relying on cached hover state (avoids async races) + if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; + + // Get click position + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); + const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + + // Calculate buffer row (same logic as processMouseMove) + const viewportRow = y; + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + let bufferRow: number; + + // Use floored viewportY for buffer mapping (must match renderer & selection) + const rawViewportYForClick = this.getViewportY(); + const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick)); + + if (viewportYForClick > 0) { + if (viewportRow < viewportYForClick) { + bufferRow = scrollbackLength - viewportYForClick + viewportRow; + } else { + const screenRow = viewportRow - viewportYForClick; + bufferRow = scrollbackLength + screenRow; + } + } else { + bufferRow = scrollbackLength + viewportRow; + } + + // Get the link at this position + const link = await this.linkDetector.getLinkAt(x, bufferRow); + + if (link) { + // Activate link + link.activate(e); + + // Prevent default action if modifier key held + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + } + }; + + /** + * Handle wheel events for scrolling (Phase 2) + */ + private handleWheel = (e: WheelEvent): void => { + // Always prevent default browser scrolling + e.preventDefault(); + e.stopPropagation(); + + // Allow custom handler to override + if (this.customWheelEventHandler && this.customWheelEventHandler(e)) { + return; + } + + // Check if in alternate screen mode (vim, less, htop, etc.) + const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false; + + if (isAltScreen) { + // Alternate screen: send arrow keys to the application + // Applications like vim handle scrolling internally + // Standard: ~3 arrow presses per wheel "click" + const direction = e.deltaY > 0 ? 'down' : 'up'; + const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5 + + for (let i = 0; i < count; i++) { + if (direction === 'up') { + this.dataEmitter.fire('\x1B[A'); // Up arrow + } else { + this.dataEmitter.fire('\x1B[B'); // Down arrow + } + } + } else { + // Normal screen: scroll viewport through history with smooth scrolling + // Handle different deltaMode values for better trackpad/mouse support + let deltaLines: number; + + if (e.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { + // Pixel mode (trackpads): convert pixels to lines + // Use actual line height from renderer for accurate conversion + const lineHeight = this.renderer?.getMetrics()?.height ?? 20; + deltaLines = e.deltaY / lineHeight; + } else if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { + // Line mode (some mice): use directly + deltaLines = e.deltaY; + } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + // Page mode (rare): convert pages to lines + deltaLines = e.deltaY * this.rows; + } else { + // Fallback: assume pixel mode with legacy divisor + deltaLines = e.deltaY / 33; + } + + // Use smooth scrolling for any amount (no rounding needed) + if (deltaLines !== 0) { + // Calculate target position + // deltaY > 0 = scroll down (decrease viewportY) + // deltaY < 0 = scroll up (increase viewportY) + const targetY = this.viewportY - deltaLines; + this.smoothScrollTo(targetY); + } + } + }; + + /** + * Handle mouse down for scrollbar interaction + */ + private handleMouseDown = (e: MouseEvent): void => { + if (!this.canvas || !this.renderer || !this.wasmTerm) return; + + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (scrollbackLength === 0) return; // No scrollbar if no scrollback + + const rect = this.canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate scrollbar dimensions (match renderer's logic) + // Use rect dimensions which are already in CSS pixels + const canvasWidth = rect.width; + const canvasHeight = rect.height; + const scrollbarWidth = 8; + const scrollbarX = canvasWidth - scrollbarWidth - 4; + const scrollbarPadding = 4; + + // Check if click is in scrollbar area + if (mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth) { + // Prevent default and stop propagation to prevent text selection + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event + + // Calculate scrollbar thumb position and size + const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; + const visibleRows = this.rows; + const totalLines = scrollbackLength + visibleRows; + const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); + const scrollPosition = this.viewportY / scrollbackLength; + const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); + + // Check if click is on thumb + if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) { + // Start dragging thumb + this.isDraggingScrollbar = true; + this.scrollbarDragStart = mouseY; + this.scrollbarDragStartViewportY = this.viewportY; + + // Prevent text selection during drag + if (this.canvas) { + this.canvas.style.userSelect = 'none'; + this.canvas.style.webkitUserSelect = 'none'; + } + } else { + // Click on track - jump to position + const relativeY = mouseY - scrollbarPadding; + const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0 + const targetViewportY = Math.round(scrollFraction * scrollbackLength); + this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY))); + } + } + }; + + /** + * Handle mouse up for scrollbar drag + */ + private handleMouseUp = (): void => { + if (this.isDraggingScrollbar) { + this.isDraggingScrollbar = false; + this.scrollbarDragStart = null; + + // Restore text selection + if (this.canvas) { + this.canvas.style.userSelect = ''; + this.canvas.style.webkitUserSelect = ''; + } + + // Schedule auto-hide after drag ends + if (this.scrollbarVisible && this.getScrollbackLength() > 0) { + this.showScrollbar(); // Reset the hide timer + } + } + }; + + /** + * Process scrollbar drag movement + */ + private processScrollbarDrag(e: MouseEvent): void { + if (!this.canvas || !this.renderer || !this.wasmTerm || this.scrollbarDragStart === null) + return; + + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (scrollbackLength === 0) return; + + const rect = this.canvas.getBoundingClientRect(); + const mouseY = e.clientY - rect.top; + + // Calculate how much the mouse moved + const deltaY = mouseY - this.scrollbarDragStart; + + // Convert mouse delta to viewport delta + // Use rect height which is already in CSS pixels + const canvasHeight = rect.height; + const scrollbarPadding = 4; + const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; + const visibleRows = this.rows; + const totalLines = scrollbackLength + visibleRows; + const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); + + // Calculate scroll fraction from thumb movement + // Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases) + const scrollFraction = -deltaY / (scrollbarTrackHeight - thumbHeight); + const viewportDelta = Math.round(scrollFraction * scrollbackLength); + + const newViewportY = this.scrollbarDragStartViewportY + viewportDelta; + this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY))); + } + + /** + * Show scrollbar with fade-in and schedule auto-hide + */ + private showScrollbar(): void { + // Clear any existing hide timeout + if (this.scrollbarHideTimeout) { + window.clearTimeout(this.scrollbarHideTimeout); + this.scrollbarHideTimeout = undefined; + } + + // If not visible, start fade-in + if (!this.scrollbarVisible) { + this.scrollbarVisible = true; + this.scrollbarOpacity = 0; + this.fadeInScrollbar(); + } else { + // Already visible, just ensure it's fully opaque + this.scrollbarOpacity = 1; + } + + // Schedule auto-hide (unless dragging) + if (!this.isDraggingScrollbar) { + this.scrollbarHideTimeout = window.setTimeout(() => { + this.hideScrollbar(); + }, this.SCROLLBAR_HIDE_DELAY_MS); + } + } + + /** + * Hide scrollbar with fade-out + */ + private hideScrollbar(): void { + if (this.scrollbarHideTimeout) { + window.clearTimeout(this.scrollbarHideTimeout); + this.scrollbarHideTimeout = undefined; + } + + if (this.scrollbarVisible) { + this.fadeOutScrollbar(); + } + } + + /** + * Fade in scrollbar + */ + private fadeInScrollbar(): void { + const startTime = Date.now(); + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); + this.scrollbarOpacity = progress; + + // Trigger render to show updated opacity + if (this.renderer && this.wasmTerm) { + this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); + } + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + animate(); + } + + /** + * Fade out scrollbar + */ + private fadeOutScrollbar(): void { + const startTime = Date.now(); + const startOpacity = this.scrollbarOpacity; + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); + this.scrollbarOpacity = startOpacity * (1 - progress); + + // Trigger render to show updated opacity + if (this.renderer && this.wasmTerm) { + this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); + } + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + this.scrollbarVisible = false; + this.scrollbarOpacity = 0; + // Final render to clear scrollbar completely + if (this.renderer && this.wasmTerm) { + this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0); + } + } + }; + animate(); + } + + /** + * Process any pending terminal responses and emit them via onData. + * + * This handles escape sequences that require the terminal to send a response + * back to the PTY, such as: + * - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR + * - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n + * + * Without this, shells like nushell that rely on cursor position queries + * will hang waiting for a response that never comes. + * + * Note: We loop to read all pending responses, not just one. This is important + * when multiple queries are processed in a single write() call (e.g., when + * buffered data is written all at once during terminal initialization). + */ + private processTerminalResponses(): void { + if (!this.wasmTerm) return; + + // Read all pending responses from the WASM terminal + // Multiple responses can be queued if a single write() contained multiple queries + while (true) { + const response = this.wasmTerm.readResponse(); + if (response === null) break; + // Send response back to the PTY via onData + // This is the same path as user keyboard input + this.dataEmitter.fire(response); + } + } + + /** + * Check for title changes in written data (OSC sequences) + * Simplified implementation - looks for OSC 0, 1, 2 + */ + private checkForTitleChange(data: string): void { + // OSC sequences: ESC ] Ps ; Pt BEL or ESC ] Ps ; Pt ST + // OSC 0 = icon + title, OSC 1 = icon, OSC 2 = title + const oscRegex = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex pattern + while ((match = oscRegex.exec(data)) !== null) { + const ps = match[1]; + const pt = match[2]; + + // OSC 0 and OSC 2 set the title + if (ps === '0' || ps === '2') { + if (pt !== this.currentTitle) { + this.currentTitle = pt; + this.titleChangeEmitter.fire(pt); + } + } + } + } + + // ============================================================================ + // Terminal Modes + // ============================================================================ + + /** + * Query terminal mode state + * + * @param mode Mode number (e.g., 2004 for bracketed paste) + * @param isAnsi True for ANSI modes, false for DEC modes (default: false) + * @returns true if mode is enabled + */ + public getMode(mode: number, isAnsi: boolean = false): boolean { + this.assertOpen(); + return this.wasmTerm!.getMode(mode, isAnsi); + } + + /** + * Check if bracketed paste mode is enabled + */ + public hasBracketedPaste(): boolean { + this.assertOpen(); + return this.wasmTerm!.hasBracketedPaste(); + } + + /** + * Check if focus event reporting is enabled + */ + public hasFocusEvents(): boolean { + this.assertOpen(); + return this.wasmTerm!.hasFocusEvents(); + } + + /** + * Check if mouse tracking is enabled + */ + public hasMouseTracking(): boolean { + this.assertOpen(); + return this.wasmTerm!.hasMouseTracking(); + } +} diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts new file mode 100644 index 0000000..4d6eefa --- /dev/null +++ b/src/client/vendor/ghostty-web/types.ts @@ -0,0 +1,797 @@ +/** + * TypeScript type definitions for libghostty-vt WASM API + * Based on include/ghostty/vt/*.h from Ghostty repository + */ + +// ============================================================================ +// SGR (Select Graphic Rendition) Types +// ============================================================================ + +/** + * SGR attribute tags - identifies the type of attribute + * From include/ghostty/vt/sgr.h + */ +export enum SgrAttributeTag { + UNSET = 0, + UNKNOWN = 1, + BOLD = 2, + RESET_BOLD = 3, + ITALIC = 4, + RESET_ITALIC = 5, + FAINT = 6, + RESET_FAINT = 7, + UNDERLINE = 8, + RESET_UNDERLINE = 9, + BLINK = 10, + RESET_BLINK = 11, + INVERSE = 12, + RESET_INVERSE = 13, + INVISIBLE = 14, + RESET_INVISIBLE = 15, + STRIKETHROUGH = 16, + RESET_STRIKETHROUGH = 17, + FG_8 = 18, // 8-color (0-7) + FG_16 = 19, // 16-color (0-15) + FG_256 = 20, // 256-color palette + FG_RGB = 21, // RGB color + FG_DEFAULT = 22, // Reset to default + BG_8 = 23, // Background 8-color + BG_16 = 24, // Background 16-color + BG_256 = 25, // Background 256-color + BG_RGB = 26, // Background RGB + BG_DEFAULT = 27, // Reset background + UNDERLINE_COLOR_8 = 28, + UNDERLINE_COLOR_16 = 29, + UNDERLINE_COLOR_256 = 30, + UNDERLINE_COLOR_RGB = 31, + UNDERLINE_COLOR_DEFAULT = 32, +} + +export interface RGBColor { + r: number; + g: number; + b: number; +} + +export type SgrAttribute = + | { tag: SgrAttributeTag.BOLD } + | { tag: SgrAttributeTag.RESET_BOLD } + | { tag: SgrAttributeTag.ITALIC } + | { tag: SgrAttributeTag.RESET_ITALIC } + | { tag: SgrAttributeTag.FAINT } + | { tag: SgrAttributeTag.RESET_FAINT } + | { tag: SgrAttributeTag.UNDERLINE } + | { tag: SgrAttributeTag.RESET_UNDERLINE } + | { tag: SgrAttributeTag.BLINK } + | { tag: SgrAttributeTag.RESET_BLINK } + | { tag: SgrAttributeTag.INVERSE } + | { tag: SgrAttributeTag.RESET_INVERSE } + | { tag: SgrAttributeTag.INVISIBLE } + | { tag: SgrAttributeTag.RESET_INVISIBLE } + | { tag: SgrAttributeTag.STRIKETHROUGH } + | { tag: SgrAttributeTag.RESET_STRIKETHROUGH } + | { tag: SgrAttributeTag.FG_8; color: number } + | { tag: SgrAttributeTag.FG_16; color: number } + | { tag: SgrAttributeTag.FG_256; color: number } + | { tag: SgrAttributeTag.FG_RGB; color: RGBColor } + | { tag: SgrAttributeTag.FG_DEFAULT } + | { tag: SgrAttributeTag.BG_8; color: number } + | { tag: SgrAttributeTag.BG_16; color: number } + | { tag: SgrAttributeTag.BG_256; color: number } + | { tag: SgrAttributeTag.BG_RGB; color: RGBColor } + | { tag: SgrAttributeTag.BG_DEFAULT } + | { tag: SgrAttributeTag.UNDERLINE_COLOR_RGB; color: RGBColor } + | { tag: SgrAttributeTag.UNDERLINE_COLOR_DEFAULT } + | { tag: SgrAttributeTag.UNKNOWN; params: number[] }; + +// ============================================================================ +// Key Encoder Types +// ============================================================================ + +/** + * Kitty keyboard protocol flags + * From include/ghostty/vt/key/encoder.h + */ +export enum KittyKeyFlags { + DISABLED = 0, + DISAMBIGUATE = 1 << 0, // Disambiguate escape codes + REPORT_EVENTS = 1 << 1, // Report press and release + REPORT_ALTERNATES = 1 << 2, // Report alternate key codes + REPORT_ALL = 1 << 3, // Report all events + REPORT_ASSOCIATED = 1 << 4, // Report associated text + ALL = 0x1f, // All flags enabled +} + +/** + * Key encoder options + */ +export enum KeyEncoderOption { + CURSOR_KEY_APPLICATION = 0, // DEC mode 1 + KEYPAD_KEY_APPLICATION = 1, // DEC mode 66 + IGNORE_KEYPAD_WITH_NUMLOCK = 2, // DEC mode 1035 + ALT_ESC_PREFIX = 3, // DEC mode 1036 + MODIFY_OTHER_KEYS_STATE_2 = 4, // xterm modifyOtherKeys + KITTY_KEYBOARD_FLAGS = 5, // Kitty protocol flags +} + +/** + * Key action + */ +export enum KeyAction { + RELEASE = 0, + PRESS = 1, + REPEAT = 2, +} + +/** + * Physical key codes matching Ghostty's internal Key enum. + * These values are used by Ghostty's key encoder to produce correct escape sequences. + * Reference: ghostty/src/input/key.zig + */ +export enum Key { + // Unidentified key + UNIDENTIFIED = 0, + + // Writing System Keys + GRAVE = 1, // ` and ~ + BACKSLASH = 2, // \ and | + BRACKET_LEFT = 3, // [ and { + BRACKET_RIGHT = 4, // ] and } + COMMA = 5, // , and < + ZERO = 6, + ONE = 7, + TWO = 8, + THREE = 9, + FOUR = 10, + FIVE = 11, + SIX = 12, + SEVEN = 13, + EIGHT = 14, + NINE = 15, + EQUAL = 16, // = and + + INTL_BACKSLASH = 17, + INTL_RO = 18, + INTL_YEN = 19, + A = 20, + B = 21, + C = 22, + D = 23, + E = 24, + F = 25, + G = 26, + H = 27, + I = 28, + J = 29, + K = 30, + L = 31, + M = 32, + N = 33, + O = 34, + P = 35, + Q = 36, + R = 37, + S = 38, + T = 39, + U = 40, + V = 41, + W = 42, + X = 43, + Y = 44, + Z = 45, + MINUS = 46, // - and _ + PERIOD = 47, // . and > + QUOTE = 48, // ' and " + SEMICOLON = 49, // ; and : + SLASH = 50, // / and ? + + // Functional Keys + ALT_LEFT = 51, + ALT_RIGHT = 52, + BACKSPACE = 53, + CAPS_LOCK = 54, + CONTEXT_MENU = 55, + CONTROL_LEFT = 56, + CONTROL_RIGHT = 57, + ENTER = 58, + META_LEFT = 59, + META_RIGHT = 60, + SHIFT_LEFT = 61, + SHIFT_RIGHT = 62, + SPACE = 63, + TAB = 64, + CONVERT = 65, + KANA_MODE = 66, + NON_CONVERT = 67, + + // Control Pad Section + DELETE = 68, + END = 69, + HELP = 70, + HOME = 71, + INSERT = 72, + PAGE_DOWN = 73, + PAGE_UP = 74, + + // Arrow Pad Section + DOWN = 75, + LEFT = 76, + RIGHT = 77, + UP = 78, + + // Numpad Section + NUM_LOCK = 79, + KP_0 = 80, + KP_1 = 81, + KP_2 = 82, + KP_3 = 83, + KP_4 = 84, + KP_5 = 85, + KP_6 = 86, + KP_7 = 87, + KP_8 = 88, + KP_9 = 89, + KP_PLUS = 90, // Keypad + + KP_BACKSPACE = 91, + KP_CLEAR = 92, + KP_CLEAR_ENTRY = 93, + KP_COMMA = 94, + KP_PERIOD = 95, // Keypad . + KP_DIVIDE = 96, // Keypad / + KP_ENTER = 97, // Keypad Enter + KP_EQUAL = 98, + KP_MEMORY_ADD = 99, + KP_MEMORY_CLEAR = 100, + KP_MEMORY_RECALL = 101, + KP_MEMORY_STORE = 102, + KP_MEMORY_SUBTRACT = 103, + KP_MULTIPLY = 104, // Keypad * + KP_PAREN_LEFT = 105, + KP_PAREN_RIGHT = 106, + KP_MINUS = 107, // Keypad - + KP_SEPARATOR = 108, + NUMPAD_UP = 109, + NUMPAD_DOWN = 110, + NUMPAD_RIGHT = 111, + NUMPAD_LEFT = 112, + NUMPAD_BEGIN = 113, + NUMPAD_HOME = 114, + NUMPAD_END = 115, + NUMPAD_INSERT = 116, + NUMPAD_DELETE = 117, + NUMPAD_PAGE_UP = 118, + NUMPAD_PAGE_DOWN = 119, + + // Function Keys + ESCAPE = 120, + F1 = 121, + F2 = 122, + F3 = 123, + F4 = 124, + F5 = 125, + F6 = 126, + F7 = 127, + F8 = 128, + F9 = 129, + F10 = 130, + F11 = 131, + F12 = 132, + F13 = 133, + F14 = 134, + F15 = 135, + F16 = 136, + F17 = 137, + F18 = 138, + F19 = 139, + F20 = 140, + F21 = 141, + F22 = 142, + F23 = 143, + F24 = 144, + F25 = 145, + FN_LOCK = 146, + PRINT_SCREEN = 147, + SCROLL_LOCK = 148, + PAUSE = 149, + + // Media Keys + BROWSER_BACK = 150, + BROWSER_FAVORITES = 151, + BROWSER_FORWARD = 152, + BROWSER_HOME = 153, + BROWSER_REFRESH = 154, + BROWSER_SEARCH = 155, + BROWSER_STOP = 156, + EJECT = 157, + LAUNCH_APP_1 = 158, + LAUNCH_APP_2 = 159, + LAUNCH_MAIL = 160, + MEDIA_PLAY_PAUSE = 161, + MEDIA_SELECT = 162, + MEDIA_STOP = 163, + MEDIA_TRACK_NEXT = 164, + MEDIA_TRACK_PREVIOUS = 165, + POWER = 166, + SLEEP = 167, + AUDIO_VOLUME_DOWN = 168, + AUDIO_VOLUME_MUTE = 169, + AUDIO_VOLUME_UP = 170, + WAKE_UP = 171, + + // Clipboard Keys + COPY = 172, + CUT = 173, + PASTE = 174, +} + +/** + * Modifier keys + */ +export enum Mods { + NONE = 0, + SHIFT = 1 << 0, + CTRL = 1 << 1, + ALT = 1 << 2, + SUPER = 1 << 3, // Windows/Command key + CAPSLOCK = 1 << 4, + NUMLOCK = 1 << 5, +} + +/** + * Key event structure + */ +export interface KeyEvent { + action: KeyAction; + key: Key; + mods: Mods; + consumedMods?: Mods; + composing?: boolean; + utf8?: string; + unshiftedCodepoint?: number; +} + +// ============================================================================ +// WASM Exports Interface +// ============================================================================ + +/** + * Interface for libghostty-vt WASM exports + */ +export interface GhosttyWasmExports extends WebAssembly.Exports { + memory: WebAssembly.Memory; + + // Memory helpers + ghostty_wasm_alloc_opaque(): number; + ghostty_wasm_free_opaque(ptr: number): void; + ghostty_wasm_alloc_u8_array(len: number): number; + ghostty_wasm_free_u8_array(ptr: number, len: number): void; + ghostty_wasm_alloc_u16_array(len: number): number; + ghostty_wasm_free_u16_array(ptr: number, len: number): void; + ghostty_wasm_alloc_u8(): number; + ghostty_wasm_free_u8(ptr: number): void; + ghostty_wasm_alloc_usize(): number; + ghostty_wasm_free_usize(ptr: number): void; + + // SGR parser + ghostty_sgr_new(allocator: number, parserPtrPtr: number): number; + ghostty_sgr_free(parser: number): void; + ghostty_sgr_reset(parser: number): void; + ghostty_sgr_set_params( + parser: number, + paramsPtr: number, + subsPtr: number, + paramsLen: number + ): number; + ghostty_sgr_next(parser: number, attrPtr: number): boolean; + ghostty_sgr_attribute_tag(attrPtr: number): number; + ghostty_sgr_attribute_value(attrPtr: number, tagPtr: number): number; + ghostty_wasm_alloc_sgr_attribute(): number; + ghostty_wasm_free_sgr_attribute(ptr: number): void; + + // Key encoder + ghostty_key_encoder_new(allocator: number, encoderPtrPtr: number): number; + ghostty_key_encoder_free(encoder: number): void; + ghostty_key_encoder_setopt(encoder: number, option: number, valuePtr: number): number; + ghostty_key_encoder_encode( + encoder: number, + eventPtr: number, + bufPtr: number, + bufLen: number, + writtenPtr: number + ): number; + + // Key event + ghostty_key_event_new(allocator: number, eventPtrPtr: number): number; + ghostty_key_event_free(event: number): void; + ghostty_key_event_set_action(event: number, action: number): void; + ghostty_key_event_set_key(event: number, key: number): void; + ghostty_key_event_set_mods(event: number, mods: number): void; + ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; + + // Terminal lifecycle + ghostty_terminal_new(cols: number, rows: number): TerminalHandle; + ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle; + ghostty_terminal_free(terminal: TerminalHandle): void; + ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; + ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; + + // RenderState API - high-performance rendering (ONE call gets ALL data) + ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full + ghostty_render_state_get_cols(terminal: TerminalHandle): number; + ghostty_render_state_get_rows(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean; + ghostty_render_state_get_bg_color(terminal: TerminalHandle): number; // 0xRRGGBB + ghostty_render_state_get_fg_color(terminal: TerminalHandle): number; // 0xRRGGBB + ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean; + ghostty_render_state_mark_clean(terminal: TerminalHandle): void; + ghostty_render_state_get_viewport( + terminal: TerminalHandle, + bufPtr: number, + bufLen: number + ): number; // Returns total cells written or -1 on error + ghostty_render_state_get_grapheme( + terminal: TerminalHandle, + row: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns count of codepoints or -1 on error + + // Terminal modes + ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; + ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; + ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: boolean): number; + + // Scrollback API + ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; + ghostty_terminal_get_scrollback_line( + terminal: TerminalHandle, + offset: number, + bufPtr: number, + bufLen: number + ): number; // Returns cells written or -1 on error + ghostty_terminal_get_scrollback_grapheme( + terminal: TerminalHandle, + offset: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns codepoint count or -1 on error + ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; + + // Hyperlink API + ghostty_terminal_get_hyperlink_uri( + terminal: TerminalHandle, + row: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns bytes written, 0 if no hyperlink, -1 on error + ghostty_terminal_get_scrollback_hyperlink_uri( + terminal: TerminalHandle, + offset: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns bytes written, 0 if no hyperlink, -1 on error + + // Response API (for DSR and other terminal queries) + ghostty_terminal_has_response(terminal: TerminalHandle): boolean; + ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error +} + +// ============================================================================ +// Terminal Types +// ============================================================================ + +/** + * Dirty state from RenderState + */ +export enum DirtyState { + NONE = 0, + PARTIAL = 1, + FULL = 2, +} + +/** + * Cursor state from RenderState (8 bytes packed) + * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8) + */ +export interface RenderStateCursor { + x: number; + y: number; + viewportX: number; // -1 if not in viewport + viewportY: number; + visible: boolean; + blinking: boolean; + style: 'block' | 'underline' | 'bar'; +} + +/** + * Colors from RenderState (12 bytes packed) + */ +export interface RenderStateColors { + background: RGB; + foreground: RGB; + cursor: RGB | null; +} + +/** + * Size of cursor struct in WASM (8 bytes) + */ +export const CURSOR_STRUCT_SIZE = 8; + +/** + * Size of colors struct in WASM (12 bytes) + */ +export const COLORS_STRUCT_SIZE = 12; + +/** + * Terminal configuration (passed to ghostty_terminal_new_with_config) + * All color values use 0xRRGGBB format. A value of 0 means "use default". + */ +export interface GhosttyTerminalConfig { + scrollbackLimit?: number; + fgColor?: number; + bgColor?: number; + cursorColor?: number; + palette?: number[]; +} + +/** + * Size of GhosttyTerminalConfig struct in WASM memory (bytes). + * Layout: scrollback_limit(u32) + fg_color(u32) + bg_color(u32) + cursor_color(u32) + palette[16](u32*16) + * Total: 4 + 4 + 4 + 4 + 64 = 80 bytes + */ +export const GHOSTTY_CONFIG_SIZE = 80; + +/** + * Opaque terminal pointer (WASM memory address) + */ +export type TerminalHandle = number; + +/** + * Cell structure matching ghostty_cell_t in C (16 bytes) + */ +export interface GhosttyCell { + codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) + fg_r: number; // u8 (foreground red) + fg_g: number; // u8 (foreground green) + fg_b: number; // u8 (foreground blue) + bg_r: number; // u8 (background red) + bg_g: number; // u8 (background green) + bg_b: number; // u8 (background blue) + flags: number; // u8 (style flags bitfield) + width: number; // u8 (character width: 1=normal, 2=wide, etc.) + hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set) + grapheme_len: number; // u8 (number of extra codepoints beyond first) +} + +/** + * RGB color + */ +export interface RGB { + r: number; + g: number; + b: number; +} + +/** + * Cell style flags (bitfield) + */ +export enum CellFlags { + BOLD = 1 << 0, + ITALIC = 1 << 1, + UNDERLINE = 1 << 2, + STRIKETHROUGH = 1 << 3, + INVERSE = 1 << 4, + INVISIBLE = 1 << 5, + BLINK = 1 << 6, + FAINT = 1 << 7, +} + +/** + * Cursor position and visibility + */ +export interface Cursor { + x: number; + y: number; + visible: boolean; +} + +/** + * Terminal configuration (passed to ghostty_terminal_new_with_config) + */ +export interface TerminalConfig { + scrollback_limit: number; // Number of scrollback lines (default: 10,000) + fg_color: RGB; // Default foreground color + bg_color: RGB; // Default background color +} + +// ============================================================================ +// Link Detection System +// ============================================================================ + +/** + * Represents a coordinate in the terminal buffer + */ +export interface IBufferCellPosition { + x: number; // Column (0-based) + y: number; // Row (0-based, absolute buffer position) +} + +/** + * Represents a range in the terminal buffer + * Can span multiple lines for wrapped links + */ +export interface IBufferRange { + start: IBufferCellPosition; + end: IBufferCellPosition; // Inclusive +} + +/** + * Represents a detected link in the terminal + */ +export interface ILink { + /** The URL or text of the link */ + text: string; + + /** The range of the link in the buffer (may span multiple lines) */ + range: IBufferRange; + + /** Called when the link is activated (clicked with modifier) */ + activate(event: MouseEvent): void; + + /** Optional: called when mouse enters/leaves the link */ + hover?(isHovered: boolean): void; + + /** Optional: called to clean up resources */ + dispose?(): void; +} + +/** + * Provides link detection for a specific type of link + * Examples: OSC 8 hyperlinks, URL regex detection + */ +export interface ILinkProvider { + /** + * Provide links for a given row + * @param y Absolute row in buffer (0-based) + * @param callback Called with detected links (or undefined if none) + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; + + /** Optional: called when terminal is disposed */ + dispose?(): void; +} + +/** + * Simplified buffer line interface for link providers + */ +export interface IBufferLine { + /** Number of cells in this line */ + length: number; + + /** Get cell at position */ + getCell(x: number): IBufferCell; + + /** Get text content of the line */ + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} + +/** + * Simplified buffer cell interface for link providers + */ +export interface IBufferCell { + /** Get the character codepoint */ + getCodepoint(): number; + + /** Get the hyperlink ID (0 = no link) */ + getHyperlinkId(): number; + + /** Get the width of the character (1 or 2 for wide chars) */ + getWidth(): number; + + /** Check if cell has specific flags */ + isBold(): boolean; + isItalic(): boolean; + isDim(): boolean; +} + +/** + * Simplified terminal buffer interface for link providers + */ +export interface IBuffer { + /** Number of rows in the buffer (viewport + scrollback) */ + length: number; + + /** Get line at absolute buffer position */ + getLine(y: number): IBufferLine; +} + +/** + * Terminal buffer manager (active vs alternate screen) + */ +export interface IBufferManager { + /** Currently active buffer */ + active: IBuffer; + + /** Normal screen buffer */ + normal: IBuffer; + + /** Alternate screen buffer (for fullscreen apps) */ + alternate: IBuffer; +} + +/** + * Event system interface (xterm.js compatible) + */ +export type IEvent = (listener: (data: T) => void) => IDisposable; + +export interface IDisposable { + dispose(): void; +} + +/** + * Event emitter for custom events + */ +export class EventEmitter { + private listeners: Array<(data: T) => void> = []; + + /** Subscribe to events */ + public readonly event: IEvent = (listener: (data: T) => void): IDisposable => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + } + }, + }; + }; + + /** Emit an event to all listeners */ + public fire(data: T): void { + for (const listener of this.listeners) { + listener(data); + } + } + + /** Remove all listeners */ + public dispose(): void { + this.listeners = []; + } +} + +/** + * Terminal mode identifiers + * + * ANSI modes (use with is_ansi = true): + * - INSERT = 4 + * + * DEC modes (use with is_ansi = false): + * - CURSOR_VISIBLE = 25 + * - MOUSE_TRACKING_NORMAL = 1000 + * - MOUSE_TRACKING_BUTTON = 1002 + * - MOUSE_TRACKING_ANY = 1003 + * - FOCUS_EVENTS = 1004 + * - ALT_SCREEN = 1047 + * - ALT_SCREEN_WITH_CURSOR = 1049 + * - BRACKETED_PASTE = 2004 + */ +export enum TerminalMode { + // ANSI modes + INSERT = 4, + + // DEC modes + CURSOR_VISIBLE = 25, + MOUSE_TRACKING_NORMAL = 1000, + MOUSE_TRACKING_BUTTON = 1002, + MOUSE_TRACKING_ANY = 1003, + FOCUS_EVENTS = 1004, + ALT_SCREEN = 1047, + ALT_SCREEN_WITH_CURSOR = 1049, + BRACKETED_PASTE = 2004, +} diff --git a/src/server/index.ts b/src/server/index.ts index ac3e85b..24c1df0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config'; import { handleRequest } from './routes'; -import { findGhosttyWeb } from './static'; +import { findGhosttyWasm } from './static'; import { closeAllSessions, createWebSocketServer, setLastSessionClosedHandler } from './websocket'; const __filename = fileURLToPath(import.meta.url); @@ -20,7 +20,7 @@ delete process.env.PORT; const HTTP_HOST_DISPLAY = config.host; const HTTP_HOST = config.host === 'localhost' ? '127.0.0.1' : config.host; -const { distPath, wasmPath } = findGhosttyWeb(); +const wasmPath = findGhosttyWasm(); const projectRoot = path.resolve(__dirname, '..', '..'); const builtDistPath = path.join(projectRoot, 'dist'); const srcClientPath = path.join(projectRoot, 'src', 'client'); @@ -47,7 +47,7 @@ process.on('unhandledRejection', (reason) => { }); const httpServer = http.createServer((req, res) => { - handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown).catch((err) => { + handleRequest(req, res, wasmPath, clientDistPath, shutdown).catch((err) => { console.error('[webtty] unhandled route error:', err); if (!res.headersSent) { res.writeHead(500); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index e4454fc..3734611 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { type ChildProcess, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -45,6 +47,21 @@ describe('server — routes', () => { expect(res.status).toBe(404); }); + test('GET /ghostty-vt.wasm serves the vendored wasm byte for byte', async () => { + const sidecar = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, '../client/vendor/ghostty-web/ghostty-vt.wasm.json'), + 'utf8', + ), + ) as { size: number; sha256: string }; + const res = await fetch(`${baseUrl}/ghostty-vt.wasm`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/wasm'); + const body = new Uint8Array(await res.arrayBuffer()); + expect(body.byteLength).toBe(sidecar.size); + expect(createHash('sha256').update(body).digest('hex')).toBe(sidecar.sha256); + }); + test('GET / redirects to /s/main when no sessions exist', async () => { const res = await fetch(`${baseUrl}/`, { redirect: 'manual' }); expect(res.status).toBe(302); diff --git a/src/server/routes.ts b/src/server/routes.ts index 2fde9d9..52d79ed 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -72,7 +72,6 @@ export function readJson(req: http.IncomingMessage): Promise { * * @param req - The incoming HTTP request. * @param res - The HTTP response object. - * @param distPath - Path to the server-side dist directory. * @param wasmPath - Path to the ghostty-vt.wasm file. * @param clientDistPath - Path to the client dist directory. * @param onStop - Callback invoked when `POST /api/server/stop` is received. @@ -80,7 +79,6 @@ export function readJson(req: http.IncomingMessage): Promise { export async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, - distPath: string, wasmPath: string, clientDistPath: string, onStop: () => void, @@ -463,25 +461,13 @@ export async function handleRequest( } if (pathname.startsWith('/dist/')) { - const relativePath = pathname.slice(6); - const ownFile = path.resolve(clientDistPath, relativePath); + const ownFile = path.resolve(clientDistPath, pathname.slice(6)); if (ownFile.startsWith(clientDistPath + path.sep)) { - const fs = await import('node:fs'); - if (fs.existsSync(ownFile)) { - serveFile(ownFile, res); - return; - } - } - const filePath = path.resolve(distPath, relativePath); - if ( - !filePath.startsWith(path.resolve(distPath) + path.sep) && - filePath !== path.resolve(distPath) - ) { - res.writeHead(404); - res.end('Not Found'); + serveFile(ownFile, res); return; } - serveFile(filePath, res); + res.writeHead(404); + res.end('Not Found'); return; } diff --git a/src/server/static.test.ts b/src/server/static.test.ts index f72cfff..26a9baa 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -1,7 +1,7 @@ import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { findGhosttyWeb, ghosttyWebRootFromMain, mimeType, serveFile } from './static'; +import { findGhosttyWasm, mimeType, serveFile } from './static'; describe('mimeType', () => { test('returns correct type for known extensions', () => { @@ -22,59 +22,39 @@ describe('mimeType', () => { }); test('works with full paths', () => { - expect(mimeType('/dist/ghostty-web.js')).toBe('application/javascript'); + expect(mimeType('/dist/client-browser.js')).toBe('application/javascript'); expect(mimeType('/dist/ghostty-vt.wasm')).toBe('application/wasm'); }); }); -describe('ghosttyWebRootFromMain', () => { - test('strips dist/ and filename on posix path', () => { - expect(ghosttyWebRootFromMain('/node_modules/ghostty-web/dist/index.js')).toBe( - '/node_modules/ghostty-web', - ); - }); - - test('strips nested dist/ path', () => { - expect(ghosttyWebRootFromMain('/node_modules/ghostty-web/dist/ghostty-web.js')).toBe( - '/node_modules/ghostty-web', - ); - }); - - test('strips windows-style path', () => { - expect(ghosttyWebRootFromMain('C:\\node_modules\\ghostty-web\\dist\\index.js')).toBe( - 'C:\\node_modules\\ghostty-web', - ); - }); -}); +describe('findGhosttyWasm', () => { + const VENDORED = path.join('src', 'client', 'vendor', 'ghostty-web', 'ghostty-vt.wasm'); + const BUILT = path.join('dist', 'ghostty-vt.wasm'); -describe('findGhosttyWeb', () => { - test('returns distPath and wasmPath when ghostty-web is installed', () => { - const { distPath, wasmPath } = findGhosttyWeb(); - expect(fs.existsSync(path.join(distPath, 'ghostty-web.js'))).toBe(true); - expect(wasmPath).toContain('ghostty-vt.wasm'); + test('prefers the wasm copied into dist/ by the build', () => { + const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => String(p).endsWith(BUILT)); + expect(findGhosttyWasm().endsWith(BUILT)).toBe(true); + existsSpy.mockRestore(); }); - test('falls back to node_modules when bundled assets are missing', () => { + test('falls back to the vendored wasm when dist/ has none', () => { const realExistsSync = fs.existsSync.bind(fs); const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => { const s = String(p); - if (s.includes('ghostty-web.js') && !s.includes('node_modules')) return false; - if (s.includes('ghostty-vt.wasm') && !s.includes('node_modules')) return false; - return realExistsSync(s); + return s.endsWith(BUILT) ? false : realExistsSync(s); }); - - const { distPath, wasmPath } = findGhosttyWeb(); - expect(distPath).toContain('ghostty-web'); - expect(wasmPath).toContain('ghostty-vt.wasm'); + const wasmPath = findGhosttyWasm(); + expect(wasmPath.endsWith(VENDORED)).toBe(true); + expect(realExistsSync(wasmPath)).toBe(true); existsSpy.mockRestore(); }); - test('exits when ghostty-web files are missing', () => { + test('exits when the wasm is missing', () => { const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - findGhosttyWeb(); + findGhosttyWasm(); expect(exitSpy).toHaveBeenCalledWith(1); existsSpy.mockRestore(); diff --git a/src/server/static.ts b/src/server/static.ts index 561b3c9..7eda026 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -1,14 +1,11 @@ import fs from 'node:fs'; import type http from 'node:http'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const require = createRequire(import.meta.url); - /** File extension → MIME type map used by {@link serveFile}. */ export const MIME_TYPES: Record = { '.html': 'text/html', @@ -34,43 +31,22 @@ export function mimeType(filePath: string): string { } /** - * Strips the `dist/…` suffix from a ghostty-web main entry path to get the package root. + * Locates `ghostty-vt.wasm`, preferring the copy the build puts in `dist/` (npm install) + * over the vendored source copy (local dev). Exits the process if neither exists. * - * @param mainPath - The main entry path from ghostty-web package. - * @returns The package root directory path. + * @returns The absolute path to `ghostty-vt.wasm`. + * @throws Exits the process with code 1 if the wasm cannot be found. */ -export function ghosttyWebRootFromMain(mainPath: string): string { - return mainPath.replace(/[/\\]dist[/\\].*$/, ''); -} - -/** - * Locates the ghostty-web package, preferring assets bundled in `dist/` (npm install) - * over `node_modules/` (local dev). Exits the process if the package cannot be found. - * - * @returns An object with `distPath` (ghostty-web dist directory) and `wasmPath` (ghostty-vt.wasm file). - * @throws Exits the process with code 1 if ghostty-web cannot be found. - */ -export function findGhosttyWeb(): { distPath: string; wasmPath: string } { - // Prefer assets bundled into dist/ — present when installed via npx/npm. - const bundledDist = path.join(__dirname, '..', '..', 'dist'); - const bundledWasm = path.join(bundledDist, 'ghostty-vt.wasm'); - if (fs.existsSync(path.join(bundledDist, 'ghostty-web.js')) && fs.existsSync(bundledWasm)) { - return { distPath: bundledDist, wasmPath: bundledWasm }; - } - - // Fall back to node_modules — present during local development. - try { - const ghosttyWebMain = require.resolve('ghostty-web') as string; - const ghosttyWebRoot = ghosttyWebRootFromMain(ghosttyWebMain); - const distPath = path.join(ghosttyWebRoot, 'dist'); - const wasmPath = path.join(ghosttyWebRoot, 'ghostty-vt.wasm'); - if (fs.existsSync(path.join(distPath, 'ghostty-web.js')) && fs.existsSync(wasmPath)) { - return { distPath, wasmPath }; - } - } catch { - // fall through +export function findGhosttyWasm(): string { + const projectRoot = path.join(__dirname, '..', '..'); + const candidates = [ + path.join(projectRoot, 'dist', 'ghostty-vt.wasm'), + path.join(projectRoot, 'src', 'client', 'vendor', 'ghostty-web', 'ghostty-vt.wasm'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; } - console.error('Error: Could not find ghostty-web package.'); + console.error('Error: Could not find ghostty-vt.wasm.'); process.exit(1); } diff --git a/src/version.test.ts b/src/version.test.ts index 5924034..ae9064f 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -33,7 +33,7 @@ describe('getVersion — bundled dist', () => { path.join(pkgRoot, 'package.json'), JSON.stringify({ name: 'webtty', version: FAKE_VERSION }), ); - // Externals (ws, node-pty, ghostty-web) resolve at runtime from the package root. + // Externals (ws, node-pty) resolve at runtime from the package root. fs.symlinkSync( path.join(REPO_ROOT, 'node_modules'), path.join(pkgRoot, 'node_modules'), @@ -48,9 +48,14 @@ describe('getVersion — bundled dist', () => { outdir: path.join(pkgRoot, 'dist'), target: 'node', format: 'esm', - external: ['@lydell/node-pty', 'ws', 'ghostty-web'], + external: ['@lydell/node-pty', 'ws'], }); if (!result.success) throw new Error(`bundle failed: ${result.logs.join('\n')}`); + // The server needs ghostty-vt.wasm at startup; the published package ships it in dist/. + fs.copyFileSync( + path.join(REPO_ROOT, 'src/client/vendor/ghostty-web/ghostty-vt.wasm'), + path.join(pkgRoot, 'dist/ghostty-vt.wasm'), + ); cliEntry = path.join(pkgRoot, 'dist/cli/index.js'); serverEntry = path.join(pkgRoot, 'dist/server/index.js'); }); From 25c66ba26429ddec9283ec18578cb723190113c2 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 22:22:42 -0400 Subject: [PATCH 003/295] fix(client): use the default pointer over the terminal at source ghostty-web's Terminal hard-coded the 'text' I-beam whenever the mouse was not over a link, which reads as editable text over TUI menus and widgets. The vendored terminal.ts now picks the cursor via mouseCursorFor() ('default' off links, 'pointer' on them), so the MutationObserver in index.ts that rewrote 'text' to 'default' after the fact is deleted. --- src/client/index.ts | 14 -------------- src/client/vendor/ghostty-web/PROVENANCE.md | 4 ++++ src/client/vendor/ghostty-web/terminal.ts | 17 +++++++++++++---- src/client/vendor/terminal.test.ts | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 18 deletions(-) create mode 100644 src/client/vendor/terminal.test.ts diff --git a/src/client/index.ts b/src/client/index.ts index 3f221b5..5bc4b56 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -219,20 +219,6 @@ function watchDevicePixelRatio(): void { syncDevicePixelRatio(); watchDevicePixelRatio(); -// ghostty-web sets the mouse cursor to 'text' (I-beam) whenever the pointer -// isn't over a detected hyperlink — it only ever writes 'text' or 'pointer' -// (see the hover-link logic in ghostty-web's dist bundle). The I-beam reads -// as "editable text" even over TUI menus/widgets, which is misleading, so -// rewrite it to the platform default arrow while leaving the hyperlink -// 'pointer' cursor untouched. -function normalizeCursor(el: HTMLElement): void { - if (el.style.cursor === 'text') el.style.cursor = 'default'; -} -normalizeCursor(container); -new MutationObserver((mutations) => { - for (const m of mutations) normalizeCursor(m.target as HTMLElement); -}).observe(container, { attributes: true, attributeFilter: ['style'], subtree: true }); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index d4fa3cd..36789e8 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -9,3 +9,7 @@ Vendored copy of [coder/ghostty-web](https://github.com/coder/ghostty-web), MIT `bun run build` writes both notices to `dist/THIRD_PARTY_LICENSES`. webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. + +## Local changes + +- `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 92d5f3d..dbe6466 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -42,6 +42,15 @@ import type { ILink, ILinkProvider } from './types'; // Terminal Class // ============================================================================ +/** + * Mouse cursor for the terminal surface: 'pointer' over a link, otherwise the + * platform default arrow. (webtty: upstream used the 'text' I-beam, which reads + * as editable text over TUI menus and widgets.) + */ +export function mouseCursorFor(link: ILink | undefined): 'pointer' | 'default' { + return link ? 'pointer' : 'default'; +} + export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; @@ -377,7 +386,7 @@ export class Terminal implements ITerminalCore { // Create canvas element this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; - this.canvas.style.cursor = 'text'; + this.canvas.style.cursor = mouseCursorFor(undefined); parent.appendChild(this.canvas); @@ -1428,7 +1437,7 @@ export class Terminal implements ITerminalCore { link?.hover?.(true); // Update cursor style on both container and canvas - const cursorStyle = link ? 'pointer' : 'text'; + const cursorStyle = mouseCursorFor(link); if (this.element) { this.element.style.cursor = cursorStyle; } @@ -1496,9 +1505,9 @@ export class Terminal implements ITerminalCore { // Reset cursor if (this.element) { - this.element.style.cursor = 'text'; + this.element.style.cursor = mouseCursorFor(undefined); if (this.canvas) { - this.canvas.style.cursor = 'text'; + this.canvas.style.cursor = mouseCursorFor(undefined); } } } diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts new file mode 100644 index 0000000..3cb8860 --- /dev/null +++ b/src/client/vendor/terminal.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test'; +import { mouseCursorFor } from './ghostty-web/terminal'; +import type { ILink } from './ghostty-web/types'; + +describe('mouseCursorFor', () => { + const link: ILink = { + text: 'https://example.com', + range: { start: { x: 0, y: 0 }, end: { x: 18, y: 0 } }, + activate: () => {}, + }; + + test('uses the default arrow when no link is hovered', () => { + expect(mouseCursorFor(undefined)).toBe('default'); + }); + + test('uses the pointer over a link', () => { + expect(mouseCursorFor(link)).toBe('pointer'); + }); +}); From b26436ddb4b4889f8fa191ea129a6af2adfbcc68 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 22:29:24 -0400 Subject: [PATCH 004/295] fix(client): encode hover motion correctly at source ghostty-web's InputHandler reported no-button motion as Cb=32 (the left-drag code) and sent a motion report per mousemove, not per cell. input-handler.ts now encodes hover as 35 (mouseMotionButton), dedups motion per cell (isNewMouseCell), and clears held buttons when event.buttons is 0. This deletes webtty's src/client/mouse.ts hover rewrite and drag dedup. --- src/client/index.ts | 21 ------ src/client/mouse.test.ts | 68 ------------------- src/client/mouse.ts | 60 ---------------- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/input-handler.ts | 40 +++++++++-- src/client/vendor/input-handler.test.ts | 43 ++++++++++++ 6 files changed, 77 insertions(+), 156 deletions(-) delete mode 100644 src/client/mouse.test.ts delete mode 100644 src/client/mouse.ts create mode 100644 src/client/vendor/input-handler.test.ts diff --git a/src/client/index.ts b/src/client/index.ts index 5bc4b56..f98b34a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,7 +3,6 @@ import { applyDecscusr } from './cursor'; import { EdgeExtender } from './edge'; import { computeGrid, computeInsets } from './fit'; import { KittyGraphics } from './graphics'; -import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; import { encodePaste } from './paste'; import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; @@ -384,27 +383,7 @@ window.addEventListener( ); // Forward terminal keystrokes and input to the PTY over WebSocket. -// See src/client/mouse.ts for the ghostty-web SGR bug and the two fixes -// (hover rewrite + drag dedup) applied below. -let isHoverMove = false; -let lastDragSeq = ''; -container.addEventListener( - 'mousemove', - (e: MouseEvent) => { - isHoverMove = term.hasMouseTracking() && e.buttons === 0; - if (isHoverMove) lastDragSeq = ''; // reset dedup on button release - }, - { capture: true }, -); term.onData((data: string) => { - const seq = rewriteHoverMotion(data, isHoverMove); - if (seq !== data) { - // Hover: forwarded with corrected Cb=35 (no-button motion per SGR spec) - if (ws && ws.readyState === WebSocket.OPEN) ws.send(seq); - return; - } - if (isDuplicateDrag(data, lastDragSeq)) return; // drop same-cell drag repeat - lastDragSeq = data.startsWith('\x1b[<32;') ? data : ''; // track or reset if (ws && ws.readyState === WebSocket.OPEN) { ws.send(data); } diff --git a/src/client/mouse.test.ts b/src/client/mouse.test.ts deleted file mode 100644 index 67ab80d..0000000 --- a/src/client/mouse.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; - -describe('rewriteHoverMotion', () => { - test('rewrites \\x1b[<32; to \\x1b[<35; when isHover is true', () => { - expect(rewriteHoverMotion('\x1b[<32;8;4M', true)).toBe('\x1b[<35;8;4M'); - }); - - test('preserves col;row and M suffix after rewrite', () => { - expect(rewriteHoverMotion('\x1b[<32;120;50M', true)).toBe('\x1b[<35;120;50M'); - }); - - test('returns data unchanged when isHover is false (drag)', () => { - expect(rewriteHoverMotion('\x1b[<32;8;4M', false)).toBe('\x1b[<32;8;4M'); - }); - - test('returns data unchanged for non-motion sequences regardless of isHover', () => { - // left press - expect(rewriteHoverMotion('\x1b[<0;8;4M', true)).toBe('\x1b[<0;8;4M'); - // left release - expect(rewriteHoverMotion('\x1b[<0;8;4m', true)).toBe('\x1b[<0;8;4m'); - // scroll up - expect(rewriteHoverMotion('\x1b[<64;8;4M', true)).toBe('\x1b[<64;8;4M'); - // middle drag - expect(rewriteHoverMotion('\x1b[<33;8;4M', true)).toBe('\x1b[<33;8;4M'); - }); - - test('returns data unchanged for plain text', () => { - expect(rewriteHoverMotion('hello', true)).toBe('hello'); - }); - - test('does not rewrite when isHover is false even for button-32', () => { - // pointer moved to a new cell while button is held — must not rewrite - const drag = '\x1b[<32;9;4M'; - expect(rewriteHoverMotion(drag, false)).toBe(drag); - }); -}); - -describe('isDuplicateDrag', () => { - test('returns true for identical consecutive button-32 sequences', () => { - const seq = '\x1b[<32;8;4M'; - expect(isDuplicateDrag(seq, seq)).toBe(true); - }); - - test('returns false when position changes', () => { - expect(isDuplicateDrag('\x1b[<32;9;4M', '\x1b[<32;8;4M')).toBe(false); - }); - - test('returns false when lastDragSeq is empty (first drag event)', () => { - expect(isDuplicateDrag('\x1b[<32;8;4M', '')).toBe(false); - }); - - test('returns false for non-drag sequences even if identical', () => { - // press, release, scroll must never be deduped - expect(isDuplicateDrag('\x1b[<0;8;4M', '\x1b[<0;8;4M')).toBe(false); - expect(isDuplicateDrag('\x1b[<0;8;4m', '\x1b[<0;8;4m')).toBe(false); - expect(isDuplicateDrag('\x1b[<64;8;4M', '\x1b[<64;8;4M')).toBe(false); - }); - - test('returns false when row differs', () => { - expect(isDuplicateDrag('\x1b[<32;8;5M', '\x1b[<32;8;4M')).toBe(false); - }); - - test('returns true only for exact string match', () => { - expect(isDuplicateDrag('\x1b[<32;8;4M', '\x1b[<32;8;4M')).toBe(true); - expect(isDuplicateDrag('\x1b[<32;8;4M', '\x1b[<35;8;4M')).toBe(false); - }); -}); diff --git a/src/client/mouse.ts b/src/client/mouse.ts deleted file mode 100644 index b0e7adb..0000000 --- a/src/client/mouse.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * SGR mouse sequence helpers. - * - * ghostty-web bug: handleMouseMove always encodes motion as SGR button-32 - * (\x1b[<32;col;rowM) regardless of whether a button is held. It uses its - * own mouseButtonsPressed bitmask; when no button is pressed the bitmask is 0, - * and 0+32=32 — the same Cb value as a left-button drag. Real terminals use - * Cb=35 (32+3, "no button") for hover and Cb=32 for left-button drag. - * - * Impact on vim: with `set mouse=a` vim requests mode 1003 (any-event - * tracking). Every hover move arrives as \x1b[<32;…M which vim decodes as - * , toggling visual mode on each pixel of cursor movement. - * - * The two exported functions implement the fixes applied in onData: - * - * - rewriteHoverMotion: when the DOM reports no button held (e.buttons===0), - * rewrite \x1b[<32; → \x1b[<35; so vim receives the correct no-button code - * (Cb=35 per the SGR spec). The position is still forwarded so vim can - * track the cursor for subsequent drag operations. - * - * - isDuplicateDrag: ghostty-web fires multiple mousemove events for the same - * character cell while the pointer stays within it. Vim treats a - * at the same cell twice as a visual-mode toggle (enter → exit), so - * consecutive identical drag sequences must be deduplicated. - */ - -/** Prefix that ghostty-web emits for both hover and left-button drag. */ -const DRAG_PREFIX = '\x1b[<32;'; -/** Correct SGR prefix for no-button hover motion (Cb = 32 + 3 = 35). */ -const HOVER_PREFIX = '\x1b[<35;'; - -/** - * If `isHover` is true and `data` is a ghostty-web hover-misencoded drag - * sequence, rewrite the button byte from 32 to 35 (the correct SGR no-button - * code) and return the corrected sequence. Otherwise return `data` unchanged. - * - * @param data Raw SGR sequence from ghostty-web's onData callback. - * @param isHover Whether the originating DOM mousemove had `e.buttons === 0`. - */ -export function rewriteHoverMotion(data: string, isHover: boolean): string { - if (isHover && data.startsWith(DRAG_PREFIX)) { - return HOVER_PREFIX + data.slice(DRAG_PREFIX.length); - } - return data; -} - -/** - * Returns true when `data` is a left-button drag sequence (`\x1b[<32;…M`) - * that is identical to the previous drag sequence, indicating the pointer - * has not moved to a new character cell. - * - * Callers should maintain `lastDragSeq` state and pass it in; on a false - * return they update `lastDragSeq = data`, on a true return they skip sending. - * - * @param data Raw SGR sequence from ghostty-web's onData callback. - * @param lastDragSeq The last drag sequence that was forwarded to the PTY. - */ -export function isDuplicateDrag(data: string, lastDragSeq: string): boolean { - return data.startsWith(DRAG_PREFIX) && data === lastDragSeq; -} diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 36789e8..99e8e07 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -13,3 +13,4 @@ webtty edits to these files are tracked in git from the commit that added them; ## Local changes - `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. +- `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, and motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`). diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 83d6f3f..0ac505f 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -172,6 +172,32 @@ export interface MouseTrackingConfig { getCanvasOffset: () => { left: number; top: number }; } +/** + * Button code for a mouse motion report, from the pressed-button bitmask + * (bit 0 left, bit 1 middle, bit 2 right). Motion adds 32 to the button, and + * no button is button 3, so hover is 35. (webtty: upstream sent 32 for hover, + * the same code as a left-button drag.) + */ +export function mouseMotionButton(pressed: number): number { + if (pressed & 1) return 32; // Left + if (pressed & 2) return 33; // Middle + if (pressed & 4) return 34; // Right + return 35; // No button +} + +/** + * Whether a motion event lands in a different cell from the last mouse report. + * Like native terminals, motion is reported once per cell, not per pixel. + * (webtty: upstream reported every mousemove, so a drag within one cell sent + * repeats that vim reads as a visual-mode toggle.) + */ +export function isNewMouseCell( + last: { col: number; row: number } | null, + cell: { col: number; row: number } +): boolean { + return !last || last.col !== cell.col || last.row !== cell.row; +} + export class InputHandler { private encoder: KeyEncoder; private container: HTMLElement; @@ -197,6 +223,7 @@ export class InputHandler { private isComposing = false; private isDisposed = false; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting + private lastMouseCell: { col: number; row: number } | null = null; // Cell of the last mouse report private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; private lastPasteData: string | null = null; @@ -796,6 +823,7 @@ export class InputHandler { event: MouseEvent ): void { const modifiers = this.getMouseModifiers(event); + this.lastMouseCell = { col, row }; // Check if SGR extended mode is enabled (mode 1006) const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true; @@ -870,19 +898,17 @@ export class InputHandler { if (!hasButtonMotion && !hasAnyMotion) return; + // No button held: a release outside the container never cleared the bitmask + if (event.buttons === 0) this.mouseButtonsPressed = 0; + // In button motion mode, only report if a button is pressed if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return; const cell = this.pixelToCell(event); if (!cell) return; + if (!isNewMouseCell(this.lastMouseCell, cell)) return; - // Determine which button to report (or 32 for motion with no button) - let button = 32; // Motion flag - if (this.mouseButtonsPressed & 1) - button += 0; // Left - else if (this.mouseButtonsPressed & 2) - button += 1; // Middle - else if (this.mouseButtonsPressed & 4) button += 2; // Right + const button = mouseMotionButton(this.mouseButtonsPressed); this.sendMouseEvent(button, cell.col, cell.row, false, event); } diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts new file mode 100644 index 0000000..3f1855b --- /dev/null +++ b/src/client/vendor/input-handler.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import { isNewMouseCell, mouseMotionButton } from './ghostty-web/input-handler'; + +describe('mouseMotionButton', () => { + test('encodes hover (no button held) as 35', () => { + expect(mouseMotionButton(0)).toBe(35); + }); + + test('encodes a left-button drag as 32', () => { + expect(mouseMotionButton(1)).toBe(32); + }); + + test('encodes a middle-button drag as 33', () => { + expect(mouseMotionButton(2)).toBe(33); + }); + + test('encodes a right-button drag as 34', () => { + expect(mouseMotionButton(4)).toBe(34); + }); + + test('reports the lowest button when several are held', () => { + expect(mouseMotionButton(1 | 4)).toBe(32); + expect(mouseMotionButton(2 | 4)).toBe(33); + }); +}); + +describe('isNewMouseCell', () => { + test('reports the first motion when nothing was reported yet', () => { + expect(isNewMouseCell(null, { col: 8, row: 4 })).toBe(true); + }); + + test('drops motion within the last reported cell', () => { + expect(isNewMouseCell({ col: 8, row: 4 }, { col: 8, row: 4 })).toBe(false); + }); + + test('reports motion into a new column', () => { + expect(isNewMouseCell({ col: 8, row: 4 }, { col: 9, row: 4 })).toBe(true); + }); + + test('reports motion into a new row', () => { + expect(isNewMouseCell({ col: 8, row: 4 }, { col: 8, row: 5 })).toBe(true); + }); +}); From 1a201339990c07074dffaecf6af3174cadfbaa63 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 22:41:39 -0400 Subject: [PATCH 005/295] fix(client): follow and round the device pixel ratio in the renderer The renderer read devicePixelRatio once and compared the integer canvas width against an unrounded cols x width x dpr, so fractional ratios resized every frame. renderer.ts now rounds the ratio up (wholeDevicePixelRatio), rounds backing-store sizes the same way, and follows changes itself (watchDevicePixelRatio); index.ts drops the private-field cast and syncDevicePixelRatio and only schedules a fit. --- .../039.client.device-pixel-ratio-change.md | 4 +- docs/specs/client.md | 2 +- src/client/index.ts | 41 ++------- src/client/smooth-scroll.ts | 3 +- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/renderer.ts | 67 ++++++++++++-- src/client/vendor/renderer.test.ts | 89 +++++++++++++++++++ 7 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 src/client/vendor/renderer.test.ts diff --git a/docs/adrs/039.client.device-pixel-ratio-change.md b/docs/adrs/039.client.device-pixel-ratio-change.md index 43c313b..cf2c577 100644 --- a/docs/adrs/039.client.device-pixel-ratio-change.md +++ b/docs/adrs/039.client.device-pixel-ratio-change.md @@ -25,12 +25,14 @@ Rounding up keeps `cols × cellWidth × dpr` an integer (cell sizes are whole CS - **Patch ghostty-web to compare against `Math.round(...)`** — stops the per-frame resize at the exact ratio, but needs a dependency patch in the build and leaves the seams. - **Round up the ratio (chosen)** — no patch, fixes both; costs a larger backing store at fractional ratios. +**Update (ADR 041):** the logic now lives in the vendored renderer (`src/client/vendor/ghostty-web/renderer.ts`): it rounds the ratio up (`wholeDevicePixelRatio`), rounds backing-store sizes the same way in `resize()` and `render()`, and follows changes itself (`watchDevicePixelRatio`); its next frame resizes and repaints the canvas. `index.ts` only schedules a fit on a change. The private-field cast is gone. + ## Consequences - Moving between displays of different scale keeps text crisp without a reload. - At a fractional ratio the canvas no longer resizes every frame (measured at 1.36: 60/s on `main`, 0 with this change). - At a fractional ratio the backing store is larger than strictly needed (e.g. 2× instead of 1.36×), and text is drawn at the higher ratio and scaled down by the browser rather than drawn at native resolution. -- Depends on a private field of ghostty-web's renderer; an upgrade that renames it would silently drop the fix (the cast compiles either way). Revisit once coder/ghostty-web#198 is fixed upstream. +- Depends on a private field of ghostty-web's renderer; an upgrade that renames it would silently drop the fix (the cast compiles either way). Revisit once coder/ghostty-web#198 is fixed upstream. (Resolved by ADR 041: the renderer owns the logic and the cast is gone.) ## Related Decisions diff --git a/docs/specs/client.md b/docs/specs/client.md index 7028983..22a71ab 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -183,7 +183,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | | IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ | -| Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, updates the renderer's pixel ratio and repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md) | ✅ | +| Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, is followed by the renderer, which repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | diff --git a/src/client/index.ts b/src/client/index.ts index f98b34a..367d56f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -7,6 +7,7 @@ import { encodePaste } from './paste'; import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; import { Ghostty, Terminal } from './vendor/ghostty-web'; +import { watchDevicePixelRatio } from './vendor/ghostty-web/renderer'; interface KeyboardBinding { key: string; @@ -181,42 +182,10 @@ document.addEventListener('visibilitychange', () => { } }); -// ghostty-web's CanvasRenderer reads window.devicePixelRatio once, in its constructor, -// and sizes the canvas backing store from that value forever after. Moving the window -// to a monitor with a different scale (Retina <-> external) or changing browser zoom -// leaves the canvas at the old ratio: blurry or mis-scaled until reload. The CSS size -// does not change, so neither ResizeObserver nor fit() notices. Watch the resolution -// media query instead and push the new ratio into the renderer; the forced render -// sees the backing-store mismatch and resizes and repaints itself. -// -// The ratio is rounded up: at a fractional ratio (zoom other than 100%, Windows 125%) -// ghostty-web compares the integer canvas width against an unrounded cols x width x dpr, -// so it resizes and clears the canvas on every frame, and cell edges land between -// device pixels and leave seams. Rendering at the next whole ratio and letting the -// browser scale the canvas down avoids both (coder/ghostty-web#198). See ADR 039. -function syncDevicePixelRatio(): void { - const renderer = term.renderer as unknown as { devicePixelRatio: number } | undefined; - if (!renderer) return; - renderer.devicePixelRatio = Math.ceil(window.devicePixelRatio || 1); - if (term.renderer && term.wasmTerm) { - term.renderer.render(term.wasmTerm, true, term.viewportY); - } -} - -function watchDevicePixelRatio(): void { - const dpr = window.devicePixelRatio || 1; - window.matchMedia(`(resolution: ${dpr}dppx)`).addEventListener( - 'change', - () => { - syncDevicePixelRatio(); - scheduleFit(); - watchDevicePixelRatio(); - }, - { once: true }, - ); -} -syncDevicePixelRatio(); -watchDevicePixelRatio(); +// The renderer follows devicePixelRatio changes itself (whole ratio, ADR 039). The +// edge strips also draw at the ratio, and the window may not resize on a move to a +// display of different scale, so fit again on a change. +watchDevicePixelRatio(window, scheduleFit); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index d607071..5841989 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -48,7 +48,6 @@ type Cell = NonNullable>[number]; interface RendererInternals { ctx: CanvasRenderingContext2D; - devicePixelRatio: number; renderLine(line: Cell[], row: number, cols: number): void; } @@ -144,7 +143,7 @@ export class SmoothScroll { this.strip.style.display = 'none'; return; } - const dpr = renderer.devicePixelRatio; + const dpr = this.term.renderer?.devicePixelRatio ?? 1; const width = canvas.offsetWidth; const w = Math.round(width * dpr); const h = Math.round(cellHeight * dpr); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 99e8e07..e18b738 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -14,3 +14,4 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. - `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, and motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`). +- `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 3b51bfd..44498a4 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -47,7 +47,44 @@ export interface RendererOptions { cursorStyle?: 'block' | 'underline' | 'bar'; // Default: 'block' cursorBlink?: boolean; // Default: false theme?: ITheme; - devicePixelRatio?: number; // Default: window.devicePixelRatio + devicePixelRatio?: number; // Default: window.devicePixelRatio, rounded up and followed +} + +/** + * The ratio the canvas is drawn at: the device pixel ratio rounded up to a whole + * number. A fractional ratio (browser zoom, Windows 125%/150%) would put cell edges + * between device pixels and make the backing-store size fractional; the browser + * scales the whole-ratio canvas down instead (coder/ghostty-web#198). Browsers + * report some whole ratios with float noise (2.0000000298023224), which must not + * round up to the next whole ratio. + */ +export function wholeDevicePixelRatio(ratio: number | undefined): number { + return Math.max(1, Math.ceil((ratio || 1) - 1e-3)); +} + +/** + * Calls onChange with the new window.devicePixelRatio whenever it changes (moving + * to a display with a different scale, browser zoom). A resolution media query + * only matches one ratio, so it is re-armed for each new ratio. Returns a function + * that stops watching. + */ +export function watchDevicePixelRatio( + win: Pick, + onChange: (ratio: number) => void +): () => void { + let stop = () => {}; + const arm = () => { + if (typeof win.matchMedia !== 'function') return; + const query = win.matchMedia(`(resolution: ${win.devicePixelRatio || 1}dppx)`); + const listener = () => { + onChange(win.devicePixelRatio || 1); + arm(); + }; + query.addEventListener('change', listener, { once: true }); + stop = () => query.removeEventListener('change', listener); + }; + arm(); + return () => stop(); } export interface FontMetrics { @@ -99,7 +136,8 @@ export class CanvasRenderer { private cursorStyle: 'block' | 'underline' | 'bar'; private cursorBlink: boolean; private theme: Required; - private devicePixelRatio: number; + private _devicePixelRatio: number; + private stopWatchingDevicePixelRatio?: () => void; private metrics: FontMetrics; private palette: string[]; @@ -152,7 +190,16 @@ export class CanvasRenderer { this.cursorStyle = options.cursorStyle ?? 'block'; this.cursorBlink = options.cursorBlink ?? false; this.theme = { ...DEFAULT_THEME, ...options.theme }; - this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1; + this._devicePixelRatio = wholeDevicePixelRatio( + options.devicePixelRatio ?? window.devicePixelRatio + ); + // Follow ratio changes; the next render() sees the backing-store mismatch and + // resizes and repaints the canvas. + if (options.devicePixelRatio === undefined) { + this.stopWatchingDevicePixelRatio = watchDevicePixelRatio(window, (ratio) => { + this._devicePixelRatio = wholeDevicePixelRatio(ratio); + }); + } // Build color palette (16 ANSI colors) this.palette = [ @@ -183,6 +230,11 @@ export class CanvasRenderer { } } + /** The whole ratio the canvas backing store is drawn at. */ + public get devicePixelRatio(): number { + return this._devicePixelRatio; + } + // ========================================================================== // Font Metrics Measurement // ========================================================================== @@ -242,8 +294,8 @@ export class CanvasRenderer { this.canvas.style.height = `${cssHeight}px`; // Set actual canvas size (scaled for DPI) - this.canvas.width = cssWidth * this.devicePixelRatio; - this.canvas.height = cssHeight * this.devicePixelRatio; + this.canvas.width = Math.round(cssWidth * this.devicePixelRatio); + this.canvas.height = Math.round(cssHeight * this.devicePixelRatio); // Scale context to match DPI (setting canvas.width/height resets the context) this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); @@ -287,8 +339,8 @@ export class CanvasRenderer { // Resize canvas if dimensions changed const needsResize = - this.canvas.width !== dims.cols * this.metrics.width * this.devicePixelRatio || - this.canvas.height !== dims.rows * this.metrics.height * this.devicePixelRatio; + this.canvas.width !== Math.round(dims.cols * this.metrics.width * this.devicePixelRatio) || + this.canvas.height !== Math.round(dims.rows * this.metrics.height * this.devicePixelRatio); if (needsResize) { this.resize(dims.cols, dims.rows); @@ -997,5 +1049,6 @@ export class CanvasRenderer { */ public dispose(): void { this.stopCursorBlink(); + this.stopWatchingDevicePixelRatio?.(); } } diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts new file mode 100644 index 0000000..8db8f90 --- /dev/null +++ b/src/client/vendor/renderer.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test'; +import { watchDevicePixelRatio, wholeDevicePixelRatio } from './ghostty-web/renderer'; + +describe('wholeDevicePixelRatio', () => { + test('keeps a whole ratio', () => { + expect(wholeDevicePixelRatio(1)).toBe(1); + expect(wholeDevicePixelRatio(2)).toBe(2); + }); + + test('rounds a fractional ratio up', () => { + expect(wholeDevicePixelRatio(1.25)).toBe(2); + expect(wholeDevicePixelRatio(1.36)).toBe(2); + expect(wholeDevicePixelRatio(2.5)).toBe(3); + }); + + test('ignores float noise on a whole ratio', () => { + expect(wholeDevicePixelRatio(2.0000000298023224)).toBe(2); + expect(wholeDevicePixelRatio(1.0000000149011612)).toBe(1); + }); + + test('never goes below 1', () => { + expect(wholeDevicePixelRatio(0.5)).toBe(1); + expect(wholeDevicePixelRatio(0)).toBe(1); + expect(wholeDevicePixelRatio(undefined)).toBe(1); + }); + + test('keeps the backing store a whole number of pixels', () => { + const dpr = wholeDevicePixelRatio(1.36); + expect(Number.isInteger(80 * 9 * dpr)).toBe(true); + }); +}); + +/** A window whose resolution queries can be fired by hand. */ +function fakeWindow(ratio: number) { + const queries: { media: string; listeners: Set<() => void> }[] = []; + const win = { + devicePixelRatio: ratio, + matchMedia(media: string) { + const query = { media, listeners: new Set<() => void>() }; + queries.push(query); + return { + addEventListener: (_: string, listener: () => void) => query.listeners.add(listener), + removeEventListener: (_: string, listener: () => void) => query.listeners.delete(listener), + } as unknown as MediaQueryList; + }, + }; + const change = (next: number) => { + const query = queries[queries.length - 1]; + win.devicePixelRatio = next; + for (const listener of [...query.listeners]) { + query.listeners.delete(listener); + listener(); + } + }; + return { win, queries, change }; +} + +describe('watchDevicePixelRatio', () => { + test('watches the current ratio', () => { + const { win, queries } = fakeWindow(2); + watchDevicePixelRatio(win, () => {}); + expect(queries.map((q) => q.media)).toEqual(['(resolution: 2dppx)']); + }); + + test('reports each change and re-arms for the new ratio', () => { + const { win, queries, change } = fakeWindow(2); + const seen: number[] = []; + watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); + change(1); + change(1.25); + expect(seen).toEqual([1, 1.25]); + expect(queries.map((q) => q.media)).toEqual([ + '(resolution: 2dppx)', + '(resolution: 1dppx)', + '(resolution: 1.25dppx)', + ]); + }); + + test('stops reporting once stopped', () => { + const { win, queries, change } = fakeWindow(2); + const seen: number[] = []; + const stop = watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); + change(1); + stop(); + expect(queries[queries.length - 1].listeners.size).toBe(0); + change(2); + expect(seen).toEqual([1]); + }); +}); From 246a6a40cc2515c140bd128fd99e684cebada539 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 22:54:37 -0400 Subject: [PATCH 006/295] perf(client): read the viewport once per frame ghostty-web's renderer called getLine(y) once per row, and each call copied and parsed the whole viewport. render() now reads it once via getViewport and serves rows (and the cursor cell) as slices through frameLines in the vendored renderer.ts. No webtty workaround existed to delete. Per ADR 041: 8.8 -> 0.27 ms per frame. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/renderer.ts | 42 +++++++++-- src/client/vendor/renderer.test.ts | 78 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index e18b738..785eb5c 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -15,3 +15,4 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. - `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, and motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`). - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. +- `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 44498a4..13a71cb 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -18,6 +18,11 @@ import { CellFlags } from './types'; // Interface for objects that can be rendered export interface IRenderable { getLine(y: number): GhosttyCell[] | null; + /** + * All viewport cells, row-major, read in one call. The array may be reused by + * the next call, so rows taken from it are only valid until then. + */ + getViewport?(): GhosttyCell[]; getCursor(): { x: number; y: number; visible: boolean }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; @@ -87,6 +92,25 @@ export function watchDevicePixelRatio( return () => stop(); } +/** + * Returns a row reader for one frame. When the buffer has getViewport, the + * viewport is read once, on the first row asked for, and every row is a slice of + * that snapshot; getLine copies and parses the whole viewport on each call. + */ +export function frameLines( + buffer: Pick, + cols: number, + rows: number +): (y: number) => GhosttyCell[] | null { + if (!buffer.getViewport) return (y) => buffer.getLine(y); + let viewport: GhosttyCell[] | null = null; + return (y) => { + if (y < 0 || y >= rows) return null; + viewport ??= buffer.getViewport!(); + return viewport.slice(y * cols, (y + 1) * cols); + }; +} + export interface FontMetrics { width: number; // Character cell width in CSS pixels height: number; // Character cell height in CSS pixels @@ -151,6 +175,8 @@ export class CanvasRenderer { // Current buffer being rendered (for grapheme lookups) private currentBuffer: IRenderable | null = null; + // Row reader for the current render pass (one viewport read per frame) + private currentLine: (y: number) => GhosttyCell[] | null = () => null; // Selection manager (for rendering selection) private selectionManager?: SelectionManager; @@ -330,6 +356,8 @@ export class CanvasRenderer { // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); const dims = buffer.getDimensions(); + const getLine = frameLines(buffer, dims.cols, dims.rows); + this.currentLine = getLine; const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; // Check if buffer needs full redraw (e.g., screen change between normal/alternate) @@ -360,7 +388,7 @@ export class CanvasRenderer { // Mark cursor lines as needing redraw if (!forceAll && !buffer.isRowDirty(cursor.y)) { // Need to redraw cursor line - const line = buffer.getLine(cursor.y); + const line = getLine(cursor.y); if (line) { this.renderLine(line, cursor.y, dims.cols); } @@ -368,7 +396,7 @@ export class CanvasRenderer { if (cursorMoved && this.lastCursorPosition.y !== cursor.y) { // Also redraw old cursor line if cursor moved to different line if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) { - const line = buffer.getLine(this.lastCursorPosition.y); + const line = getLine(this.lastCursorPosition.y); if (line) { this.renderLine(line, this.lastCursorPosition.y, dims.cols); } @@ -426,11 +454,11 @@ export class CanvasRenderer { } else { // This row is from visible screen const screenRow = y - Math.floor(viewportY); - line = buffer.getLine(screenRow); + line = getLine(screenRow); } } else { // At bottom - fetch from visible screen - line = buffer.getLine(y); + line = getLine(y); } if (line) { @@ -518,11 +546,11 @@ export class CanvasRenderer { } else { // This row is from visible screen (lower part of viewport) const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y; - line = buffer.getLine(screenRow); + line = getLine(screenRow); } } else { // At bottom - fetch from visible screen - line = buffer.getLine(y); + line = getLine(y); } if (line) { @@ -780,7 +808,7 @@ export class CanvasRenderer { this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); // Re-draw character under cursor with cursorAccent color { - const line = this.currentBuffer?.getLine(y); + const line = this.currentLine(y); if (line?.[x]) { this.ctx.save(); this.ctx.beginPath(); diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 8db8f90..1e0be4e 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { watchDevicePixelRatio, wholeDevicePixelRatio } from './ghostty-web/renderer'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty } from './ghostty-web/ghostty'; +import { frameLines, watchDevicePixelRatio, wholeDevicePixelRatio } from './ghostty-web/renderer'; +import type { GhosttyCell } from './ghostty-web/types'; + +const WASM = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-web/ghostty-vt.wasm'); describe('wholeDevicePixelRatio', () => { test('keeps a whole ratio', () => { @@ -87,3 +93,73 @@ describe('watchDevicePixelRatio', () => { expect(seen).toEqual([1]); }); }); + +/** A cell whose codepoint records its row and column. */ +function cellAt(y: number, x: number): GhosttyCell { + return { + codepoint: y * 100 + x, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; +} + +describe('frameLines', () => { + const cols = 3; + const rows = 2; + const viewport = [0, 1].flatMap((y) => [0, 1, 2].map((x) => cellAt(y, x))); + + test('reads the viewport once per frame and slices rows from it', () => { + let reads = 0; + let lineReads = 0; + const buffer = { + getViewport: () => { + reads++; + return viewport; + }, + getLine: () => { + lineReads++; + return null; + }, + }; + const getLine = frameLines(buffer, cols, rows); + expect(reads).toBe(0); + expect(getLine(1)?.map((c) => c.codepoint)).toEqual([100, 101, 102]); + expect(getLine(0)?.map((c) => c.codepoint)).toEqual([0, 1, 2]); + expect(getLine(1)?.map((c) => c.codepoint)).toEqual([100, 101, 102]); + expect(reads).toBe(1); + expect(lineReads).toBe(0); + frameLines(buffer, cols, rows)(0); + expect(reads).toBe(2); + }); + + test('returns null outside the viewport', () => { + const getLine = frameLines({ getViewport: () => viewport, getLine: () => null }, cols, rows); + expect(getLine(-1)).toBeNull(); + expect(getLine(rows)).toBeNull(); + }); + + test('falls back to getLine without getViewport', () => { + const getLine = frameLines({ getLine: (y) => [cellAt(y, 0)] }, cols, rows); + expect(getLine(1)?.[0].codepoint).toBe(100); + }); + + test('serves the same rows as getLine from a real terminal', async () => { + const ghostty = await Ghostty.load(WASM); + const term = ghostty.createTerminal(20, 5); + term.write('\x1b[31mred\x1b[0m \x1b[1;44mbold\x1b[0m\r\n中文 ✓\r\n\x1b[7mrev\x1b[0m'); + term.update(); + const getLine = frameLines(term, 20, 5); + for (let y = 0; y < 5; y++) { + expect(getLine(y)).toEqual(term.getLine(y)); + } + term.free(); + }); +}); From bf3f29cdede0e360a8444dfdf8ea15d589595a7d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 23:04:01 -0400 Subject: [PATCH 007/295] fix(client): apply DECSCUSR cursor style inside the terminal ghostty-web ignored DECSCUSR, so webtty scanned WebSocket output in src/client/cursor.ts and forced a full repaint to clear the old shape. terminal.ts write() now applies it (scanDecscusr, also across split writes) over the configured cursorStyle/cursorBlink, Ps 0 restores the default, and renderer.ts repaints only the cursor row on the next frame. cursor.ts deleted. --- docs/adrs/013.client.cursor-style.md | 2 + docs/adrs/015.client.cursor-ghost-repaint.md | 2 + docs/specs/client.md | 2 +- src/client/cursor.test.ts | 150 ------------------- src/client/cursor.ts | 60 -------- src/client/index.ts | 5 +- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/renderer.ts | 11 +- src/client/vendor/ghostty-web/terminal.ts | 79 +++++++++- src/client/vendor/terminal.test.ts | 83 +++++++++- 10 files changed, 173 insertions(+), 222 deletions(-) delete mode 100644 src/client/cursor.test.ts delete mode 100644 src/client/cursor.ts diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 7ada93d..315d651 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -34,6 +34,8 @@ Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `t The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and terminal wiring in `index.ts`. It is removed when ghostty-web implements DECSCUSR natively. +**Update (ADR 041):** DECSCUSR is now applied inside the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `write()` scans the output (`scanDecscusr`, which also carries a sequence split across writes) and keeps the app's shape apart from `options.cursorStyle` / `options.cursorBlink`, which stay the configured default. Ps 0 now clears the app's shape, so the configured cursor shows again (it was blinking block). `cursor.ts` and its call in `index.ts` are deleted. The Coder wasm does not export the cursor style, so the output is still scanned; reading it from the render state is ADR 041 step 5. + ## Considered Options **Option A: Fix ghostty-web upstream (long-term)** diff --git a/docs/adrs/015.client.cursor-ghost-repaint.md b/docs/adrs/015.client.cursor-ghost-repaint.md index 737c669..e97a8e6 100644 --- a/docs/adrs/015.client.cursor-ghost-repaint.md +++ b/docs/adrs/015.client.cursor-ghost-repaint.md @@ -71,6 +71,8 @@ The force repaint is guarded by a `changed` flag and only fires when style or blink actually differs from the current value — i.e., on vim mode switches, which are infrequent. There is no per-message overhead. +**Update (ADR 041):** the repaint now lives in the vendored renderer (`src/client/vendor/ghostty-web/renderer.ts`): `setCursorStyle()` / `setCursorBlink()` flag a shape change, and the next frame redraws the cursor row as it does when the cursor moves, instead of a synchronous full repaint from `cursor.ts`. It also covers shape changes made through `term.options`. + --- ## Considered Options diff --git a/docs/specs/client.md b/docs/specs/client.md index 22a71ab..3e97c20 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -179,7 +179,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Multi-client | Multiple tabs can attach to the same session; scrollback replayed on reconnect; tab closes when PTY exits | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | -| Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime via client-side intercept | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | +| Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime inside the terminal (Ps 0 restores the default), and the cursor cell is repainted so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | | IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ | diff --git a/src/client/cursor.test.ts b/src/client/cursor.test.ts deleted file mode 100644 index 6117f5b..0000000 --- a/src/client/cursor.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, mock, test } from 'bun:test'; -import { applyDecscusr } from './cursor'; - -function makeTerm(): { options: { cursorStyle: string; cursorBlink: boolean } } { - return { options: { cursorStyle: 'block', cursorBlink: false } }; -} - -function makeTermWithRenderer(): { - options: { cursorStyle: string; cursorBlink: boolean }; - renderer: { render: ReturnType }; - wasmTerm: object; - viewportY: number; -} { - return { - options: { cursorStyle: 'block', cursorBlink: false }, - renderer: { render: mock(() => {}) }, - wasmTerm: {}, - viewportY: 0, - }; -} - -describe('applyDecscusr', () => { - test('Ps 0 — default reset — sets block blinking', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[0 q'); - expect(term.options.cursorStyle).toBe('block'); - expect(term.options.cursorBlink).toBe(true); - }); - - test('Ps 1 — blinking block', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[1 q'); - expect(term.options.cursorStyle).toBe('block'); - expect(term.options.cursorBlink).toBe(true); - }); - - test('Ps 2 — steady block', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[2 q'); - expect(term.options.cursorStyle).toBe('block'); - expect(term.options.cursorBlink).toBe(false); - }); - - test('Ps 3 — blinking underline', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[3 q'); - expect(term.options.cursorStyle).toBe('underline'); - expect(term.options.cursorBlink).toBe(true); - }); - - test('Ps 4 — steady underline', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[4 q'); - expect(term.options.cursorStyle).toBe('underline'); - expect(term.options.cursorBlink).toBe(false); - }); - - test('Ps 5 — blinking bar', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[5 q'); - expect(term.options.cursorStyle).toBe('bar'); - expect(term.options.cursorBlink).toBe(true); - }); - - test('Ps 6 — steady bar', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[6 q'); - expect(term.options.cursorStyle).toBe('bar'); - expect(term.options.cursorBlink).toBe(false); - }); - - test('empty Ps defaults to 0 (blinking block)', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[ q'); - expect(term.options.cursorStyle).toBe('block'); - expect(term.options.cursorBlink).toBe(true); - }); - - test('out-of-range Ps is ignored', () => { - const term = makeTerm(); - term.options.cursorStyle = 'bar'; - term.options.cursorBlink = false; - applyDecscusr(term as never, '\x1b[7 q'); - expect(term.options.cursorStyle).toBe('bar'); - expect(term.options.cursorBlink).toBe(false); - }); - - test('no DECSCUSR sequence leaves options unchanged', () => { - const term = makeTerm(); - applyDecscusr(term as never, 'hello world'); - expect(term.options.cursorStyle).toBe('block'); - expect(term.options.cursorBlink).toBe(false); - }); - - test('multiple sequences in one chunk — last one wins', () => { - const term = makeTerm(); - applyDecscusr(term as never, '\x1b[2 q some output \x1b[5 q'); - expect(term.options.cursorStyle).toBe('bar'); - expect(term.options.cursorBlink).toBe(true); - }); - - test('sequence embedded in regular output', () => { - const term = makeTerm(); - applyDecscusr(term as never, 'text before\x1b[6 qtext after'); - expect(term.options.cursorStyle).toBe('bar'); - expect(term.options.cursorBlink).toBe(false); - }); -}); - -describe('applyDecscusr — force repaint', () => { - test('calls renderer.render with forceAll=true when style changes', () => { - const term = makeTermWithRenderer(); - applyDecscusr(term as never, '\x1b[6 q'); - expect(term.renderer.render).toHaveBeenCalledTimes(1); - expect(term.renderer.render).toHaveBeenCalledWith(term.wasmTerm, true, 0); - }); - - test('calls renderer.render when only blink changes', () => { - const term = makeTermWithRenderer(); - term.options.cursorBlink = true; - applyDecscusr(term as never, '\x1b[2 q'); - expect(term.renderer.render).toHaveBeenCalledTimes(1); - }); - - test('does not call renderer.render when style is unchanged', () => { - const term = makeTermWithRenderer(); - term.options.cursorStyle = 'block'; - term.options.cursorBlink = false; - applyDecscusr(term as never, '\x1b[2 q'); - expect(term.renderer.render).not.toHaveBeenCalled(); - }); - - test('does not call renderer.render when no DECSCUSR sequence', () => { - const term = makeTermWithRenderer(); - applyDecscusr(term as never, 'hello world'); - expect(term.renderer.render).not.toHaveBeenCalled(); - }); - - test('calls renderer.render once even with multiple changing sequences', () => { - const term = makeTermWithRenderer(); - applyDecscusr(term as never, '\x1b[2 q\x1b[6 q'); - expect(term.renderer.render).toHaveBeenCalledTimes(1); - }); - - test('does not call renderer.render when sequences cancel out to original values', () => { - const term = makeTermWithRenderer(); - applyDecscusr(term as never, '\x1b[6 q\x1b[2 q'); - expect(term.renderer.render).not.toHaveBeenCalled(); - }); -}); diff --git a/src/client/cursor.ts b/src/client/cursor.ts deleted file mode 100644 index da04736..0000000 --- a/src/client/cursor.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Terminal } from './vendor/ghostty-web'; - -// ghostty-web does not yet read cursor style from the WASM render state after -// write() — getCursor() hardcodes style: 'block' (see TODO in ghostty-web source). -// As a workaround, we intercept DECSCUSR sequences (CSI Ps SP q) from PTY output -// and apply them directly via the options proxy, which forwards to the renderer. -// -// DECSCUSR codes (ECMA-48 / DEC): -// 0, 1 — blinking block (0 = default) -// 2 — steady block -// 3 — blinking underline -// 4 — steady underline -// 5 — blinking bar -// 6 — steady bar -// -// config.cursorStyle sets the initial shape at startup; PTY sequences override -// it at runtime. The two compose cleanly: config is your default, apps (vim, -// fish normal mode, etc.) switch dynamically as needed. - -const ESC = '\x1b'; -const DECSCUSR = new RegExp(`${ESC}\\[(\\d*) q`, 'g'); - -/** - * Scans `data` for DECSCUSR sequences (`CSI Ps SP q`) and applies matching cursor - * style/blink changes directly to `term.options`, then forces a full repaint so the - * previous cursor shape is cleared before the new one is drawn. - * - * ghostty-web does not yet propagate cursor style from PTY output — this is a - * client-side workaround until the WASM layer handles it natively. - */ -export function applyDecscusr(term: Terminal, data: string): void { - const initialStyle = term.options.cursorStyle; - const initialBlink = term.options.cursorBlink; - DECSCUSR.lastIndex = 0; - let match = DECSCUSR.exec(data); - while (match !== null) { - const ps = match[1] === '' ? 0 : Number(match[1]); - if (!Number.isNaN(ps) && ps >= 0 && ps <= 6) { - term.options.cursorStyle = - ps === 0 || ps === 1 || ps === 2 ? 'block' : ps === 3 || ps === 4 ? 'underline' : 'bar'; - term.options.cursorBlink = ps === 0 || ps === 1 || ps === 3 || ps === 5; - } - match = DECSCUSR.exec(data); - } - // ghostty-web's render loop only clears the cursor row when the cursor moves or - // cursorBlink is true. When switching to a non-blinking style (e.g. block→bar in - // vim normal→insert), the old cursor shape stays painted on canvas and the new - // shape is drawn on top — leaving a ghost of the previous cursor. - // - // Force a full repaint so the cursor row is cleared before the new shape is drawn. - // term.renderer and term.wasmTerm are public on Terminal; forceAll=true repaints - // every row, which clears the stale cursor, and renderCursor() draws the new shape. - if ( - (term.options.cursorStyle !== initialStyle || term.options.cursorBlink !== initialBlink) && - term.renderer && - term.wasmTerm - ) { - term.renderer.render(term.wasmTerm, true, term.viewportY); - } -} diff --git a/src/client/index.ts b/src/client/index.ts index 367d56f..ad340c7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,4 @@ import { Composer } from './compose'; -import { applyDecscusr } from './cursor'; import { EdgeExtender } from './edge'; import { computeGrid, computeInsets } from './fit'; import { KittyGraphics } from './graphics'; @@ -173,8 +172,7 @@ document.fonts.ready.then(() => { // memory; Firefox does not. ghostty-web's render loop only repaints // WASM-dirty rows, so a cleared canvas can stay blank/partial after switching // back to the tab until content happens to change. Force a full repaint on -// visibility restore. See ADR 031 and ADR 015 for the same forced-repaint -// pattern used for the DECSCUSR ghost-cursor fix. +// visibility restore. See ADR 031. document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; if (term.renderer && term.wasmTerm) { @@ -213,7 +211,6 @@ function connect(): void { }; ws.onmessage = (event: MessageEvent) => { - applyDecscusr(term, event.data); graphics.feed(event.data); smoothScroll.onOutput(); }; diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 785eb5c..71d2627 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -16,3 +16,4 @@ webtty edits to these files are tracked in git from the commit that added them; - `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, and motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`). - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. +- `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 13a71cb..2d893fb 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -169,6 +169,9 @@ export class CanvasRenderer { private cursorVisible: boolean = true; private cursorBlinkInterval?: number; private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 }; + // Cursor style or blink changed since the last frame: repaint the cursor cell so + // the old shape does not stay under the new one (webtty) + private cursorShapeChanged: boolean = false; // Viewport tracking (for scrolling) private lastViewportY: number = 0; @@ -381,10 +384,12 @@ export class CanvasRenderer { this.lastViewportY = viewportY; } - // Check if cursor position changed or if blinking (need to redraw cursor line) + // Check if cursor position changed, blinking or changed shape (need to redraw cursor line) const cursorMoved = cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y; - if (cursorMoved || this.cursorBlink) { + const cursorShapeChanged = this.cursorShapeChanged && viewportY === 0; + this.cursorShapeChanged = false; + if (cursorMoved || this.cursorBlink || cursorShapeChanged) { // Mark cursor lines as needing redraw if (!forceAll && !buffer.isRowDirty(cursor.y)) { // Need to redraw cursor line @@ -910,6 +915,7 @@ export class CanvasRenderer { * Update cursor style */ public setCursorStyle(style: 'block' | 'underline' | 'bar'): void { + if (style !== this.cursorStyle) this.cursorShapeChanged = true; this.cursorStyle = style; } @@ -917,6 +923,7 @@ export class CanvasRenderer { * Enable/disable cursor blinking */ public setCursorBlink(enabled: boolean): void { + if (enabled !== this.cursorBlink) this.cursorShapeChanged = true; if (enabled && !this.cursorBlink) { this.cursorBlink = true; this.startCursorBlink(); diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index dbe6466..698b800 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -51,6 +51,48 @@ export function mouseCursorFor(link: ILink | undefined): 'pointer' | 'default' { return link ? 'pointer' : 'default'; } +/** Cursor shape set by an app through DECSCUSR. */ +export interface CursorShape { + style: 'block' | 'underline' | 'bar'; + blink: boolean; +} + +// DECSCUSR is CSI Ps SP q. A sequence split across writes leaves a prefix at the +// end of a chunk, which is carried into the next scan. +const DECSCUSR = /\x1b\[(\d*) q/g; +const DECSCUSR_PREFIX = /\x1b(?:\[\d{0,3} ?)?$/; + +/** + * Scans output for DECSCUSR and returns the last Ps from 0 to 6 (undefined if + * none), plus the unfinished sequence at the end of the chunk to pass back in as + * `carry` with the next one. (webtty: the Coder wasm applies DECSCUSR but does not + * export the cursor style, so it is read from the output here.) + */ +export function scanDecscusr( + data: string, + carry: string = '' +): { ps: number | undefined; carry: string } { + const text = carry + data; + let ps: number | undefined; + for (const match of text.matchAll(DECSCUSR)) { + const value = match[1] === '' ? 0 : Number(match[1]); + if (value <= 6) ps = value; + } + return { ps, carry: DECSCUSR_PREFIX.exec(text)?.[0] ?? '' }; +} + +/** + * Applies DECSCUSR Ps to the app's cursor shape: 1-6 set it, 0 clears it so the + * configured `cursorStyle` / `cursorBlink` show again. + */ +export function decscusrShape(ps: number): CursorShape | undefined { + if (ps === 0) return undefined; + return { + style: ps <= 2 ? 'block' : ps <= 4 ? 'underline' : 'bar', + blink: ps % 2 === 1, + }; +} + export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; @@ -130,6 +172,11 @@ export class Terminal implements ITerminalCore { private scrollAnimationFrame?: number; private customWheelEventHandler?: (event: WheelEvent) => boolean; private lastCursorY: number = 0; // Track cursor position for onCursorMove + // DECSCUSR state: the app's cursor shape (undefined = options) and the + // unfinished sequence at the end of the last write + private appCursorShape?: CursorShape; + private decscusrCarry: string = ''; + private readonly decoder = new TextDecoder(); // Scrollbar interaction state private isDraggingScrollbar: boolean = false; @@ -204,10 +251,7 @@ export class Terminal implements ITerminalCore { case 'cursorBlink': case 'cursorStyle': - if (this.renderer) { - this.renderer.setCursorStyle(this.options.cursorStyle); - this.renderer.setCursorBlink(this.options.cursorBlink); - } + this.applyCursorShape(); break; case 'theme': @@ -583,6 +627,17 @@ export class Terminal implements ITerminalCore { this.bellEmitter.fire(); } + // DECSCUSR: the app's cursor shape (webtty) + const decscusr = scanDecscusr( + typeof data === 'string' ? data : this.decoder.decode(data, { stream: true }), + this.decscusrCarry + ); + this.decscusrCarry = decscusr.carry; + if (decscusr.ps !== undefined) { + this.appCursorShape = decscusrShape(decscusr.ps); + this.applyCursorShape(); + } + // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); @@ -751,6 +806,22 @@ export class Terminal implements ITerminalCore { // Reset title this.currentTitle = ''; + + // Back to the configured cursor shape (webtty) + this.appCursorShape = undefined; + this.decscusrCarry = ''; + this.applyCursorShape(); + } + + /** + * Shows the app's DECSCUSR cursor shape, or the configured one when the app has + * not set one. The renderer repaints the cursor cell when the shape changes. + * (webtty) + */ + private applyCursorShape(): void { + if (!this.renderer) return; + this.renderer.setCursorStyle(this.appCursorShape?.style ?? this.options.cursorStyle); + this.renderer.setCursorBlink(this.appCursorShape?.blink ?? this.options.cursorBlink); } /** diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 3cb8860..b1040a6 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { mouseCursorFor } from './ghostty-web/terminal'; +import { decscusrShape, mouseCursorFor, scanDecscusr } from './ghostty-web/terminal'; import type { ILink } from './ghostty-web/types'; describe('mouseCursorFor', () => { @@ -17,3 +17,84 @@ describe('mouseCursorFor', () => { expect(mouseCursorFor(link)).toBe('pointer'); }); }); + +describe('decscusrShape', () => { + test('Ps 0 clears the app shape so the configured one shows', () => { + expect(decscusrShape(0)).toBeUndefined(); + }); + + test('Ps 1 — blinking block', () => { + expect(decscusrShape(1)).toEqual({ style: 'block', blink: true }); + }); + + test('Ps 2 — steady block', () => { + expect(decscusrShape(2)).toEqual({ style: 'block', blink: false }); + }); + + test('Ps 3 — blinking underline', () => { + expect(decscusrShape(3)).toEqual({ style: 'underline', blink: true }); + }); + + test('Ps 4 — steady underline', () => { + expect(decscusrShape(4)).toEqual({ style: 'underline', blink: false }); + }); + + test('Ps 5 — blinking bar', () => { + expect(decscusrShape(5)).toEqual({ style: 'bar', blink: true }); + }); + + test('Ps 6 — steady bar', () => { + expect(decscusrShape(6)).toEqual({ style: 'bar', blink: false }); + }); +}); + +describe('scanDecscusr', () => { + test('reads Ps', () => { + expect(scanDecscusr('\x1b[6 q')).toEqual({ ps: 6, carry: '' }); + }); + + test('empty Ps is 0', () => { + expect(scanDecscusr('\x1b[ q').ps).toBe(0); + }); + + test('no sequence yields no Ps', () => { + expect(scanDecscusr('hello world')).toEqual({ ps: undefined, carry: '' }); + }); + + test('out-of-range Ps is ignored', () => { + expect(scanDecscusr('\x1b[7 q').ps).toBeUndefined(); + expect(scanDecscusr('\x1b[5 q\x1b[7 q').ps).toBe(5); + }); + + test('last sequence in a chunk wins', () => { + expect(scanDecscusr('\x1b[2 q some output \x1b[5 q').ps).toBe(5); + }); + + test('finds a sequence embedded in output', () => { + expect(scanDecscusr('text before\x1b[6 qtext after').ps).toBe(6); + }); + + test('ignores other CSI sequences ending in q', () => { + expect(scanDecscusr('\x1b[>q\x1b[1q\x1b[0"q').ps).toBeUndefined(); + }); + + test('carries a sequence split across chunks', () => { + for (const [head, tail] of [ + ['ab\x1b', '[6 q'], + ['ab\x1b[', '6 q'], + ['ab\x1b[6', ' q'], + ['ab\x1b[6 ', 'q'], + ]) { + const first = scanDecscusr(head); + expect(first.ps).toBeUndefined(); + expect(first.carry).toBe(head.slice(2)); + expect(scanDecscusr(tail, first.carry)).toEqual({ ps: 6, carry: '' }); + } + }); + + test('carries nothing after a finished sequence or plain text', () => { + expect(scanDecscusr('\x1b[6 q').carry).toBe(''); + expect(scanDecscusr('\x1b[6m').carry).toBe(''); + expect(scanDecscusr('\x1b[6 x').carry).toBe(''); + }); +}); From 8b1de80904b30160c72436daea5e7dbbf1cba283 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 23:13:13 -0400 Subject: [PATCH 008/295] fix(client): encode wheel scroll for mouse-tracking apps at source ghostty-web's Terminal.handleWheel sent arrow keys (alt screen) or scrolled the viewport even when the app had enabled mouse tracking. handleWheel now sends SGR scroll buttons 64/65 at the pointer cell (wheelTicks, wheelSgr) with a new mouseScrollSpeed option, so the custom SGR wheel handler in index.ts is deleted; it now only routes non-tracking wheels to pixel-smooth scrollback (ADR 040). --- docs/adrs/017.client.mouse-scroll.md | 2 + docs/adrs/040.client.pixel-smooth-scroll.md | 2 + docs/specs/client.md | 6 +- src/client/index.ts | 52 ++--------------- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/interfaces.ts | 1 + src/client/vendor/ghostty-web/terminal.ts | 61 ++++++++++++++++++++ src/client/vendor/terminal.test.ts | 62 ++++++++++++++++++++- 8 files changed, 137 insertions(+), 50 deletions(-) diff --git a/docs/adrs/017.client.mouse-scroll.md b/docs/adrs/017.client.mouse-scroll.md index 3dd0938..6c110f9 100644 --- a/docs/adrs/017.client.mouse-scroll.md +++ b/docs/adrs/017.client.mouse-scroll.md @@ -89,6 +89,8 @@ term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { }); ``` +**Update (ADR 041):** the intercept now lives in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `Terminal.handleWheel` checks mouse tracking on either screen before its arrow-key and viewport paths and fires the SGR reports through `onData` (`wheelTicks`, `wheelSgr`); `mouseScrollSpeed` is a `Terminal` option. The custom wheel handler in `index.ts` now only does pixel-smooth scrollback (ADR 040) when the app does not track the mouse. The cell is measured from the canvas rather than the event target. + When the custom handler returns `true`, `Terminal.handleWheel` returns early and skips the arrow-key loop. When it returns `false` (mouse tracking off), the default behaviour is preserved — arrow keys are still sent for apps that diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index 9655643..fb04f6e 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -21,6 +21,8 @@ Keep the scroll position as fractional lines in the client (`src/client/smooth-s The alternate screen and mouse-tracking apps scroll their own view in whole lines, so no pixel offset is possible there. For mouse-tracking apps (herdr, Claude Code's fullscreen TUI, vim `mouse=a`), count pixel wheel deltas by distance instead: one SGR scroll tick per cell height scrolled, 1:1 with the OS like native macOS scrolling. `mouseScrollSpeed` (ADR 017) does not apply to pixel deltas, as Ghostty skips its scroll multiplier for precision scrolls; at 1.5 it made apps scroll visibly faster than native. Previously every wheel event was one tick, so a trackpad's many small events and momentum scrolled far ahead of the fingers. Line and page deltas still count one tick per event. +**Update (ADR 041):** the mouse-tracking tick counting now lives in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`, `wheelTicks`), with ADR 017's SGR reports; `index.ts` keeps only the pixel-smooth scrollback handler. + ## Consequences - Trackpad scrolling through scrollback follows the fingers pixel by pixel, with macOS momentum, and no easing lag. diff --git a/docs/specs/client.md b/docs/specs/client.md index 3e97c20..fc843bd 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -59,7 +59,7 @@ src/client/vendor/ 1. Reads `sessionId` from `window.location.pathname` (`/s/main` → `main`) 2. Fetches `GET /api/config` to get terminal config 3. Sets `document.title = sessionId + ' | webtty'` -4. Loads `/ghostty-vt.wasm` and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior) +4. Loads `/ghostty-vt.wasm` and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) 5. Connects to `ws:///ws/:id?cols=&rows=` over WebSocket 6. Fits the terminal to the viewport from the full container size (no scrollbar reserve, see ADR 033) and observes resize events 7. Sends a `{ type: 'resize', cols, rows }` JSON message on open and on every terminal resize @@ -73,7 +73,7 @@ src/client/vendor/ { cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme, copyOnSelect, rightClickBehavior, - mouseScrollSpeed, // used by the custom wheel handler, not passed to Terminal constructor + mouseScrollSpeed, // Terminal option: SGR wheel ticks per line/page event when the app tracks the mouse padding, // px around the canvas, used by fit(); coloured by theme.padding or theme.background keyboardBindings // used by the keydown capture handler, not passed to Terminal constructor } @@ -190,7 +190,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Official libghostty-vt wasm | The terminal runs on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked) with current Ghostty parsing and query replies; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | -| Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | +| Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | | Font-size zoom | `Ctrl/Cmd` + `=`/`-`/`0` adjust terminal font size in-session; not configurable; same shortcuts as VS Code | [ADR 023](../adrs/023.client.font-size-zoom.md) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index ad340c7..e76c811 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -79,6 +79,7 @@ const term = new Terminal({ fontSize: config.fontSize, fontFamily: config.fontFamily, scrollback: Math.ceil(config.scrollback / 80), + mouseScrollSpeed: config.mouseScrollSpeed, theme: terminalTheme, }); @@ -237,52 +238,11 @@ function connect(): void { connect(); -// ghostty-web's Terminal.handleWheel sends \x1b[A/\x1b[B (arrow keys) on the -// alternate screen regardless of mouse tracking state, moving the cursor instead -// of scrolling. When the PTY application has enabled mouse tracking (e.g. vim -// with `set mouse=a`), intercept wheel events and send the correct SGR mouse -// scroll sequence so the app receives a scroll event, not a cursor move. -// SGR button 64 = scroll up, 65 = scroll down. See ADR 017. -// -// config.mouseScrollSpeed scales SGR events per wheel tick (default 1). -// Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. -// The accumulator resets on direction change to prevent cross-direction bleed. -// -// Pixel deltas (trackpads, and mouse wheels in Chromium/Safari on macOS) count by -// distance: one tick per cell height scrolled, 1:1 with the OS like native macOS -// scrolling, and mouseScrollSpeed does not apply (Ghostty likewise skips its scroll -// multiplier for precision scrolls). A trackpad fires many small events per swipe, -// plus momentum, and counting each as a whole tick made apps scroll far ahead of the -// fingers. Line and page deltas still count one tick per event, times -// mouseScrollSpeed. See ADR 040. -let scrollAccum = 0; -let scrollDir = 0; -term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { - if (!term.hasMouseTracking()) return smoothScroll.handleWheel(e); - const metrics = term.renderer?.getMetrics(); - if (!metrics) return false; - const dir = e.deltaY < 0 ? -1 : 1; - if (dir !== scrollDir) { - scrollAccum = 0; - scrollDir = dir; - } - scrollAccum += - e.deltaMode === WheelEvent.DOM_DELTA_PIXEL - ? Math.abs(e.deltaY) / metrics.height - : config.mouseScrollSpeed; - const ticks = Math.trunc(scrollAccum); - if (ticks === 0) return true; - scrollAccum -= ticks; - const rect = (e.target as HTMLElement).getBoundingClientRect(); - const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); - const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); - const btn = dir < 0 ? 64 : 65; - const seq = `\x1b[<${btn};${col};${row}M`; - if (ws && ws.readyState === WebSocket.OPEN) { - for (let i = 0; i < ticks; i++) ws.send(seq); - } - return true; -}); +// Pixel-smooth scrollback for the normal screen. When the app tracks the mouse, the +// terminal reports wheel ticks to it as SGR scroll buttons (ADR 017, ADR 040). +term.attachCustomWheelEventHandler( + (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), +); // Shared by the configured-binding listener below and the Ctrl+V paste // listener further down, so the latter can tell whether a user has already diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 71d2627..75d8203 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -17,3 +17,4 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. +- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`). diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 4c6fbe4..1d9e53b 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -21,6 +21,7 @@ export interface ITerminalOptions { // Scrolling options smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant) + mouseScrollSpeed?: number; // Wheel ticks per line/page wheel event when the app tracks the mouse (default: 1) // Internal: Ghostty WASM instance (optional, for test isolation) // If not provided, uses the module-level instance from init() diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 698b800..7647016 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -93,6 +93,46 @@ export function decscusrShape(ps: number): CursorShape | undefined { }; } +/** Wheel travel not yet sent to a mouse-tracking app, and its direction. */ +export interface WheelAccumulator { + accum: number; + dir: number; +} + +/** + * Scroll ticks a wheel event sends to an app that tracks the mouse: negative + * scrolls up (SGR button 64), positive down (65). Pixel deltas (trackpads, and + * mouse wheels in Chromium/Safari on macOS) count by distance, one tick per cell + * height, 1:1 with the OS, and `speed` does not apply (Ghostty skips its scroll + * multiplier for precision scrolls too). Line and page deltas count one tick per + * event times `speed`; below 1 the remainder accumulates. A direction change + * drops the remainder. (webtty: upstream sent arrow keys on the alternate screen + * and one SGR per event elsewhere, so trackpads scrolled far ahead of the fingers.) + */ +export function wheelTicks( + acc: WheelAccumulator, + e: Pick, + cellHeight: number, + speed: number +): number { + const dir = e.deltaY < 0 ? -1 : 1; + if (dir !== acc.dir) { + acc.accum = 0; + acc.dir = dir; + } + // 0 is WheelEvent.DOM_DELTA_PIXEL. + acc.accum += e.deltaMode === 0 ? Math.abs(e.deltaY) / cellHeight : speed; + const ticks = Math.trunc(acc.accum); + acc.accum -= ticks; + return ticks === 0 ? 0 : ticks * dir; +} + +/** SGR mouse report for `ticks` wheel ticks at a 1-based cell. */ +export function wheelSgr(ticks: number, col: number, row: number): string { + const btn = ticks < 0 ? 64 : 65; + return `\x1b[<${btn};${col};${row}M`.repeat(Math.abs(ticks)); +} + export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; @@ -171,6 +211,7 @@ export class Terminal implements ITerminalCore { private scrollAnimationStartY?: number; private scrollAnimationFrame?: number; private customWheelEventHandler?: (event: WheelEvent) => boolean; + private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private lastCursorY: number = 0; // Track cursor position for onCursorMove // DECSCUSR state: the app's cursor shape (undefined = options) and the // unfinished sequence at the end of the last write @@ -208,6 +249,7 @@ export class Terminal implements ITerminalCore { convertEol: options.convertEol ?? false, disableStdin: options.disableStdin ?? false, smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll + mouseScrollSpeed: options.mouseScrollSpeed ?? 1, }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) @@ -1644,6 +1686,25 @@ export class Terminal implements ITerminalCore { return; } + // App tracks the mouse (e.g. vim `set mouse=a`): report scroll ticks as SGR + // buttons 64/65 at the cell under the pointer, on either screen. + if (this.wasmTerm?.hasMouseTracking()) { + const metrics = this.renderer?.getMetrics(); + if (!metrics || !this.canvas) return; + const ticks = wheelTicks( + this.wheelAccumulator, + e, + metrics.height, + this.options.mouseScrollSpeed + ); + if (ticks === 0) return; + const rect = this.canvas.getBoundingClientRect(); + const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); + const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); + this.dataEmitter.fire(wheelSgr(ticks, col, row)); + return; + } + // Check if in alternate screen mode (vim, less, htop, etc.) const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false; diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index b1040a6..fafd3e2 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from 'bun:test'; -import { decscusrShape, mouseCursorFor, scanDecscusr } from './ghostty-web/terminal'; +import { + decscusrShape, + mouseCursorFor, + scanDecscusr, + type WheelAccumulator, + wheelSgr, + wheelTicks, +} from './ghostty-web/terminal'; import type { ILink } from './ghostty-web/types'; describe('mouseCursorFor', () => { @@ -98,3 +105,56 @@ describe('scanDecscusr', () => { expect(scanDecscusr('\x1b[6 x').carry).toBe(''); }); }); + +describe('wheelTicks', () => { + const fresh = (): WheelAccumulator => ({ accum: 0, dir: 0 }); + const PIXEL = 0; + const LINE = 1; + const PAGE = 2; + + test('a line delta is one tick per event, down positive and up negative', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 1)).toBe(1); + expect(wheelTicks(acc, { deltaY: -3, deltaMode: LINE }, 14, 1)).toBe(-1); + }); + + test('a page delta is one tick per event too', () => { + expect(wheelTicks(fresh(), { deltaY: 1, deltaMode: PAGE }, 14, 1)).toBe(1); + }); + + test('speed above 1 sends several ticks per line event', () => { + expect(wheelTicks(fresh(), { deltaY: 3, deltaMode: LINE }, 14, 3)).toBe(3); + }); + + test('speed below 1 accumulates across events', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(0); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(1); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(0); + }); + + test('pixel deltas count one tick per cell height, ignoring speed', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 7, deltaMode: PIXEL }, 14, 3)).toBe(0); + expect(wheelTicks(acc, { deltaY: 7, deltaMode: PIXEL }, 14, 3)).toBe(1); + expect(wheelTicks(acc, { deltaY: -42, deltaMode: PIXEL }, 14, 3)).toBe(-3); + }); + + test('a direction change drops the remainder', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 10, deltaMode: PIXEL }, 14, 1)).toBe(0); + expect(wheelTicks(acc, { deltaY: -10, deltaMode: PIXEL }, 14, 1)).toBe(0); + expect(wheelTicks(acc, { deltaY: -4, deltaMode: PIXEL }, 14, 1)).toBe(-1); + expect(wheelTicks(acc, { deltaY: 10, deltaMode: PIXEL }, 14, 1)).toBe(0); + }); +}); + +describe('wheelSgr', () => { + test('scroll up is button 64 at the cell', () => { + expect(wheelSgr(-1, 5, 3)).toBe('\x1b[<64;5;3M'); + }); + + test('scroll down is button 65, one report per tick', () => { + expect(wheelSgr(2, 1, 1)).toBe('\x1b[<65;1;1M\x1b[<65;1;1M'); + }); +}); From 54313c548584de626c7a15a0a67a90601fbd948a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 23:21:55 -0400 Subject: [PATCH 009/295] fix(client): drop the scrollbar reserve from the vendored FitAddon FitAddon.proposeDimensions subtracted a fixed 15 px scrollbar reserve even though the scrollbar is an overlay drawn inside the canvas, leaving a strip. The grid math is now the pure fitDimensions in vendor addons/fit.ts with no reserve; webtty's computeGrid workaround in src/client/fit.ts is deleted and index.ts takes the grid from proposeDimensions (insets still via computeInsets). --- docs/adrs/033.client.padding-fit.md | 2 + docs/specs/client.md | 2 +- src/client/fit.test.ts | 39 +---------------- src/client/fit.ts | 38 ++--------------- src/client/index.ts | 30 ++++++-------- src/client/vendor/fit.test.ts | 36 ++++++++++++++++ src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/addons/fit.ts | 46 ++++++++++++++++----- 8 files changed, 92 insertions(+), 102 deletions(-) create mode 100644 src/client/vendor/fit.test.ts diff --git a/docs/adrs/033.client.padding-fit.md b/docs/adrs/033.client.padding-fit.md index e58bdda..6346ab6 100644 --- a/docs/adrs/033.client.padding-fit.md +++ b/docs/adrs/033.client.padding-fit.md @@ -37,6 +37,8 @@ Two changes, both aimed at the visible strip: terminal colour, so the client removes it from the theme object before handing the theme to ghostty-web. +**Update (ADR 041):** the reserve is now removed in the vendored `FitAddon` (`src/client/vendor/ghostty-web/addons/fit.ts`, `fitDimensions`), and `computeGrid` is gone. The client sets `config.padding` as the container's CSS padding and takes the grid from `FitAddon.proposeDimensions()`; the leftover is still placed by `computeInsets` in `src/client/fit.ts`. + ## Considered Options ### Option A: Local fit without the reserve, plus `theme.padding` (chosen) diff --git a/docs/specs/client.md b/docs/specs/client.md index fc843bd..1af70ff 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -195,7 +195,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | | Font-size zoom | `Ctrl/Cmd` + `=`/`-`/`0` adjust terminal font size in-session; not configurable; same shortcuts as VS Code | [ADR 023](../adrs/023.client.font-size-zoom.md) | ✅ | | Kitty graphics | Client intercepts kitty graphics APC sequences and draws images on an overlay canvas; enables terminal-browser and `icat`-style tools | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | -| Tighter fit | Grid sized from the full container without `FitAddon`'s 15px scrollbar reserve, shrinking the strip at the edges to under one cell; what is left over is handled by the padding and edge-extension rows below | [ADR 033](../adrs/033.client.padding-fit.md) | ✅ | +| Tighter fit | Grid sized by the vendored `FitAddon` from the full container, with no scrollbar reserve, shrinking the strip at the edges to under one cell; what is left over is handled by the padding and edge-extension rows below | [ADR 033](../adrs/033.client.padding-fit.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Configurable padding | Top-level `padding` (px, default `0`) reserves space around the canvas on all sides; the grid is fitted to what remains; the top and left gap is exactly `padding` and the leftover goes to the right and bottom; optional `theme.padding` colours the space `padding` adds (default: `theme.background`) but not the leftover | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Edge extension | The leftover strip at the right and bottom takes the edge cells' own background, by repainting the canvas' last pixel row and column across it; always on, no setting | [ADR 035](../adrs/035.client.edge-extend.md) | ✅ | | Theme key mapping | The config's `purple`, `brightPurple` and `selection` are translated to the terminal's `magenta`, `brightMagenta` and `selectionBackground`, which were previously ignored; `selectionForeground` is new and defaults to `background` when `selection` is set | [ADR 036](../adrs/036.client.theme-key-mapping.md) | ✅ | diff --git a/src/client/fit.test.ts b/src/client/fit.test.ts index f90abe4..e311141 100644 --- a/src/client/fit.test.ts +++ b/src/client/fit.test.ts @@ -1,42 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { computeGrid, computeInsets, MIN_COLS, MIN_ROWS } from './fit'; - -describe('computeGrid', () => { - test('uses the whole container width, with no scrollbar reserve', () => { - // 800px / 10px = exactly 80 columns; a 15px reserve would give 78. - expect(computeGrid(800, 400, 10, 20)).toEqual({ cols: 80, rows: 20 }); - }); - - test('floors to whole cells', () => { - expect(computeGrid(809, 419, 10, 20)).toEqual({ cols: 80, rows: 20 }); - expect(computeGrid(799, 399, 10, 20)).toEqual({ cols: 79, rows: 19 }); - }); - - test('handles fractional cell sizes', () => { - expect(computeGrid(1000, 500, 8.4, 17)).toEqual({ cols: 119, rows: 29 }); - }); - - test('reserves padding on each side', () => { - // 800px - 2 x 10px = 780px -> 78 cols; 400px - 2 x 10px = 380px -> 19 rows. - expect(computeGrid(800, 400, 10, 20, 10)).toEqual({ cols: 78, rows: 19 }); - }); - - test('treats a negative padding as none', () => { - expect(computeGrid(800, 400, 10, 20, -5)).toEqual({ cols: 80, rows: 20 }); - }); - - test('never goes below the minimum grid', () => { - expect(computeGrid(5, 5, 10, 20)).toEqual({ cols: MIN_COLS, rows: MIN_ROWS }); - expect(computeGrid(100, 100, 10, 20, 60)).toEqual({ cols: MIN_COLS, rows: MIN_ROWS }); - }); - - test('returns null when the size or the cell size is unknown', () => { - expect(computeGrid(0, 400, 10, 20)).toBeNull(); - expect(computeGrid(800, 0, 10, 20)).toBeNull(); - expect(computeGrid(800, 400, 0, 20)).toBeNull(); - expect(computeGrid(800, 400, 10, 0)).toBeNull(); - }); -}); +import { computeInsets } from './fit'; describe('computeInsets', () => { test('with no padding, top and left are flush and the leftover goes right and bottom', () => { diff --git a/src/client/fit.ts b/src/client/fit.ts index 15132e6..c8b3139 100644 --- a/src/client/fit.ts +++ b/src/client/fit.ts @@ -1,41 +1,11 @@ /** - * Terminal grid sizing for the `#terminal` container. + * Padding around the canvas in the `#terminal` container. * - * ghostty-web's `FitAddon` always subtracts a fixed 15px scrollbar reserve, which - * leaves a visible strip at the edges even though the scrollbar is drawn as an - * overlay inside the canvas. This computes the grid from the full container, so the - * only space left over is the sub-cell remainder. See ADR 033. + * The vendored `FitAddon` sizes the grid from the full container, with no scrollbar + * reserve, so the only space left over is the sub-cell remainder. This places that + * remainder and the configured padding around the canvas. See ADR 033. */ -/** Fewest columns and rows the terminal is resized to (same as `FitAddon`). */ -export const MIN_COLS = 2; -export const MIN_ROWS = 1; - -export interface Grid { - cols: number; - rows: number; -} - -/** - * Cols and rows that fit in `width` x `height` CSS pixels of container, given the - * cell size, after reserving `padding` px on each side. Returns `null` if the - * container or cell size is not known yet. - */ -export function computeGrid( - width: number, - height: number, - cellWidth: number, - cellHeight: number, - padding = 0, -): Grid | null { - if (width <= 0 || height <= 0 || cellWidth <= 0 || cellHeight <= 0) return null; - const inner = Math.max(0, padding) * 2; - return { - cols: Math.max(MIN_COLS, Math.floor((width - inner) / cellWidth)), - rows: Math.max(MIN_ROWS, Math.floor((height - inner) / cellHeight)), - }; -} - export interface Insets { left: number; right: number; diff --git a/src/client/index.ts b/src/client/index.ts index e76c811..e6b3deb 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,11 +1,11 @@ import { Composer } from './compose'; import { EdgeExtender } from './edge'; -import { computeGrid, computeInsets } from './fit'; +import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { encodePaste } from './paste'; import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; -import { Ghostty, Terminal } from './vendor/ghostty-web'; +import { FitAddon, Ghostty, Terminal } from './vendor/ghostty-web'; import { watchDevicePixelRatio } from './vendor/ghostty-web/renderer'; interface KeyboardBinding { @@ -100,23 +100,17 @@ new Composer(term, container, terminalTheme); // Pixel-smooth scrollback scrolling for trackpads. See ADR 040. const smoothScroll = new SmoothScroll(term, container); -// ghostty-web's FitAddon subtracts a fixed 15px scrollbar reserve, which leaves a -// strip at the edges even though the scrollbar is an overlay drawn inside the canvas. -// Size the grid from the full container instead (computeGrid), then distribute what is -// left, plus config.padding on each side, as padding: top and left are exactly -// config.padding, and the leftover goes to the right and bottom. Padding is cleared -// first so clientWidth/Height measure the whole container. See ADR 033. +// FitAddon sizes the grid from the full container less its CSS padding, so the +// padding is set to config.padding on every side first (#terminal is border-box, so +// clientWidth/Height do not change). What is left over, plus config.padding on each +// side, is then distributed as padding: top and left are exactly config.padding, and +// the leftover goes to the right and bottom. proposeDimensions rather than +// FitAddon.fit(), which ignores calls for 50 ms after each resize. See ADR 033. +const fitAddon = new FitAddon(); +term.loadAddon(fitAddon); function fit(): void { - container.style.padding = '0'; - const metrics = term.renderer?.getMetrics(); - if (!metrics) return; - const grid = computeGrid( - container.clientWidth, - container.clientHeight, - metrics.width, - metrics.height, - config.padding, - ); + container.style.padding = `${config.padding}px`; + const grid = fitAddon.proposeDimensions(); if (!grid) return; if (grid.cols !== term.cols || grid.rows !== term.rows) { term.resize(grid.cols, grid.rows); diff --git a/src/client/vendor/fit.test.ts b/src/client/vendor/fit.test.ts new file mode 100644 index 0000000..46e6eed --- /dev/null +++ b/src/client/vendor/fit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { fitDimensions } from './ghostty-web/addons/fit'; + +const NONE = { top: 0, right: 0, bottom: 0, left: 0 }; +const pad = (p: number) => ({ top: p, right: p, bottom: p, left: p }); + +describe('fitDimensions', () => { + test('uses the whole container width, with no scrollbar reserve', () => { + // 800px / 10px = exactly 80 columns; a 15px reserve would give 78. + expect(fitDimensions(800, 400, NONE, 10, 20)).toEqual({ cols: 80, rows: 20 }); + }); + + test('floors to whole cells', () => { + expect(fitDimensions(809, 419, NONE, 10, 20)).toEqual({ cols: 80, rows: 20 }); + expect(fitDimensions(799, 399, NONE, 10, 20)).toEqual({ cols: 79, rows: 19 }); + }); + + test('handles fractional cell sizes', () => { + expect(fitDimensions(1000, 500, NONE, 8.4, 17)).toEqual({ cols: 119, rows: 29 }); + }); + + test('subtracts the padding on each side', () => { + // 800px - 2 x 10px = 780px -> 78 cols; 400px - 2 x 10px = 380px -> 19 rows. + expect(fitDimensions(800, 400, pad(10), 10, 20)).toEqual({ cols: 78, rows: 19 }); + // Sides are independent: 800 - 5 - 15 = 780 -> 78; 400 - 0 - 40 = 360 -> 18. + expect(fitDimensions(800, 400, { top: 0, right: 15, bottom: 40, left: 5 }, 10, 20)).toEqual({ + cols: 78, + rows: 18, + }); + }); + + test('never goes below the minimum grid', () => { + expect(fitDimensions(5, 5, NONE, 10, 20)).toEqual({ cols: 2, rows: 1 }); + expect(fitDimensions(100, 100, pad(60), 10, 20)).toEqual({ cols: 2, rows: 1 }); + }); +}); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 75d8203..dd11602 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -18,3 +18,4 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`). +- `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. diff --git a/src/client/vendor/ghostty-web/addons/fit.ts b/src/client/vendor/ghostty-web/addons/fit.ts index ea53265..0339c85 100644 --- a/src/client/vendor/ghostty-web/addons/fit.ts +++ b/src/client/vendor/ghostty-web/addons/fit.ts @@ -21,7 +21,6 @@ import type { ITerminalAddon, ITerminalCore } from '../interfaces'; const MINIMUM_COLS = 2; const MINIMUM_ROWS = 1; -const DEFAULT_SCROLLBAR_WIDTH = 15; // Reserve space for future scrollback scrollbar const RESIZE_DEBOUNCE_MS = 100; // Debounce time for ResizeObserver // ============================================================================ @@ -33,6 +32,33 @@ export interface ITerminalDimensions { rows: number; } +export interface IFitPadding { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Cols and rows that fit in a `width` x `height` element (clientWidth/Height, which + * include padding) after its padding, with the given cell size. No space is + * reserved for a scrollbar: the scrollbar is an overlay drawn inside the canvas. + */ +export function fitDimensions( + width: number, + height: number, + padding: IFitPadding, + cellWidth: number, + cellHeight: number +): ITerminalDimensions { + const availableWidth = width - padding.left - padding.right; + const availableHeight = height - padding.top - padding.bottom; + return { + cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / cellWidth)), + rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / cellHeight)), + }; +} + // ============================================================================ // FitAddon Class // ============================================================================ @@ -133,7 +159,6 @@ export class FitAddon implements ITerminalAddon { * - Terminal container element dimensions (clientWidth/Height) * - Terminal element padding * - Font metrics (character cell size) - * - Scrollbar width reservation * * @returns Proposed dimensions or undefined if cannot calculate */ @@ -184,15 +209,14 @@ export class FitAddon implements ITerminalAddon { return undefined; } - // Calculate available space (subtract padding since clientWidth includes padding) - const availableWidth = containerWidth - paddingLeft - paddingRight - DEFAULT_SCROLLBAR_WIDTH; - const availableHeight = containerHeight - paddingTop - paddingBottom; - - // Calculate dimensions (enforce minimums) - const cols = Math.max(MINIMUM_COLS, Math.floor(availableWidth / metrics.width)); - const rows = Math.max(MINIMUM_ROWS, Math.floor(availableHeight / metrics.height)); - - return { cols, rows }; + // Subtract padding (clientWidth includes it) and enforce minimums + return fitDimensions( + containerWidth, + containerHeight, + { top: paddingTop, right: paddingRight, bottom: paddingBottom, left: paddingLeft }, + metrics.width, + metrics.height + ); } /** From 3638b52b9380fa803575025ac7d28c42b6e4d718 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 23:30:57 -0400 Subject: [PATCH 010/295] fix(client): keep focus on the textarea at the cursor cell at source ghostty-web lets its contenteditable container take focus, so IME text landed in the container, and its textarea sits at a fixed spot, so OS IME/dictation panels opened away from the cursor. The vendored terminal now redirects container focus (focusin, window focus) to the textarea and anchors it at the cursor cell each frame (pure cursorCellOffset, unit-tested); compose.ts drops its focus redirects and per-frame textarea anchoring and keeps only the overlay. --- .../038.client.ime-composition-at-cursor.md | 2 + docs/specs/client.md | 4 +- src/client/compose.test.ts | 34 ------ src/client/compose.ts | 103 ++++-------------- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/terminal.ts | 91 ++++++++++++++++ src/client/vendor/terminal.test.ts | 33 ++++++ 7 files changed, 150 insertions(+), 118 deletions(-) delete mode 100644 src/client/compose.test.ts diff --git a/docs/adrs/038.client.ime-composition-at-cursor.md b/docs/adrs/038.client.ime-composition-at-cursor.md index 008a5d0..3d4093a 100644 --- a/docs/adrs/038.client.ime-composition-at-cursor.md +++ b/docs/adrs/038.client.ime-composition-at-cursor.md @@ -27,6 +27,8 @@ The client takes over how composition is presented. The data path that ghostty-w 2. **Anchor the textarea at the cursor.** On every animation frame, move the textarea to the pixel position of the terminal cursor cell, writing only the styles that changed. ghostty-web's `onRender` never fires and `onCursorMove` only reports row changes, so neither can drive this. The position is `term.buffer.active.cursorX` / `cursorY`, shifted down by `viewportY` and clamped to the grid, multiplied by the cell size from `renderer.getMetrics()`, and offset by the canvas position inside the container. The textarea stays anchored even before a composition starts, because macOS places the dictation microphone when dictation begins. The textarea gets the cell's line height. macOS then places the dictation microphone and the IME candidate window at the cursor. 3. **Show a themed preview at the cursor.** Add a small overlay element (`src/client/compose.ts`) with absolute position and `pointer-events: none`, placed over the cursor cell. It is updated from `compositionupdate` (`event.data`) and hidden on `compositionend` or `blur`. It uses the terminal's `fontFamily` and `fontSize`, `theme.foreground` on `theme.background`, and an underline, which is the usual style for uncommitted text. Long text is not wrapped and runs to the right edge. The textarea stays invisible, so the overlay is the only visible copy of the composition. +**Update (ADR 041):** steps 1 and 2 now live in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `open()` adds the `focusin` and window `focus` redirects, and the render loop anchors the textarea at the cursor cell each frame (`cursorCellOffset`, unit-tested in `src/client/vendor/terminal.test.ts`). `compose.ts` keeps only the overlay, placed from `Terminal.getCursorCellOffset()` on each frame while composing. + ## Considered Options - **Remove `contenteditable` from the container.** This would stop the stray text node. However, ghostty-web adds the attribute deliberately and its `beforeinput` guard depends on it. Removing it would also leave a focused container that accepts no IME input at all. Sending focus to the textarea fixes the same problem without patching ghostty-web's assumptions. diff --git a/docs/specs/client.md b/docs/specs/client.md index 1af70ff..ec2db17 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -182,11 +182,11 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime inside the terminal (Ps 0 restores the default), and the cursor cell is repainted so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | -| IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ | +| IME / dictation at cursor | The terminal keeps focus on its input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, is followed by the renderer, which repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | -| ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Official libghostty-vt wasm | The terminal runs on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked) with current Ghostty parsing and query replies; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | diff --git a/src/client/compose.test.ts b/src/client/compose.test.ts deleted file mode 100644 index 33a6122..0000000 --- a/src/client/compose.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { computeCursorSpot } from './compose'; - -const grid = { cols: 80, rows: 24 }; -const cell = { width: 9, height: 18 }; -const canvas = { left: 0, top: 0 }; - -describe('computeCursorSpot', () => { - test('puts the spot at the cursor cell', () => { - expect(computeCursorSpot({ x: 10, y: 5 }, 0, grid, cell, canvas)).toEqual({ - left: 90, - top: 90, - }); - }); - - test('offsets by the canvas position inside the padding', () => { - expect(computeCursorSpot({ x: 0, y: 0 }, 0, grid, cell, { left: 12, top: 8 })).toEqual({ - left: 12, - top: 8, - }); - }); - - test('moves down with the scrollback offset', () => { - expect(computeCursorSpot({ x: 0, y: 5 }, 3, grid, cell, canvas).top).toBe(8 * 18); - }); - - test('stays on the last row once the cursor scrolls out of view', () => { - expect(computeCursorSpot({ x: 0, y: 20 }, 100, grid, cell, canvas).top).toBe(23 * 18); - }); - - test('clamps a pending-wrap cursor to the last column', () => { - expect(computeCursorSpot({ x: 80, y: 0 }, 0, grid, cell, canvas).left).toBe(79 * 9); - }); -}); diff --git a/src/client/compose.ts b/src/client/compose.ts index bdd09fa..e3285c6 100644 --- a/src/client/compose.ts +++ b/src/client/compose.ts @@ -1,17 +1,11 @@ /** * Shows IME / dictation composition text at the terminal cursor. See ADR 038. * - * ghostty-web makes the container `contenteditable` and reads input from a hidden - * textarea. When the container itself holds focus (a click in the padding, tab focus), - * the browser writes the uncommitted composition into the container as a text node: a - * stray line under the canvas in the page's default colours. When the textarea holds - * focus, the composition is invisible and the OS panels anchor to its spot at the - * top-left corner. - * - * So focus is kept on the textarea, the textarea follows the cursor cell (the OS places - * the dictation and candidate panels by its caret), and an overlay shows the composition - * over the cursor cell in the theme's colours. Committed text still reaches the PTY - * through ghostty-web's own compositionend handling. + * The vendored terminal keeps focus on its hidden textarea and anchors the textarea at + * the cursor cell, so the OS dictation and candidate panels follow the cursor. The + * textarea is clipped, so the composition itself is invisible; an overlay shows it over + * the cursor cell in the theme's colours. Committed text still reaches the PTY through + * ghostty-web's own compositionend handling. */ import type { Terminal } from './vendor/ghostty-web'; @@ -20,35 +14,10 @@ import type { Terminal } from './vendor/ghostty-web'; const DEFAULT_FOREGROUND = '#d4d4d4'; const DEFAULT_BACKGROUND = '#1e1e1e'; -export interface CursorSpot { - left: number; - top: number; -} - -/** - * The cursor cell's top-left corner, in CSS pixels relative to the container's padding - * box. `cursorY` is relative to the bottom screen; when scrolled back by `viewportY` - * lines the cursor moves down by that many rows, and it is kept on the last visible row - * once it scrolls out of view. - */ -export function computeCursorSpot( - cursor: { x: number; y: number }, - viewportY: number, - grid: { cols: number; rows: number }, - cell: { width: number; height: number }, - canvas: { left: number; top: number }, -): CursorSpot { - const col = Math.min(Math.max(0, cursor.x), Math.max(0, grid.cols - 1)); - const row = Math.min(Math.max(0, cursor.y + Math.floor(viewportY)), Math.max(0, grid.rows - 1)); - return { - left: canvas.left + col * cell.width, - top: canvas.top + row * cell.height, - }; -} - export class Composer { private readonly overlay = document.createElement('div'); private composing = false; + private frame = 0; constructor( private readonly term: Terminal, @@ -66,56 +35,39 @@ export class Composer { o.style.textDecoration = 'underline'; container.append(o); - container.addEventListener('focusin', this.onFocusIn); - // focus() on a background window moves activeElement without a focusin; catch the - // container still holding focus when the window comes back. - window.addEventListener('focus', () => { - if (document.activeElement === container) this.term.textarea?.focus(); - }); container.addEventListener('compositionstart', this.onCompositionStart); container.addEventListener('compositionupdate', this.onCompositionUpdate); container.addEventListener('compositionend', this.onCompositionEnd); container.addEventListener('focusout', this.onCompositionEnd); - // ghostty-web's onRender never fires and onCursorMove only reports row changes, so - // follow the cursor every frame. update() only writes styles that changed. - window.requestAnimationFrame(this.tick); } + // ghostty-web's onRender never fires and onCursorMove only reports row changes, so + // the overlay follows the cursor every frame while composing. setPx only writes + // styles that changed. private readonly tick = (): void => { - window.requestAnimationFrame(this.tick); + if (!this.composing) { + this.frame = 0; + return; + } + this.frame = window.requestAnimationFrame(this.tick); this.update(); }; - /** Re-anchor the textarea (and the overlay, while composing). */ + /** Re-place the overlay over the cursor cell. */ update(): void { - const spot = this.cursorSpot(); - const textarea = this.term.textarea; - if (!spot || !textarea) return; - const cell = this.cell(); - // ghostty-web's right-click copy flow repositions the textarea while it holds the - // selection; leave it alone until that flow restores it. - if (textarea.style.position === 'fixed' && textarea.style.pointerEvents === 'auto') return; - if (textarea.style.position !== 'absolute') textarea.style.position = 'absolute'; - setPx(textarea, 'left', spot.left); - setPx(textarea, 'top', spot.top); - setPx(textarea, 'width', 1); - setPx(textarea, 'height', cell.height); - if (this.composing) this.placeOverlay(spot); + const spot = this.term.getCursorCellOffset(); + if (spot) this.placeOverlay(spot); } - private readonly onFocusIn = (e: FocusEvent): void => { - // The container is contenteditable; never let it hold focus, or the browser writes - // composition text into it. - if (e.target === this.container) this.term.textarea?.focus(); - }; - private readonly onCompositionStart = (): void => { this.composing = true; this.update(); + if (!this.frame) this.frame = window.requestAnimationFrame(this.tick); }; private readonly onCompositionUpdate = (e: CompositionEvent): void => { this.composing = true; + if (!this.frame) this.frame = window.requestAnimationFrame(this.tick); const text = e.data ?? ''; if (!text) { this.overlay.style.display = 'none'; @@ -132,7 +84,7 @@ export class Composer { this.overlay.textContent = ''; }; - private placeOverlay(spot: CursorSpot): void { + private placeOverlay(spot: { left: number; top: number }): void { const o = this.overlay; const cell = this.cell(); const canvas = this.terminalCanvas(); @@ -148,19 +100,6 @@ export class Composer { o.style.background = this.theme.background ?? DEFAULT_BACKGROUND; } - private cursorSpot(): CursorSpot | null { - const canvas = this.terminalCanvas(); - if (!canvas) return null; - const buf = this.term.buffer.active; - return computeCursorSpot( - { x: buf.cursorX, y: buf.cursorY }, - this.term.viewportY, - { cols: this.term.cols, rows: this.term.rows }, - this.cell(), - { left: canvas.offsetLeft, top: canvas.offsetTop }, - ); - } - private cell(): { width: number; height: number } { const metrics = this.term.renderer?.getMetrics(); return { width: metrics?.width ?? 0, height: metrics?.height ?? 0 }; @@ -173,7 +112,7 @@ export class Composer { function setPx( el: HTMLElement, - prop: 'left' | 'top' | 'width' | 'height' | 'lineHeight' | 'maxWidth', + prop: 'left' | 'top' | 'height' | 'lineHeight' | 'maxWidth', value: number, ): void { const px = `${value}px`; diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index dd11602..812d7a5 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -19,3 +19,4 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. +- `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 7647016..9f901df 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -133,6 +133,39 @@ export function wheelSgr(ticks: number, col: number, row: number): string { return `\x1b[<${btn};${col};${row}M`.repeat(Math.abs(ticks)); } +/** + * The cursor cell's top-left corner, in CSS pixels relative to the container's + * padding box. `cursor.y` is relative to the bottom screen; when scrolled back by + * `viewportY` lines the cursor moves down by that many rows, and it is kept on the + * last visible row once it scrolls out of view. A pending-wrap cursor is kept on + * the last column. (webtty: the input textarea is anchored here so the OS places + * IME and dictation panels at the cursor.) + */ +export function cursorCellOffset( + cursor: { x: number; y: number }, + viewportY: number, + grid: { cols: number; rows: number }, + cell: { width: number; height: number }, + canvas: { left: number; top: number } +): { left: number; top: number } { + const col = Math.min(Math.max(0, cursor.x), Math.max(0, grid.cols - 1)); + const row = Math.min(Math.max(0, cursor.y + Math.floor(viewportY)), Math.max(0, grid.rows - 1)); + return { + left: canvas.left + col * cell.width, + top: canvas.top + row * cell.height, + }; +} + +/** Sets a pixel style only when it changed, so a per-frame update writes nothing. */ +function setStylePx( + el: HTMLElement, + prop: 'left' | 'top' | 'width' | 'height', + value: number +): void { + const px = `${value}px`; + if (el.style[prop] !== px) el.style[prop] = px; +} + export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; @@ -459,6 +492,13 @@ export class Terminal implements ITerminalCore { e.preventDefault(); } }); + // Never let the container hold focus, or the browser writes IME composition + // text into it; input goes to the textarea. This covers clicks in the padding, + // tab focus and focus(). (webtty) + parent.addEventListener('focusin', this.handleFocusIn); + // focus() on a background window moves activeElement without a focusin; catch + // the container still holding focus when the window comes back. (webtty) + window.addEventListener('focus', this.handleWindowFocus); // Add accessibility attributes for screen readers and extensions parent.setAttribute('role', 'textbox'); @@ -1263,6 +1303,52 @@ export class Terminal implements ITerminalCore { // Private Methods // ========================================================================== + /** + * Sends focus that lands on the container itself to the textarea. (webtty) + */ + private handleFocusIn = (e: FocusEvent): void => { + if (e.target === this.element) this.textarea?.focus(); + }; + + private handleWindowFocus = (): void => { + if (this.element && document.activeElement === this.element) this.textarea?.focus(); + }; + + /** + * The cursor cell's top-left corner in CSS pixels relative to the container's + * padding box, or undefined before open(). (webtty) + */ + getCursorCellOffset(): { left: number; top: number } | undefined { + if (!this.canvas || !this.renderer || !this.wasmTerm) return undefined; + const metrics = this.renderer.getMetrics(); + return cursorCellOffset( + this.wasmTerm.getCursor(), + this.viewportY, + { cols: this.cols, rows: this.rows }, + metrics, + { left: this.canvas.offsetLeft, top: this.canvas.offsetTop } + ); + } + + /** + * Moves the textarea onto the cursor cell, one cell high, writing only styles + * that changed. macOS places the dictation microphone and IME candidate window + * by its caret, so it stays anchored before a composition starts too. (webtty) + */ + private anchorTextarea(): void { + const textarea = this.textarea; + const spot = this.getCursorCellOffset(); + if (!textarea || !spot) return; + // The right-click copy flow repositions the textarea while it holds the + // selection; leave it alone until that flow restores it. + if (textarea.style.position === 'fixed' && textarea.style.pointerEvents === 'auto') return; + if (textarea.style.position !== 'absolute') textarea.style.position = 'absolute'; + setStylePx(textarea, 'left', spot.left); + setStylePx(textarea, 'top', spot.top); + setStylePx(textarea, 'width', 1); + setStylePx(textarea, 'height', this.renderer!.getMetrics().height); + } + /** * Cancel the render loop */ @@ -1305,6 +1391,9 @@ export class Terminal implements ITerminalCore { this.cursorMoveEmitter.fire(); } + // Keep the input textarea on the cursor cell (webtty) + this.anchorTextarea(); + // Note: onRender event is intentionally not fired in the render loop // to avoid performance issues. For now, consumers can use requestAnimationFrame // if they need frame-by-frame updates. @@ -1374,6 +1463,7 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mousemove', this.handleMouseMove); this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); + this.element.removeEventListener('focusin', this.handleFocusIn); // Remove contenteditable and accessibility attributes added in open() this.element.removeAttribute('contenteditable'); @@ -1385,6 +1475,7 @@ export class Terminal implements ITerminalCore { // Remove document-level listeners (only if opened) if (this.isOpen && typeof document !== 'undefined') { document.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener('focus', this.handleWindowFocus); } // Clean up scrollbar timers diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index fafd3e2..8b824c1 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { + cursorCellOffset, decscusrShape, mouseCursorFor, scanDecscusr, @@ -158,3 +159,35 @@ describe('wheelSgr', () => { expect(wheelSgr(2, 1, 1)).toBe('\x1b[<65;1;1M\x1b[<65;1;1M'); }); }); + +describe('cursorCellOffset', () => { + const grid = { cols: 80, rows: 24 }; + const cell = { width: 9, height: 18 }; + const canvas = { left: 0, top: 0 }; + + test('puts the spot at the cursor cell', () => { + expect(cursorCellOffset({ x: 10, y: 5 }, 0, grid, cell, canvas)).toEqual({ + left: 90, + top: 90, + }); + }); + + test('offsets by the canvas position inside the padding', () => { + expect(cursorCellOffset({ x: 0, y: 0 }, 0, grid, cell, { left: 12, top: 8 })).toEqual({ + left: 12, + top: 8, + }); + }); + + test('moves down with the scrollback offset', () => { + expect(cursorCellOffset({ x: 0, y: 5 }, 3, grid, cell, canvas).top).toBe(8 * 18); + }); + + test('stays on the last row once the cursor scrolls out of view', () => { + expect(cursorCellOffset({ x: 0, y: 20 }, 100, grid, cell, canvas).top).toBe(23 * 18); + }); + + test('clamps a pending-wrap cursor to the last column', () => { + expect(cursorCellOffset({ x: 80, y: 0 }, 0, grid, cell, canvas).left).toBe(79 * 9); + }); +}); From 0622e72a609742f73648b8201ea2f6519850bcd6 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 00:08:18 -0400 Subject: [PATCH 011/295] docs: ADR 042 - official libghostty-vt engine behind ?engine Decides ADR 041 step 3: the official binding as a second engine behind the GhosttyTerminal interface, callbacks through a borrowed fn-table trampoline, viewport/page-read rules that keep render-state dirty tracking correct, offline sha256 + minisign verification, ?engine=coder|official with coder as the default until the acceptance matrix is recorded. ReleaseSmall: the ReleaseFast build is only 0-8% faster on a 20 MiB cat for 35% more bytes. --- docs/adrs/041.client.libghostty-vt.md | 7 +- docs/adrs/042.client.official-vt-engine.md | 159 +++++++++++++++++++++ docs/specs/client.md | 18 ++- 3 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 docs/adrs/042.client.official-vt-engine.md diff --git a/docs/adrs/041.client.libghostty-vt.md b/docs/adrs/041.client.libghostty-vt.md index 28d4040..4174e5d 100644 --- a/docs/adrs/041.client.libghostty-vt.md +++ b/docs/adrs/041.client.libghostty-vt.md @@ -52,7 +52,7 @@ Take ownership of the terminal layer: vendor ghostty-web's TypeScript into webtt | Textarea keeps focus and follows the cursor cell | focus and positioning half of `compose.ts` | 038 | | DECSCUSR applied inside the terminal, with a cursor-cell repaint | `cursor.ts` stream interceptor and ghost-cursor repaint | 013, 015 | -3. **Official wasm and binding, alongside the Coder engine** (own ADR). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. +3. **Official wasm and binding, alongside the Coder engine** ([ADR 042](042.client.official-vt-engine.md)). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. 4. **Scrollback in lines** (own ADR, client and config). A new client key `scrollbackLines` (default 10,000) sets `SCROLLBACK_MAX_LINES`, with `SCROLLBACK_MAX_BYTES` at 32 MiB. `scrollbackLines: 0` means unlimited lines: `SCROLLBACK_MAX_LINES` NULL, bytes still 32 MiB. `scrollback` keeps its current meaning, the server's replay buffer in characters (`src/server/websocket.ts`), and the client stops deriving its limit from it. Official engine only. 5. **Delete the Coder engine and the workarounds the official API makes unnecessary**, after one release with `official` as the default: the Coder wasm and its sidecar, cursor style from the render-state cursor, size reports answered by Ghostty through `OPT_SIZE` (deletes the `kitty.ts` size replies; depends on step 3 installing `OPT_SIZE`), the official mouse/key encoders and `terminal_paste`, theme colours from render state. 6. **Pixel-smooth scroll through `OVERSCAN`** (own ADR, supersedes ADR 040 in part). Draw the extra rows the render state provides at a fractional offset, replacing the strip canvas and `renderLine` swap of ADR 040. When it ships, ADR 040 gets a "Superseded in part by" note and the client SPEC's ADR 040 row is updated. @@ -89,7 +89,7 @@ Code borrowed from other projects keeps its licence notice and a header naming s - **Depend on `@gkoreli/ghostty-vt-js`** — an MIT ghostty-web-shaped TS layer over unpatched Ghostty, but pinned at Ghostty `09557e91` (2026-08-10), 1071 commits behind the pin above. It calls exports the current wasm no longer has (it fails at once with `ghostty_wasm_alloc_u8_array is not a function`), predates `CELLS_RAW`, `next_dirty` and `OVERSCAN`, and replaces FitAddon and the renderer API webtty uses. One maintainer, created 2026-09-19. Borrow its function-table trampoline (`src/wasm/fn-table.ts`, verified to install a `WRITE_PTY` callback on the official wasm) with attribution; do not depend on it. - **Thin rewrite of the whole terminal** — a new renderer, input and selection on the official API. Most of ghostty-web's TS works and is what webtty is tuned against; rewriting it re-opens every behaviour ADR 013–040 settled. - **A Zig shim around Ghostty** that exports one bulk-row helper — no Ghostty diff, but adds a Zig toolchain to the build and binds to internal Zig APIs, which are less stable than the C ABI. `CELLS_RAW` with a style cache already matches a bulk copy per frame, so the shim buys nothing measurable. -- **ReleaseFast (`ghostty-vt.wasm`, about 1.06 MB) instead of ReleaseSmall (780 KB)** — left to the step 3 ADR. Frame decode is already about 0.2 ms on ReleaseSmall, so the deciding factor is `vt_write` throughput on a large `cat`, which has not been compared between the two. +- **ReleaseFast (`ghostty-vt.wasm`, about 1.06 MB) instead of ReleaseSmall (780 KB)** — left to the step 3 ADR. Frame decode is already about 0.2 ms on ReleaseSmall, so the deciding factor is `vt_write` throughput on a large `cat`, which has not been compared between the two. Settled in [ADR 042](042.client.official-vt-engine.md): ReleaseSmall, as ReleaseFast is 0–8% faster on a 20 MiB `cat`. ## Consequences @@ -103,7 +103,7 @@ Code borrowed from other projects keeps its licence notice and a header naming s - **Memory never shrinks.** Wasm linear memory has no maximum and stays at its high-water mark after `reset` and `free`; compression is unsupported on wasm32. A tab gives memory back only when it closes. - **Tip-build availability.** Only some main commits get a tip wasm (8 of the last 25 checked), and the `.minisig` is downloadable only while the commit is the current `tip`. Committing the wasm and its signature removes the runtime dependence; pinning an arbitrary commit means a local Zig build. - **Kitty graphics stays client-side.** It is compiled out of the wasm32 build (`kittyGraphics: false`), so ADR 032's overlay remains. -- **Behaviour changes apps can see.** Primary DA (`ESC[?62;22c`) is unchanged. Secondary DA stays `ESC[>1;10;0c` only because the binding installs `DEVICE_ATTRIBUTES`; step 3 verifies both end to end. Replies that are actually new are the ones the Coder patch dropped: XTWINOPS and mode 2048 size reports from Ghostty (through `OPT_SIZE`), OSC 4/10/11/12 colour queries (a no-op in the Coder patch), DECRQM mode reports, XTVERSION, the kitty keyboard query and ENQ. The Hindi conjunct above takes one wide cell, and newer Ghostty parsing applies everywhere. All of these replies reach the PTY only through the `WRITE_PTY` callback; step 3 installs it, or the official engine answers nothing. +- **Behaviour changes apps can see.** Primary DA (`ESC[?62;22c`) is unchanged. Secondary DA stays `ESC[>1;10;0c` only because the binding installs `DEVICE_ATTRIBUTES`; step 3 verifies both end to end. Replies that are actually new are the ones the Coder patch dropped: XTWINOPS and mode 2048 size reports from Ghostty (through `OPT_SIZE`), OSC 4/10/11/12 colour queries (a no-op in the Coder patch), DECRQM mode reports, XTVERSION, the kitty keyboard query and ENQ (ENQ only with the `ENQUIRY` callback, which step 3 does not install; see [ADR 042](042.client.official-vt-engine.md)). The Hindi conjunct above takes one wide cell, and newer Ghostty parsing applies everywhere. All of these replies reach the PTY only through the `WRITE_PTY` callback; step 3 installs it, or the official engine answers nothing. - The official artifact needs wasm SIMD (Chrome 91+, Firefox 89+, Safari 16.4+). - Not yet measured: glyph drawing cost, Firefox and Safari, resize/reflow parity, OSC/query replies end to end, secondary DA without a callback in a browser, and `OVERSCAN` frames in a browser. The step 3 matrix and step 6 must cover them before the default flips or step 6 ships. @@ -118,3 +118,4 @@ Code borrowed from other projects keeps its licence notice and a header naming s - [ADR 038 — IME composition at the cursor](038.client.ime-composition-at-cursor.md) - [ADR 039 — Follow devicePixelRatio changes](039.client.device-pixel-ratio-change.md) - [ADR 040 — Pixel-smooth scrollback scrolling](040.client.pixel-smooth-scroll.md) +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md) diff --git a/docs/adrs/042.client.official-vt-engine.md b/docs/adrs/042.client.official-vt-engine.md new file mode 100644 index 0000000..4d20eba --- /dev/null +++ b/docs/adrs/042.client.official-vt-engine.md @@ -0,0 +1,159 @@ +# ADR 042: client — Official libghostty-vt engine behind `?engine` + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-12 + +--- + +## Context + +ADR 041 step 3 adds a binding to Ghostty's official, unpatched libghostty-vt wasm next to the Coder engine, behind the `GhosttyTerminal` surface the renderer, selection and buffer code already use, and leaves the details to this ADR. Its rules for the binding (commit the wasm and signature, verify offline, one binding module, layouts from `ghostty_type_json`, no wasm memory views across export calls, never 0 bytes or NULL for both scrollback limits) apply here unchanged. + +ADR 041 left ReleaseSmall (`ghostty-vt-small.wasm`) against ReleaseFast (`ghostty-vt.wasm`) open, to be settled on `vt_write` throughput. Both builds from Ghostty `05dcf887` were measured (Apple M3 Pro; 120×40; mode 2027 on through `MODE_DEFAULT`; `SCROLLBACK_MAX_BYTES` 3277; 64 KiB `vt_write` chunks; no rendering; a fresh instance per run, build order alternated; medians of 10 runs in Bun and 8 in each browser). "Mixed" is 20 MiB of word lines with 1,219,849 SGR sequences (16-colour, 256-colour and truecolor foregrounds, bold with background, italic with underline, resets); "plain" is 20 MiB of the same words without SGR. + +| | ReleaseSmall | ReleaseFast | +|---|---|---| +| Size | 780,049 B (gzip -9: 280,250) | 1,057,238 B (gzip -9: 346,187) | +| Bun 1.3.11, mixed / plain | 141.6 / 478.7 MiB/s | 141.9 / 515.0 MiB/s | +| Headless Chrome 155, mixed / plain | 154.6 / 704.2 MiB/s | 162.2 / 701.8 MiB/s | +| Headless Firefox 157, mixed / plain | 120.5 / 500.0 MiB/s | 127.4 / 540.5 MiB/s | + +ReleaseFast is 0–8% faster. A 20 MiB SGR-heavy `cat` spends 0.13–0.17 s in `vt_write` on ReleaseSmall and about 0.01 s less on ReleaseFast. With step 4's 32 MiB byte ceiling (Bun, 4 runs) the gap is the same: mixed 135.7 against 138.5 MiB/s, plain 438 against 478.8 MiB/s. Wasm compile was under 1 ms in Chrome and 2–5 ms in Firefox for both. + +The Coder wasm, and the official wasm (`05dcf887`, ReleaseSmall) with the callbacks decided below, reply as follows (probed in Bun): + +| Query | Coder wasm | Official, binding as decided below | +|---|---|---| +| DA1 `CSI c` | `ESC[?62;22c` | `ESC[?62;22c` | +| DA2 `CSI > c` | `ESC[>1;10;0c` | `ESC[>1;10;0c` (`ESC[>1;0;0c` without the callback) | +| DA3 `CSI = c` | none | `DCS ! \| 00000000 ST` (with or without the callback) | +| XTWINOPS `14/16/18 t` | none (`kitty.ts` answers) | `ESC[4;h;wt`, `ESC[6;ch;cwt`, `ESC[8;rows;colst` from `OPT_SIZE` | +| XTWINOPS `19 t`, mode 2048 | none | `ESC[9;rows;colst`, `ESC[48;rows;cols;h;wt` | +| OSC 4/10/11 `?` | none | colour report, once the theme colours are set | +| XTVERSION `CSI > q` | none | `DCS >\|libghostty ST` (no callback installed) | +| DSR `6n` | `ESC[r;cR` | same | +| ENQ | none | none (no callback installed) | + +Scrollback was measured on the first 4 MiB of the same plain and mixed payloads, 64 KiB writes, at the derived default budget (3277: `SCROLLBACK_MAX_BYTES` on the official wasm, `scrollbackLimit` on the Coder wasm): + +| Size, payload | Coder wasm | Official wasm | +|---|---|---| +| 120×40, plain | 409 rows | 512 rows (+25%) | +| 120×40, mixed | 525 rows | 584 rows (+11%) | +| 200×60, plain | 351 rows | 378 rows (+8%) | +| 200×60, mixed | 261 rows | 243 rows (−7%) | + +The official wasm keeps more rows in three cases and 7% fewer in one. ADR 041's figures (500–650 rows at 120 columns, 224 at 200×60) came from a different payload and are not compared here. + +## Decision + +Add the official engine as a second engine behind one interface, selectable per tab with `?engine=official`, with `coder` as the default until the acceptance matrix below is recorded. + +**Files.** + +``` +src/client/vendor/ + ghostty-vt/ ← official wasm, not edited + ghostty-vt-small.wasm ← Ghostty 05dcf887, ReleaseSmall, 780,049 B + ghostty-vt-small.wasm.minisig ← from the GitHub `tip` release while 05dcf887 was tip + ghostty-vt-small.wasm.json ← commit, source URL, minisig source, public key, size, sha256 + LICENSE ← Ghostty's MIT licence at the pinned commit + PROVENANCE.md + ghostty-web/ + ghostty.ts ← both engines: Coder (existing) and official (new) + fn-table.ts ← function-table trampoline, borrowed (see below) + ghostty-vt.test.ts ← offline sha256, size and signature check + engine-parity.test.ts, fixtures/ ← both engines on the PTY captures and the #180 payload +src/client/engine.ts ← DEFAULT_ENGINE and the `?engine` override +scripts/update-ghostty-vt.ts ← fetch and verify a new pin (network; run by hand) +``` + +ADR 041's "one binding module" holds: every wasm export call and memory access for both engines is in `ghostty.ts`. `fn-table.ts` receives `__indirect_function_table` from `ghostty.ts` and only adds and recycles entries in it. It is copied from `gkoreli/ghostty-vt-js` at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts` (MIT), with that licence notice and a header naming repo, commit and path. Its notice and Ghostty's licence go into `dist/THIRD_PARTY_LICENSES`. Biome excludes `src/client/vendor/ghostty-vt/` like the vendored TS, since the update script writes the sidecar; the tests in `src/client/vendor/` stay under Biome. + +**Engine interface.** In `ghostty.ts`, `GhosttyTerminal` becomes an interface with today's public method set (lifecycle, render-state reads, `getViewport`/`getLine`, scrollback and grapheme reads, hyperlinks, modes, `hasResponse`/`readResponse`), implemented by `CoderTerminal` (the current class, renamed) and `OfficialTerminal`. `Ghostty.load(path, engine)` returns an engine with `createTerminal(cols, rows, config)` and `createKeyEncoder()`. The renderer, selection, buffer and input handler keep importing `GhosttyTerminal` and `KeyEncoder`; only the renderer changes: `IRenderable` gains an optional `setViewportOffset?`, which `render` calls first (below). The interface gains: + +- `setCellPixelSize(width, height)`: device-pixel cell size, as `kitty.ts`'s `deviceCellSize` computes it today from the renderer's CSS cell metrics and `window.devicePixelRatio` (the ratio `graphics.ts` uses). `terminal.ts` calls it at the points listed under `SIZE` below. A no-op on Coder. +- `resize(cols, rows, cellWidthPx, cellHeightPx)`: today's `resize` with the device-pixel cell size added, so a change of both grid and pixels is one call. Coder ignores the pixel arguments. +- `setViewportOffset(linesFromBottom)`: the terminal's `viewportY`. `renderer.render` calls it first with the `viewportY` it was given, so every render path (the loop, `writeInternal`'s `awaitingEcho` render, font change, scrollbar fade, `index.ts`'s `visibilitychange` repaint) syncs in one place; reads between renders use the last offset. Not implemented on Coder. +- `readEffects(): { bell: boolean; title: string | null }`: bell and title changes since the last write. Coder returns none, and `terminal.ts` keeps its stream scan for BEL and OSC 0/2 only on Coder. +- `answersSizeQueries: boolean`: true on official. The graphics overlay (`graphics.ts`) then stops sending `kitty.ts`'s `sizeReply`, so apps get one reply per query. + +Every existing read keeps the Coder engine's coordinates on both engines, so the renderer, selection and buffer code need no change: `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` use active-screen rows, and `getScrollbackLine(i)` counts from the oldest scrollback row. `terminal.ts` drains `readResponse` after `write`, after `wasmTerm.resize` and `setCellPixelSize`, and after each frame's render, not only after `write`. + +**Binding (official).** + +- Rows: a refresh runs `ghostty_render_state_update`, then `ghostty_render_state_row_iterator_next_dirty` and one `ghostty_render_state_row_get_multi` per dirty row for `ROW_DATA_CELLS_RAW` and `ROW_DATA_VIEWPORT_Y`. Each row is decoded straight into JS-owned `GhosttyCell` objects, with bit positions from the `GhosttyCell` descriptor in `ghostty_type_json` and colours and flags from a per-row style cache (`ghostty_render_state_row_cells_get` `STYLE`, only for a style id not yet seen). Graphemes are read only for cells tagged as graphemes. The JS copy is indexed by viewport row. At the bottom (`viewportY` 0) the viewport is the active screen, so `getViewport`, `getLine(n)` and `isRowDirty(n)` read copy row `n`. +- Refresh and dirty rules. The renderer and the Coder-style reads call `update()` many times per frame (`renderer.ts` `render` through `getCursor`, `needsFullRedraw` and `getLine`; selection through `getLine` once per row) and rely on dirty state persisting until `clearDirty()`/`markClean()`. The binding keeps a generation counter, bumped by `write`, `resize`, a `setCellPixelSize` that calls `ghostty_terminal_resize`, a viewport move in `setViewportOffset`, and `reset`. The viewport moves a page read makes, and its move back, do not bump it, or the page cache would clear itself. `update()` refreshes only when the generation has changed since the last refresh; otherwise it returns the stored dirty state without an export call. So `getLine`, `getViewport` and `getCursor` always reflect the latest write, as they do on Coder, and a fully dirty frame decodes each row once however often `update()` is called. Each refresh ends with `ghostty_render_state_clean`, which clears both the global `DIRTY` and every per-row `ROW_OPTION_DIRTY` (`render.h`: `update` does not clear either layer), so the next refresh decodes only rows changed since. The rows it decoded go into a JS dirty set, and the global state it returned is kept as the highest seen (none, partial, full). Both accumulate until `markClean()` empties them. Renders outside the render loop (`writeInternal`'s `awaitingEcho` render, `index.ts`'s `visibilitychange` repaint, ADR 040's `renderLine`) go through the same `update()`. +- Cursor: `getCursor` reads `DATA_CURSOR_X`/`DATA_CURSOR_Y` (active-screen coordinates), not the render state's viewport cursor. +- Scrollback: `getScrollbackLength` reads `DATA_SCROLLBACK_ROWS` (S below). Writes never leave the view scrolled, but `terminal.ts` `writeInternal` calls `wasmTerm.write` first and `scrollToBottom()` after it (xterm behaviour), and `smooth-scroll.ts` `onOutput` then drops its sub-line offset. So `OfficialTerminal.write` itself moves Ghostty's viewport to `BOTTOM` before `vt_write` whenever it is scrolled, sets its offset to 0 and marks every row dirty; pruning and screen switches then happen with the viewport at the bottom, and any render before `scrollToBottom` (the `awaitingEcho` render) passes a stale `viewportY` that `setViewportOffset` moves back. A scrolled frame (k = `floor(viewportY)` > 0) follows user scrolling, and S changes under it only through resize and reflow. `setViewportOffset` moves Ghostty's viewport with `ghostty_terminal_scroll_viewport` `ROW` to row S − k (`ROW` counts from the top of scrollback, `viewportY` from the bottom) whenever k or S differs from the last move, which also covers a resize while scrolled. The frame is read through the same render-state path, and copy row y maps to `getScrollbackLine(S − k + y)` for y < k and to `getLine(y − k)` for y ≥ k. Those reads are served from the copy. `isRowDirty` returns true for every row while scrolled, since the renderer repaints all rows then anyway. Back at the bottom (`viewportY` 0, including the snap after a write), the binding scrolls Ghostty's viewport with `BOTTOM` and marks every row dirty for the next frame. +- Reads outside the viewport (`getLine(n)` for n ≥ rows − k, scrollback rows above the viewport, smooth scroll's row above the top, selection copy and the buffer API) are read a page at a time through the render state, not cell by cell. On a miss the binding moves Ghostty's viewport with `scroll_viewport` `ROW` to the page of `rows` rows that holds the row, reads the page with `CELLS_RAW` through a second render state, and moves the viewport back before returning: to `BOTTOM` when the offset is 0, to `ROW` S − k otherwise. `scroll_viewport` `ROW` n with n ≥ S snaps to the active screen (`PageList.zig` `scroll`), so a page starts at min(n, S) and rows are mapped from that start. Pages are cached until the generation changes. A render state's update consumes the terminal's dirty state (page and row dirty bits and the terminal-wide full-redraw flags; `render.zig` `beginUpdate` assumes it is the only consumer), so the second state would hide changes from the primary. Before a page read the binding therefore refreshes the primary when the generation has changed, so the primary has captured every pending dirty bit and flag (and the palette) before the second state runs; this ordering is what keeps the primary correct. The reverse also holds: the second state cannot trust dirty bits the primary has already cleared, and `beginUpdate` rebuilds a row only on a pin, screen or size change or a dirty bit (`render.zig`), so a page read at the same row after an in-place write, or after pruning recycled a page at the same address, would return stale cells. The page-read state is therefore freed and recreated whenever the generation has changed, so every miss rebuilds every row it reads. Copying a selection over N rows costs about N / rows page reads plus one decode per row, and smooth scroll's row above the top is a cache hit on most frames. `ghostty_terminal_grid_ref` is kept for single-cell lookups (hyperlink hover, a grapheme outside the copy). Per-cell `grid_ref` costs one lookup plus cell, style and grapheme calls per cell (`grid_ref.h` notes the lookup's cost): a 3,000-row selection at 200 columns would be over 1.8 million export calls, and step 4 raises scrollback to 10,000 lines. +- Callbacks through `fn-table.ts`, installed per terminal and released after `ghostty_terminal_free`. None re-enters `vt_write`; each copies what it needs and returns. + - `WRITE_PTY`: copies the bytes into the queue that `readResponse` drains, the path `terminal.ts` already uses for Coder replies. Replies also arise outside `write` (the mode 2048 report from `ghostty_terminal_resize`), so `terminal.ts` drains at the three points listed above. + - `BELL`, `TITLE_CHANGED`: set flags. The title is read with `DATA_TITLE` after `vt_write` returns, copied, and handed out by `readEffects`. + - `SIZE`: fills `GhosttySizeReportSize` with rows, columns and the cell pixel size Ghostty last received. `ghostty_terminal_resize(cols, rows, cellWidthPx, cellHeightPx)` stores the pixel size, ends synchronized output and, with mode 2048 on, sends a report on every call, even with unchanged dimensions (`terminal.h`). So the binding makes exactly one call per real change, and `terminal.ts` supplies the pixels at three fixed points: + - `open()`, after `createTerminal` and before the first write: `setCellPixelSize` with the initial metrics, so `SIZE` replies never use 0 px. Mode 2048 cannot be on yet, so this sends no report. + - `Terminal.resize()`, which already returns early when columns and rows are unchanged: it computes the pixel size and calls `wasmTerm.resize(cols, rows, w, h)`, one `ghostty_terminal_resize` with the new grid and pixels. + - A new `Terminal.syncCellPixelSize()`, called at the end of `index.ts` `fit()`. Every metrics or DPR change reaches `fit()`: font zoom calls it directly after setting `fontSize`, and the `document.fonts.ready` remeasure and `watchDevicePixelRatio` go through `scheduleFit`. If the grid changed, `resize` has already sent the new pixels and this does nothing; if only the pixels changed, `setCellPixelSize` makes one call at the current grid. + + `setCellPixelSize` returns at once when the size equals the one Ghostty last received. Nothing is flushed from the render loop, so the order of the fit's and the render loop's `requestAnimationFrame` callbacks does not matter. A unit test drives `OfficialTerminal` over a fake wasm exports object through the calls `terminal.ts` and `fit()` make, and asserts one `ghostty_terminal_resize` for a zoom that changes grid and pixels, one for a zoom or DPR change that keeps the grid, and none for repeated fits. The refresh, scrolled-view and page-read logic is tested on the real wasm in `engine-parity.test.ts`, against the Coder engine: scrolled reads (`getLine`, `getScrollbackLine`, rendered rows) at several k, a write while scrolled, a resize while scrolled, a write that dirties active rows followed by a page read and then a render, and a scrolled page read, an in-place write, a render with the stale `viewportY`, then the same page read again. + - `DEVICE_ATTRIBUTES`: primary conformance 62 with feature 22, secondary type 1, firmware 10, ROM 0, tertiary unit id 0. This gives `ESC[?62;22c` and `ESC[>1;10;0c`. + - `ENQUIRY`, `XTVERSION`, clipboard, notification and the other effects are not installed (ADR 041 future work). +- Setup: `MODE_DEFAULT` with mode 2027 on, so RIS keeps grapheme clustering. The theme's foreground, background, cursor and palette are set through `COLOR_FOREGROUND`, `COLOR_BACKGROUND`, `COLOR_CURSOR` and `COLOR_PALETTE`. The colour values in `GhosttyTerminalConfig` cannot be used as they are: `terminal.ts` `parseColorToHex` encodes an unset colour as 0 ("use default" in `types.ts`), and the default theme's background `#000000` (`src/config.ts`) also becomes 0. `buildWasmConfig` therefore adds a mask of the theme keys that parsed (foreground, background, cursor and the 16 palette entries), which Coder ignores. A key in the mask is set as given, so `#000000` is black. An unset foreground, background or cursor keeps Ghostty's default, and unset palette entries and entries 16–255 come from `COLOR_PALETTE_DEFAULT`. +- Scrollback budget unchanged: the same `Math.ceil(config.scrollback / 80)` goes to `SCROLLBACK_MAX_BYTES`. `scrollback: 0`, unlimited on the Coder wasm, becomes 32 MiB, because 0 bytes disables scrollback and the binding never passes NULL for both limits. `SCROLLBACK_MAX_LINES` stays unset until step 4. +- Keys: the official `KeyEncoder` translates ghostty-web's `Key` and `KeyEncoderOption` values to the official `GhosttyKey` and `GhosttyKeyEncoderOption` values from `ghostty_type_json`. It matches by name, with a fixed rename table for the 44 keys whose names differ (e.g. `GRAVE` → `BACKQUOTE`, `ZERO` → `DIGIT_0`, `UP` → `ARROW_UP`, `KP_*` → `NUMPAD_*`) and for `KITTY_KEYBOARD_FLAGS` → `KITTY_FLAGS`. Twenty-nine keys have different numbers in the two builds, so no value is passed through untranslated. It allocates with `ghostty_wasm_alloc` and `ghostty_wasm_free`. + +**Served wasm.** `scripts/build.ts` copies `ghostty-vt-small.wasm` to `dist/`. The server serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`, resolved from `dist/` or `src/client/vendor/ghostty-vt/` like `/ghostty-vt.wasm` (ADR 041 step 1). The client fetches only the selected engine's wasm. + +**Selection.** `src/client/engine.ts` exports `DEFAULT_ENGINE = 'coder'`, compiled into `client-browser.js`. `?engine=coder` or `?engine=official` in the page URL overrides it for that tab; any other value uses the default. Nothing is stored, and a reload keeps whatever the URL says. + +**Verification, offline.** `ghostty-vt.test.ts` (part of `bun test`) checks the committed wasm against its sidecar (size and sha256) and verifies the `.minisig` with `node:crypto`: the Ed25519 signature over the wasm's BLAKE2b-512 hash and the global signature over the trusted comment, both against Ghostty's key from `PACKAGING.md` (`RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV`, key id `F5787DDB42320225`). The key is a constant in the test, and the test also asserts that the sidecar records the same key. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar. Neither step uses the network. `scripts/update-ghostty-vt.ts --commit ` is the only networked step: it downloads from `tip.files.ghostty.org//` and the GitHub `tip` release, checks that both give the same sha256 and that `tip` points at `` before and after the download, verifies the signature, and writes the wasm, `.minisig` and sidecar. `--verify` re-runs the offline check. + +**ReleaseSmall.** Vendor `ghostty-vt-small.wasm`. ReleaseFast's 0–8% throughput gain is about 0.01 s on a 20 MiB `cat`. It does not justify 277,189 more bytes per pin in git and per page load (the server does not compress), or re-running the spike's parity, #180 and memory results, which were all measured on ReleaseSmall. + +**Acceptance matrix before the default flips to `official`.** Every check runs on `?engine=official`. Rows marked "vs Coder" compare the result with the Coder engine on the same input; rows marked "official" check replies the Coder wasm does not give. Chrome and Firefox are automated headless, each with its own profile. Safari is checked by hand; safaridriver is not enabled. The results are recorded in this ADR's "Acceptance record" section, with date, browser versions and Ghostty pin, in the change that flips `DEFAULT_ENGINE`. + +| Area | Kind | Check | +|---|---|---| +| Browsers | vs Coder | Load, type, render, select and copy, paste, IME, wheel and mouse apps in Chrome, Firefox and Safari | +| Resize/reflow | vs Coder | Normal-screen reflow of wrapped lines when shrinking and growing; alternate screen; font zoom and DPR change re-report cell pixels | +| Round trips | official | DA1, DA2, DA3; DSR `5n`/`6n`; XTWINOPS `14/16/18/19 t` answered once, in device pixels; a mode 2048 report delivered on resize, font zoom and DPR change, exactly once each and without waiting for PTY output; OSC 0/2 title; OSC 4/10/11 against the theme, including OSC 11 replying `rgb:0000/0000/0000` on the default config; DECRQM 2027 reports set | +| #180 | official | The ADR 041 stress payload: 0 stale rows over 300 repetitions on the official engine, in Bun (`engine-parity.test.ts`) and in Chrome | +| Parity | vs Coder | ls, vim, nvim, less, top, SGR/256/truecolor, Unicode and `seq 5000` captures match cell for cell (text, fg, bg, flags, width, cursor) with 512-byte and 1-byte writes; the Hindi conjunct in the Unicode capture is the one listed difference | +| Scrollback | vs Coder | Rows kept at the default config are at least 90% of the Coder engine's, on the same plain and mixed payloads at 120×40 and 200×60 (more rows pass) | +| Scrolled view | vs Coder | Scrolled up with no output arriving: rows, cursor, selection copy across the scrollback/screen boundary and smooth scroll match the Coder engine. Resize and reflow while scrolled match the Coder engine. Output arriving while scrolled jumps to the bottom with every row repainted, as on Coder | +| Selection copy | vs Coder | Selecting all of a 10,000-row scrollback at 200 columns and copying takes at most 1.5× the Coder engine's time on the same rows, in Chrome and Firefox | +| Screenshots | vs Coder | A/B pixel diff of a static scene between engines, with differences explained | + +The captures are re-recorded from a clean `HOME` before they are committed as fixtures, so no local paths are committed. + +**Rollback.** Before the flip, the official engine is reached only through `?engine=official`, and reverting the commit removes it. After the flip, `?engine=coder` falls back per tab, and setting `DEFAULT_ENGINE` back is a one-line change. The Coder engine stays for at least one release after the flip (ADR 041 step 5). + +### Considered Options + +- **ReleaseSmall (chosen)** — smallest download; parity, memory and #180 already measured on it. +- **ReleaseFast** — 0–8% more `vt_write` throughput for 35% more bytes (see Context). +- **Official binding in its own module** (e.g. `ghostty-vt.ts`) — smaller files, but splits wasm access across two modules, against ADR 041's one-binding rule. +- **Engine chosen in config** (`GET /api/config`) — needs a config key that step 5 deletes, and a server change to try an engine. A URL parameter is per tab and needs neither. +- **Answer size queries in `kitty.ts` on both engines** and leave `OPT_SIZE` unset — then mode 2048 and `19 t` stay unanswered, and step 5 could not delete the `kitty.ts` replies. +- **Read rows through `grid_ref`**, every row or only those outside the viewport — the header marks `ghostty_terminal_grid_ref` as not for performance-sensitive use, and it works one cell at a time. The render state's dirty rows and `CELLS_RAW` are the bulk path that the spike's 0.17 ms full frame and 0.005 ms typing frame were measured on, and page reads reuse it for selection copy and the buffer API. +- **Selection text from Ghostty's formatter** (`ghostty_formatter_format` with a selection) — one call per copy, but selection and buffer code would change to use it, and they read cells, not text. Page reads keep them as they are. + +## Consequences + +- `?engine=official` runs the official wasm with real effects: bell and title from Ghostty, query replies through `WRITE_PTY`, and size replies from Ghostty with device-pixel cells. +- Apps on the official engine see new replies: DA3, XTWINOPS `19 t`, mode 2048 reports, OSC 4/10/11 colour reports, DECRQM and XTVERSION `libghostty`. DA1 and DA2 are unchanged. ENQ stays silent: it needs the `ENQUIRY` callback, which this step does not install, although ADR 041's Consequences list ENQ among the new replies. +- Scrollback under the default config holds −7% to +25% of the Coder engine's rows, depending on size and payload (see Context), until step 4. +- The repo carries 780,049 more bytes of wasm, plus the signature, the sidecar, `fn-table.ts` and the fixtures. Pages using the Coder engine download nothing new. +- `ghostty.ts` holds two engines until step 5 deletes the Coder one. +- The default stays `coder`, and nothing changes for users who do not add `?engine=official`. + +## Acceptance record + +Not yet run. Filled in by the change that flips `DEFAULT_ENGINE` to `official`. + +## Related Decisions + +- [ADR 012 — Static asset build](012.client.static-assets.md) +- [ADR 032 — Kitty graphics via overlay canvas](032.client.kitty-graphics-overlay.md) +- [ADR 041 — Own the terminal layer on official libghostty-vt](041.client.libghostty-vt.md) diff --git a/docs/specs/client.md b/docs/specs/client.md index ec2db17..281946c 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -27,7 +27,7 @@ dist/ `client.html` loads only `client-browser.js`. The npm `ghostty-web.js` build inlined the wasm as a base64 `data:` URL, so the browser never fetched `/ghostty-vt.wasm`. The vendored TS is bundled without inlining, so the client now fetches it on every page load through an explicit `Ghostty.load('/ghostty-vt.wasm')`, and the page depends on the server serving that route as `application/wasm`. The server resolves the wasm from `dist/` (npm install) or the vendored path (local dev); nothing comes from `node_modules` ([ADR 041](../adrs/041.client.libghostty-vt.md)). -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): the wasm stays the Coder-patched build until the official libghostty-vt wasm, committed under `src/client/vendor/ghostty-vt/`, becomes the default and the Coder engine is removed. +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): `dist/ghostty-vt-small.wasm`, the official libghostty-vt wasm (ReleaseSmall) copied from `src/client/vendor/ghostty-vt/`, is served at `GET /ghostty-vt-small.wasm`. The client fetches only the selected engine's wasm: the build-time default (`coder` until the acceptance matrix is recorded) or `?engine=coder|official` in the page URL. The Coder wasm goes away when the Coder engine is removed. ## Source layout @@ -44,12 +44,17 @@ src/client/ The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): ``` -src/client/vendor/ - ghostty-web/ ← all wasm calls in ghostty.ts; Coder wasm + sidecar until the Coder engine is removed - ghostty-vt/ ← official ghostty-vt-small.wasm + .minisig + provenance sidecar (commit, URL, sha256) +src/client/ + engine.ts ← DEFAULT_ENGINE and the ?engine=coder|official override + vendor/ + ghostty-web/ ← all wasm calls for both engines in ghostty.ts; fn-table.ts (borrowed trampoline); + Coder wasm + sidecar until the Coder engine is removed + ghostty-vt/ ← official ghostty-vt-small.wasm + .minisig + sidecar (commit, URL, size, sha256, key), + Ghostty LICENSE, PROVENANCE.md; checked offline by vendor/ghostty-vt.test.ts +scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step) ``` ## Page @@ -187,7 +192,8 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | -| Official libghostty-vt wasm | The terminal runs on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked) with current Ghostty parsing and query replies; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | +| Official engine by default | The default flips to `official` once ADR 042's acceptance matrix (Chrome, Firefox, Safari; resize/reflow; query round trips; #180; parity) is recorded; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | From 341507873a950273cb882ac963878667c2bc68ac Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 00:18:07 -0400 Subject: [PATCH 012/295] build(client): vendor the official libghostty-vt wasm Vendor Ghostty's official ghostty-vt-small.wasm (05dcf887, ReleaseSmall, 780,049 B) with its .minisig, sidecar and LICENSE, per ADR 042. bun test verifies size, sha256 and the minisign signature offline; the build refuses a wasm that does not match its sidecar. The server serves it at GET /ghostty-vt-small.wasm; scripts/update-ghostty-vt.ts fetches new pins. --- biome.json | 8 +- docs/adrs/042.client.official-vt-engine.md | 4 +- docs/specs/client.md | 18 +- scripts/build.ts | 30 +- scripts/update-ghostty-vt.ts | 362 ++++++++++++++++++ src/client/vendor/ghostty-vt.test.ts | 105 +++++ src/client/vendor/ghostty-vt/LICENSE | 21 + src/client/vendor/ghostty-vt/PROVENANCE.md | 11 + .../vendor/ghostty-vt/ghostty-vt-small.wasm | Bin 0 -> 780049 bytes .../ghostty-vt/ghostty-vt-small.wasm.json | 39 ++ .../ghostty-vt/ghostty-vt-small.wasm.minisig | 4 + src/server/index.ts | 6 +- src/server/routes.test.ts | 22 ++ src/server/routes.ts | 7 +- src/server/static.test.ts | 49 ++- src/server/static.ts | 39 +- src/version.test.ts | 6 +- 17 files changed, 706 insertions(+), 25 deletions(-) create mode 100644 scripts/update-ghostty-vt.ts create mode 100644 src/client/vendor/ghostty-vt.test.ts create mode 100644 src/client/vendor/ghostty-vt/LICENSE create mode 100644 src/client/vendor/ghostty-vt/PROVENANCE.md create mode 100644 src/client/vendor/ghostty-vt/ghostty-vt-small.wasm create mode 100644 src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.json create mode 100644 src/client/vendor/ghostty-vt/ghostty-vt-small.wasm.minisig diff --git a/biome.json b/biome.json index dbac7e5..cc68b63 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,13 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/node_modules", "!**/dist", "!src/client/vendor/ghostty-web"] + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!src/client/vendor/ghostty-web", + "!src/client/vendor/ghostty-vt" + ] }, "formatter": { "enabled": true, diff --git a/docs/adrs/042.client.official-vt-engine.md b/docs/adrs/042.client.official-vt-engine.md index 4d20eba..12aa8ba 100644 --- a/docs/adrs/042.client.official-vt-engine.md +++ b/docs/adrs/042.client.official-vt-engine.md @@ -103,11 +103,11 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the - Scrollback budget unchanged: the same `Math.ceil(config.scrollback / 80)` goes to `SCROLLBACK_MAX_BYTES`. `scrollback: 0`, unlimited on the Coder wasm, becomes 32 MiB, because 0 bytes disables scrollback and the binding never passes NULL for both limits. `SCROLLBACK_MAX_LINES` stays unset until step 4. - Keys: the official `KeyEncoder` translates ghostty-web's `Key` and `KeyEncoderOption` values to the official `GhosttyKey` and `GhosttyKeyEncoderOption` values from `ghostty_type_json`. It matches by name, with a fixed rename table for the 44 keys whose names differ (e.g. `GRAVE` → `BACKQUOTE`, `ZERO` → `DIGIT_0`, `UP` → `ARROW_UP`, `KP_*` → `NUMPAD_*`) and for `KITTY_KEYBOARD_FLAGS` → `KITTY_FLAGS`. Twenty-nine keys have different numbers in the two builds, so no value is passed through untranslated. It allocates with `ghostty_wasm_alloc` and `ghostty_wasm_free`. -**Served wasm.** `scripts/build.ts` copies `ghostty-vt-small.wasm` to `dist/`. The server serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`, resolved from `dist/` or `src/client/vendor/ghostty-vt/` like `/ghostty-vt.wasm` (ADR 041 step 1). The client fetches only the selected engine's wasm. +**Served wasm.** `scripts/build.ts` copies `ghostty-vt-small.wasm` to `dist/`. The server serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`, resolved from `dist/` or `src/client/vendor/ghostty-vt/` like `/ghostty-vt.wasm` (ADR 041 step 1); like that wasm, the server does not start without it. The client fetches only the selected engine's wasm. **Selection.** `src/client/engine.ts` exports `DEFAULT_ENGINE = 'coder'`, compiled into `client-browser.js`. `?engine=coder` or `?engine=official` in the page URL overrides it for that tab; any other value uses the default. Nothing is stored, and a reload keeps whatever the URL says. -**Verification, offline.** `ghostty-vt.test.ts` (part of `bun test`) checks the committed wasm against its sidecar (size and sha256) and verifies the `.minisig` with `node:crypto`: the Ed25519 signature over the wasm's BLAKE2b-512 hash and the global signature over the trusted comment, both against Ghostty's key from `PACKAGING.md` (`RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV`, key id `F5787DDB42320225`). The key is a constant in the test, and the test also asserts that the sidecar records the same key. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar. Neither step uses the network. `scripts/update-ghostty-vt.ts --commit ` is the only networked step: it downloads from `tip.files.ghostty.org//` and the GitHub `tip` release, checks that both give the same sha256 and that `tip` points at `` before and after the download, verifies the signature, and writes the wasm, `.minisig` and sidecar. `--verify` re-runs the offline check. +**Verification, offline.** `ghostty-vt.test.ts` (part of `bun test`) checks the committed wasm against its sidecar (size and sha256) and verifies the `.minisig` with `node:crypto`: the Ed25519 signature over the wasm's BLAKE2b-512 hash and the global signature over the trusted comment, both against Ghostty's key from `PACKAGING.md` (`RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV`, key id `F5787DDB42320225`). The key is a constant in the test, and the test also asserts that the sidecar records the same key. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar. Neither step uses the network. `scripts/update-ghostty-vt.ts --commit ` is the only networked step: it downloads from `tip.files.ghostty.org//` and the GitHub `tip` release, checks that both give the same sha256 and that `tip` points at `` before and after the download, verifies the signature, and writes the wasm, `.minisig`, sidecar and Ghostty's `LICENSE` at that commit. `--verify` re-runs the offline check. The minisign verifier is exported from the script and imported by the test, so the script and the test check the same way; the test holds its own copy of the key. **ReleaseSmall.** Vendor `ghostty-vt-small.wasm`. ReleaseFast's 0–8% throughput gain is about 0.01 s on a 20 MiB `cat`. It does not justify 277,189 more bytes per pin in git and per page load (the server does not compress), or re-running the spike's parity, #180 and memory results, which were all measured on ReleaseSmall. diff --git a/docs/specs/client.md b/docs/specs/client.md index 281946c..f4d0d53 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,6 +1,6 @@ # SPEC: Client -**Last Updated:** 2026-10-12 +**Last Updated:** 2026-10-13 --- @@ -22,12 +22,17 @@ dist/ client-browser.js ← compiled from src/client/index.ts, with the vendored ghostty-web TS bundled in client.css ← copied from src/client/index.css ghostty-vt.wasm ← copied from src/client/vendor/ghostty-web/; served at GET /ghostty-vt.wasm + ghostty-vt-small.wasm ← official libghostty-vt (ReleaseSmall), copied from src/client/vendor/ghostty-vt/ + only if it matches its sidecar's size and sha256; served at GET /ghostty-vt-small.wasm THIRD_PARTY_LICENSES ← Coder's ghostty-web and Ghostty's MIT notices, from src/client/vendor/ghostty-web/ + and src/client/vendor/ghostty-vt/ ``` `client.html` loads only `client-browser.js`. The npm `ghostty-web.js` build inlined the wasm as a base64 `data:` URL, so the browser never fetched `/ghostty-vt.wasm`. The vendored TS is bundled without inlining, so the client now fetches it on every page load through an explicit `Ghostty.load('/ghostty-vt.wasm')`, and the page depends on the server serving that route as `application/wasm`. The server resolves the wasm from `dist/` (npm install) or the vendored path (local dev); nothing comes from `node_modules` ([ADR 041](../adrs/041.client.libghostty-vt.md)). -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): `dist/ghostty-vt-small.wasm`, the official libghostty-vt wasm (ReleaseSmall) copied from `src/client/vendor/ghostty-vt/`, is served at `GET /ghostty-vt-small.wasm`. The client fetches only the selected engine's wasm: the build-time default (`coder` until the acceptance matrix is recorded) or `?engine=coder|official` in the page URL. The Coder wasm goes away when the Coder engine is removed. +The server resolves `ghostty-vt-small.wasm`, the official libghostty-vt wasm ([ADR 042](../adrs/042.client.official-vt-engine.md)), the same way and serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`. Nothing loads it yet. + +Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): the client fetches only the selected engine's wasm: the build-time default (`coder` until the acceptance matrix is recorded) or `?engine=coder|official` in the page URL. The Coder wasm goes away when the Coder engine is removed. ## Source layout @@ -40,9 +45,13 @@ src/client/ ghostty-web/ ← coder/ghostty-web lib/ at 1858a59 (non-test TS, unchanged), MIT LICENSE, PROVENANCE.md; Ghostty's LICENSE.ghostty; Coder ghostty-vt.wasm + ghostty-vt.wasm.json sidecar (npm version, Ghostty commit + patch, licence, size, sha256), checked by vendor/ghostty-web.test.ts + ghostty-vt/ ← official ghostty-vt-small.wasm (Ghostty 05dcf887, ReleaseSmall) + .minisig + sidecar + (commit, URL, size, sha256, signature, key, licence), Ghostty LICENSE, PROVENANCE.md; + checked offline by vendor/ghostty-vt.test.ts (size, sha256, minisign signature) +scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step); --verify re-checks offline ``` -The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. +The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. `vendor/ghostty-vt/` is excluded from Biome too, since the update script writes its sidecar; nothing in it is edited. Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): @@ -52,9 +61,6 @@ src/client/ vendor/ ghostty-web/ ← all wasm calls for both engines in ghostty.ts; fn-table.ts (borrowed trampoline); Coder wasm + sidecar until the Coder engine is removed - ghostty-vt/ ← official ghostty-vt-small.wasm + .minisig + sidecar (commit, URL, size, sha256, key), - Ghostty LICENSE, PROVENANCE.md; checked offline by vendor/ghostty-vt.test.ts -scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step) ``` ## Page diff --git a/scripts/build.ts b/scripts/build.ts index dd72608..b3faf65 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -42,8 +43,26 @@ fs.copyFileSync( path.resolve('./dist/ghostty-vt.wasm'), ); +// The official libghostty-vt wasm (ADR 042) ships the same way. Nothing is fetched: the +// committed wasm is copied only if it matches its sidecar's size and sha256. +const officialDir = './src/client/vendor/ghostty-vt'; +const officialSidecar = JSON.parse( + fs.readFileSync(path.resolve(officialDir, 'ghostty-vt-small.wasm.json'), 'utf8'), +) as { file: string; size: number; sha256: string }; +const officialWasm = fs.readFileSync(path.resolve(officialDir, officialSidecar.file)); +const officialSha256 = createHash('sha256').update(officialWasm).digest('hex'); +if (officialWasm.byteLength !== officialSidecar.size || officialSha256 !== officialSidecar.sha256) { + console.error( + `${officialDir}/${officialSidecar.file} does not match its sidecar: ` + + `${officialWasm.byteLength} bytes, sha256 ${officialSha256}; ` + + `expected ${officialSidecar.size} bytes, sha256 ${officialSidecar.sha256}`, + ); + process.exit(1); +} +fs.writeFileSync(path.resolve('./dist', officialSidecar.file), officialWasm); + // The vendored TS has no licence headers, so nothing survives minification; the -// MIT notices for the code in client-browser.js and ghostty-vt.wasm ship here instead. +// MIT notices for the code in client-browser.js and both wasm files ship here instead. const vendorDir = './src/client/vendor/ghostty-web'; fs.writeFileSync( path.resolve('./dist/THIRD_PARTY_LICENSES'), @@ -60,8 +79,15 @@ fs.writeFileSync( '', fs.readFileSync(path.resolve(vendorDir, 'LICENSE.ghostty'), 'utf8').trim(), '', + '---', + '', + `Ghostty libghostty-vt (${officialSidecar.file}, unmodified official build)`, + 'https://github.com/ghostty-org/ghostty', + '', + fs.readFileSync(path.resolve(officialDir, 'LICENSE'), 'utf8').trim(), + '', ].join('\n'), ); -const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 4; +const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 5; console.log(`✓ Build complete (${totalFiles} files)`); diff --git a/scripts/update-ghostty-vt.ts b/scripts/update-ghostty-vt.ts new file mode 100644 index 0000000..301aec2 --- /dev/null +++ b/scripts/update-ghostty-vt.ts @@ -0,0 +1,362 @@ +/** + * Vendors and verifies Ghostty's official libghostty-vt wasm (ReleaseSmall) into + * src/client/vendor/ghostty-vt/ (ADR 041, ADR 042). + * + * bun scripts/update-ghostty-vt.ts --commit fetch and verify a new pin (network) + * bun scripts/update-ghostty-vt.ts --verify re-check the committed pin (offline) + * + * Where Ghostty publishes the wasm (.github/workflows/release-tip.yml, build-lib-vt-wasm): + * - tip.files.ghostty.org//ghostty-vt-small.wasm: per commit, unsigned. + * - The GitHub `tip` prerelease: the current tip's wasm and .minisig, overwritten by every + * tip build. A signature can only be captured while the pinned commit is `tip`. + * + * The wasm does not record its commit (`ghostty_build_info` reports 0.1.0-dev), so the + * binding to is: `tip` points at before and after the download, and the GitHub + * asset has the same sha256 as the commit-addressed one. The signature by Ghostty's release + * key (PACKAGING.md) says Ghostty's CI built those bytes. Both are recorded in the sidecar. + */ +import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** Ghostty's minisign release key, from Ghostty's PACKAGING.md. */ +export const GHOSTTY_MINISIGN_PUBKEY = 'RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV'; +export const WASM_FILE = 'ghostty-vt-small.wasm'; +export const VENDOR_DIR = path.resolve( + import.meta.dir, + '..', + 'src', + 'client', + 'vendor', + 'ghostty-vt', +); + +const REPO = 'ghostty-org/ghostty'; +const R2 = 'https://tip.files.ghostty.org'; +const GH_TIP = `https://github.com/${REPO}/releases/download/tip`; +const LICENSE_NOTE = 'MIT (Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors; LICENSE)'; + +/** Exports the binding needs; a pin without them is refused before it is written. */ +const REQUIRED_EXPORTS = [ + 'memory', + '__indirect_function_table', + 'ghostty_type_json', + 'ghostty_wasm_alloc', + 'ghostty_wasm_free', + 'ghostty_build_info', + 'ghostty_terminal_new', + 'ghostty_terminal_free', + 'ghostty_terminal_vt_write', + 'ghostty_terminal_resize', + 'ghostty_terminal_scroll_viewport', + 'ghostty_render_state_new', + 'ghostty_render_state_update', + 'ghostty_key_encoder_encode', +]; + +export interface MinisigResult { + /** Key id as minisign prints it (big-endian hex). */ + keyId: string; + /** `ED`: signature over BLAKE2b-512 of the file; `Ed`: over the file itself. */ + algorithm: 'Ed' | 'ED'; + trustedComment: string; + timestamp?: number; + file?: string; +} + +/** The JSON sidecar written next to the wasm. */ +export interface Sidecar { + repo: string; + commit: string; + commitDate?: string; + url: string; + file: string; + variant: 'ReleaseSmall'; + size: number; + sha256: string; + minisig: string; + minisigSource: string; + publicKey: string; + signature: { + keyId: string; + algorithm: string; + trustedComment: string; + signedAt?: string; + }; + verifiedAt: string; + license: string; + wasm: Awaited>; +} + +export function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Verifies a minisign signature over `data`: the Ed25519 signature (over BLAKE2b-512 of the + * data for `ED`) and the global signature over the trusted comment, both against `publicKey`. + * + * @throws If the signature, its key id or its trusted comment does not verify. + */ +export function verifyMinisign( + data: Uint8Array, + minisigText: string, + publicKey: string, +): MinisigResult { + const pk = Buffer.from(publicKey, 'base64'); + if (pk.length !== 42 || pk.toString('latin1', 0, 2) !== 'Ed') { + throw new Error('minisign: bad public key'); + } + const pkKeyId = pk.subarray(2, 10); + + const lines = minisigText.replace(/\r/g, '').split('\n'); + if (!lines[0]?.startsWith('untrusted comment:')) { + throw new Error('minisign: missing untrusted comment'); + } + const sig = Buffer.from(lines[1] ?? '', 'base64'); + if (sig.length !== 74) throw new Error(`minisign: signature is ${sig.length} bytes, want 74`); + const algorithm = sig.toString('latin1', 0, 2); + if (algorithm !== 'Ed' && algorithm !== 'ED') { + throw new Error(`minisign: unknown algorithm ${algorithm}`); + } + const keyId = sig.subarray(2, 10); + const sigBytes = sig.subarray(10, 74); + const tcPrefix = 'trusted comment: '; + if (!lines[2]?.startsWith(tcPrefix)) throw new Error('minisign: missing trusted comment'); + const trustedComment = lines[2].slice(tcPrefix.length); + const globalSig = Buffer.from(lines[3] ?? '', 'base64'); + if (globalSig.length !== 64) throw new Error('minisign: bad global signature'); + + const hexId = (b: Uint8Array) => Buffer.from(b).reverse().toString('hex').toUpperCase(); + if (!keyId.equals(pkKeyId)) { + throw new Error(`minisign: signed by key ${hexId(keyId)}, expected ${hexId(pkKeyId)}`); + } + // Raw Ed25519 key in an SPKI DER wrapper for node:crypto. + const key = createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), pk.subarray(10, 42)]), + format: 'der', + type: 'spki', + }); + const message = + algorithm === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data); + if (!edVerify(null, message, key, sigBytes)) { + throw new Error('minisign: file signature is invalid'); + } + const signedComment = Buffer.concat([sigBytes, Buffer.from(trustedComment, 'utf8')]); + if (!edVerify(null, signedComment, key, globalSig)) { + throw new Error('minisign: trusted comment signature is invalid'); + } + const ts = /timestamp:(\d+)/.exec(trustedComment)?.[1]; + return { + keyId: hexId(keyId), + algorithm, + trustedComment, + timestamp: ts ? Number(ts) : undefined, + file: /file:([^\t]+)/.exec(trustedComment)?.[1], + }; +} + +/** + * Instantiates the wasm, checks the exports the binding needs and reads `ghostty_build_info`. + * + * @throws If the module does not validate or lacks a required export. + */ +export async function inspectWasm(bytes: Uint8Array) { + if (!WebAssembly.validate(bytes)) throw new Error('wasm: WebAssembly.validate failed'); + const mod = new WebAssembly.Module(bytes); + const exports = WebAssembly.Module.exports(mod); + const names = new Set(exports.map((e) => e.name)); + const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n)); + if (missing.length) throw new Error(`wasm: missing exports ${missing.join(', ')}`); + const instance = await WebAssembly.instantiate(mod, {}); + const ex = instance.exports as { + memory: WebAssembly.Memory; + ghostty_wasm_alloc(len: number): number; + ghostty_wasm_free(ptr: number, len: number): void; + ghostty_build_info(id: number, out: number): number; + }; + const out = ex.ghostty_wasm_alloc(16); + const query = (id: number) => { + const rc = ex.ghostty_build_info(id, out); + if (rc !== 0) throw new Error(`wasm: ghostty_build_info(${id}) returned ${rc}`); + return new DataView(ex.memory.buffer); + }; + const str = (id: number) => { + const view = query(id); + const ptr = view.getUint32(out, true); + const len = view.getUint32(out + 4, true); + return new TextDecoder().decode(new Uint8Array(ex.memory.buffer, ptr, len)); + }; + const bool = (id: number) => query(id).getUint8(out) !== 0; + const optimize = query(4).getUint32(out, true); + const buildInfo = { + version: str(5), + versionBuild: str(10), + optimize: ['Debug', 'ReleaseSafe', 'ReleaseSmall', 'ReleaseFast'][optimize] ?? String(optimize), + simd: bool(1), + kittyGraphics: bool(2), + tmuxControlMode: bool(3), + }; + ex.ghostty_wasm_free(out, 16); + return { + imports: WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}:${i.kind}`), + exportCount: exports.length, + functionExports: exports.filter((e) => e.kind === 'function').length, + nonFunctionExports: exports + .filter((e) => e.kind !== 'function') + .map((e) => `${e.name}:${e.kind}`), + initialMemoryBytes: ex.memory.buffer.byteLength, + buildInfo, + }; +} + +/** + * Re-checks a vendored pin offline: size, sha256, signature against `publicKey`, and the + * signed file name. + * + * @throws On any mismatch. + */ +export function verifyPinned( + dir: string, + publicKey: string, +): { sidecar: Sidecar; sig: MinisigResult } { + const sidecar = JSON.parse( + fs.readFileSync(path.join(dir, `${WASM_FILE}.json`), 'utf8'), + ) as Sidecar; + const bytes = new Uint8Array(fs.readFileSync(path.join(dir, sidecar.file))); + if (bytes.byteLength !== sidecar.size) { + throw new Error(`${sidecar.file}: size ${bytes.byteLength} != sidecar ${sidecar.size}`); + } + const hash = sha256(bytes); + if (hash !== sidecar.sha256) + throw new Error(`${sidecar.file}: sha256 ${hash} != sidecar ${sidecar.sha256}`); + const sig = verifyMinisign( + bytes, + fs.readFileSync(path.join(dir, sidecar.minisig), 'utf8'), + publicKey, + ); + if (sig.file !== sidecar.file) + throw new Error(`signature is for ${sig.file}, not ${sidecar.file}`); + return { sidecar, sig }; +} + +async function get(url: string): Promise> { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +async function gh(apiPath: string): Promise { + const headers: Record = { accept: 'application/vnd.github+json' }; + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (token) headers.authorization = `Bearer ${token}`; + const res = await fetch(`https://api.github.com/repos/${REPO}/${apiPath}`, { headers }); + if (!res.ok) throw new Error(`GitHub API ${apiPath}: ${res.status}`); + return (await res.json()) as T; +} + +interface GitRef { + object: { type: string; sha: string }; +} + +async function tipCommit(): Promise { + const ref = await gh('git/ref/tags/tip'); + if (ref.object.type === 'commit') return ref.object.sha; + return (await gh(`git/tags/${ref.object.sha}`)).object.sha; +} + +async function update(commit: string): Promise { + if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error('--commit needs a full 40-character sha'); + const tipBefore = await tipCommit(); + if (tipBefore !== commit) { + throw new Error( + `${commit} is not the current tip (${tipBefore}); its .minisig is only published while it is tip`, + ); + } + const url = `${R2}/${commit}/${WASM_FILE}`; + const bytes = await get(url); + const hash = sha256(bytes); + const ghBytes = await get(`${GH_TIP}/${WASM_FILE}`); + const minisig = new TextDecoder().decode(await get(`${GH_TIP}/${WASM_FILE}.minisig`)); + const license = await get(`https://raw.githubusercontent.com/${REPO}/${commit}/LICENSE`); + const tipAfter = await tipCommit(); + if (tipAfter !== commit) throw new Error(`tip moved to ${tipAfter} during the download; rerun`); + if (sha256(ghBytes) !== hash) { + throw new Error(`GitHub tip asset sha256 ${sha256(ghBytes)} != ${url} ${hash}; rerun later`); + } + const release = await gh<{ assets?: { name: string; digest?: string }[] }>('releases/tags/tip'); + const digest = release.assets?.find((a) => a.name === WASM_FILE)?.digest?.replace(/^sha256:/, ''); + if (digest && digest !== hash) throw new Error(`GitHub asset digest ${digest} != ${hash}`); + + const sig = verifyMinisign(bytes, minisig, GHOSTTY_MINISIGN_PUBKEY); + if (sig.file !== WASM_FILE) throw new Error(`signature is for ${sig.file}, not ${WASM_FILE}`); + const wasm = await inspectWasm(bytes); + if (wasm.buildInfo.optimize !== 'ReleaseSmall') { + throw new Error(`${WASM_FILE} is ${wasm.buildInfo.optimize}, not ReleaseSmall`); + } + const commitInfo = await gh<{ commit?: { committer?: { date?: string } } }>( + `commits/${commit}`, + ).catch(() => undefined); + + const sidecar: Sidecar = { + repo: `https://github.com/${REPO}`, + commit, + commitDate: commitInfo?.commit?.committer?.date, + url, + file: WASM_FILE, + variant: 'ReleaseSmall', + size: bytes.byteLength, + sha256: hash, + minisig: `${WASM_FILE}.minisig`, + minisigSource: `${GH_TIP}/${WASM_FILE}.minisig (tip == ${commit})`, + publicKey: GHOSTTY_MINISIGN_PUBKEY, + signature: { + keyId: sig.keyId, + algorithm: sig.algorithm, + trustedComment: sig.trustedComment, + signedAt: sig.timestamp ? new Date(sig.timestamp * 1000).toISOString() : undefined, + }, + verifiedAt: new Date().toISOString(), + license: LICENSE_NOTE, + wasm, + }; + fs.mkdirSync(VENDOR_DIR, { recursive: true }); + fs.writeFileSync(path.join(VENDOR_DIR, WASM_FILE), bytes); + fs.writeFileSync(path.join(VENDOR_DIR, `${WASM_FILE}.minisig`), minisig); + fs.writeFileSync(path.join(VENDOR_DIR, 'LICENSE'), license); + fs.writeFileSync( + path.join(VENDOR_DIR, `${WASM_FILE}.json`), + `${JSON.stringify(sidecar, null, 2)}\n`, + ); + console.log(`wrote ${WASM_FILE}{,.minisig,.json} and LICENSE for ${commit} to ${VENDOR_DIR}`); + console.log( + 'Update PROVENANCE.md if needed, then re-run parity and the binding tests (ADR 041).', + ); +} + +async function verify(): Promise { + const { sidecar, sig } = verifyPinned(VENDOR_DIR, GHOSTTY_MINISIGN_PUBKEY); + if (sidecar.publicKey !== GHOSTTY_MINISIGN_PUBKEY) { + throw new Error(`sidecar key ${sidecar.publicKey} != ${GHOSTTY_MINISIGN_PUBKEY}`); + } + const wasm = await inspectWasm( + new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))), + ); + console.log( + `OK ${sidecar.file} ${sidecar.sha256}, Ghostty ${sidecar.commit}, ${wasm.buildInfo.optimize}, ` + + `signed by ${sig.keyId} (${sig.trustedComment})`, + ); +} + +if (import.meta.main) { + const args = process.argv.slice(2); + const commitAt = args.indexOf('--commit'); + try { + if (args.includes('--verify')) await verify(); + else if (commitAt >= 0 && args[commitAt + 1]) await update(args[commitAt + 1] as string); + else throw new Error('usage: update-ghostty-vt.ts --commit | --verify'); + } catch (err) { + console.error(`error: ${(err as Error).message}`); + process.exit(1); + } +} diff --git a/src/client/vendor/ghostty-vt.test.ts b/src/client/vendor/ghostty-vt.test.ts new file mode 100644 index 0000000..eae6d16 --- /dev/null +++ b/src/client/vendor/ghostty-vt.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + GHOSTTY_MINISIGN_PUBKEY, + type Sidecar, + verifyMinisign, + verifyPinned, +} from '../../../scripts/update-ghostty-vt'; + +const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-vt'); + +// Ghostty's minisign release key, copied from Ghostty's PACKAGING.md (ADR 042). Kept here +// rather than imported so a changed key in the script or the sidecar fails this test. +const GHOSTTY_PUBLIC_KEY = 'RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV'; +const GHOSTTY_KEY_ID = 'F5787DDB42320225'; + +describe('vendored official ghostty-vt-small.wasm', () => { + const sidecar = JSON.parse( + fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + ) as Sidecar; + const wasm = new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))); + const minisig = fs.readFileSync(path.join(VENDOR_DIR, sidecar.minisig), 'utf8'); + + test('is the ReleaseSmall build the sidecar names', () => { + expect(sidecar.file).toBe('ghostty-vt-small.wasm'); + expect(sidecar.variant).toBe('ReleaseSmall'); + expect(sidecar.commit).toMatch(/^[0-9a-f]{40}$/); + expect(sidecar.url).toBe(`https://tip.files.ghostty.org/${sidecar.commit}/${sidecar.file}`); + }); + + test('matches the size recorded in its sidecar', () => { + expect(wasm.byteLength).toBe(sidecar.size); + }); + + test('matches the sha256 recorded in its sidecar', () => { + expect(createHash('sha256').update(wasm).digest('hex')).toBe(sidecar.sha256); + }); + + test("update script pins Ghostty's release key", () => { + expect(GHOSTTY_MINISIGN_PUBKEY).toBe(GHOSTTY_PUBLIC_KEY); + }); + + test("sidecar records Ghostty's release key", () => { + expect(sidecar.publicKey).toBe(GHOSTTY_PUBLIC_KEY); + expect(sidecar.signature.keyId).toBe(GHOSTTY_KEY_ID); + }); + + test("is signed by Ghostty's release key", () => { + const sig = verifyMinisign(wasm, minisig, GHOSTTY_PUBLIC_KEY); + expect(sig.keyId).toBe(GHOSTTY_KEY_ID); + expect(sig.algorithm).toBe('ED'); + expect(sig.file).toBe(sidecar.file); + expect(sig.trustedComment).toBe(sidecar.signature.trustedComment); + }); + + test('passes the same offline check as `update-ghostty-vt.ts --verify`', () => { + expect(() => verifyPinned(VENDOR_DIR, GHOSTTY_PUBLIC_KEY)).not.toThrow(); + }); + + test('ships with the Ghostty MIT licence', () => { + const license = fs.readFileSync(path.join(VENDOR_DIR, 'LICENSE'), 'utf8'); + expect(license).toStartWith('MIT License'); + expect(license).toContain('Mitchell Hashimoto, Ghostty contributors'); + }); +}); + +describe('verifyMinisign rejects', () => { + const sidecar = JSON.parse( + fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + ) as Sidecar; + const wasm = new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))); + const minisig = fs.readFileSync(path.join(VENDOR_DIR, sidecar.minisig), 'utf8'); + + test('a wasm with one byte changed', () => { + const tampered = wasm.slice(); + tampered[tampered.length >> 1] = (tampered[tampered.length >> 1] as number) ^ 1; + expect(() => verifyMinisign(tampered, minisig, GHOSTTY_PUBLIC_KEY)).toThrow( + 'file signature is invalid', + ); + }); + + test('an edited trusted comment', () => { + const edited = minisig.replace('file:ghostty-vt-small.wasm', 'file:ghostty-vt.wasm'); + expect(edited).not.toBe(minisig); + expect(() => verifyMinisign(wasm, edited, GHOSTTY_PUBLIC_KEY)).toThrow( + 'trusted comment signature is invalid', + ); + }); + + test('a signature checked against a different key', () => { + // Same key id, different Ed25519 key: the id check passes, the signature must not. + const pk = Buffer.from(GHOSTTY_PUBLIC_KEY, 'base64'); + pk[20] = (pk[20] as number) ^ 1; + expect(() => verifyMinisign(wasm, minisig, pk.toString('base64'))).toThrow(); + }); + + test('a signature from another key id', () => { + const pk = Buffer.from(GHOSTTY_PUBLIC_KEY, 'base64'); + pk[2] = (pk[2] as number) ^ 1; + expect(() => verifyMinisign(wasm, minisig, pk.toString('base64'))).toThrow('signed by key'); + }); +}); diff --git a/src/client/vendor/ghostty-vt/LICENSE b/src/client/vendor/ghostty-vt/LICENSE new file mode 100644 index 0000000..0a07a66 --- /dev/null +++ b/src/client/vendor/ghostty-vt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/client/vendor/ghostty-vt/PROVENANCE.md b/src/client/vendor/ghostty-vt/PROVENANCE.md new file mode 100644 index 0000000..d699557 --- /dev/null +++ b/src/client/vendor/ghostty-vt/PROVENANCE.md @@ -0,0 +1,11 @@ +# Provenance + +Ghostty's official libghostty-vt wasm, ReleaseSmall build, unpatched and not edited. MIT licence, Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors (see [LICENSE](LICENSE), copied from Ghostty at the pinned commit). See [ADR 041](../../../../docs/adrs/041.client.libghostty-vt.md) and [ADR 042](../../../../docs/adrs/042.client.official-vt-engine.md). + +- **`ghostty-vt-small.wasm`:** built by Ghostty's tip CI (`.github/workflows/release-tip.yml`) from [ghostty-org/ghostty](https://github.com/ghostty-org/ghostty) at `05dcf887ef51ea27821f20979493306ec3a19149` (2026-10-13), downloaded from `https://tip.files.ghostty.org//ghostty-vt-small.wasm`. The wasm does not record its commit (`ghostty_build_info` reports `0.1.0-dev`); the sidecar is the only record. +- **`ghostty-vt-small.wasm.minisig`:** from the GitHub `tip` release while `tip` pointed at that commit, with the GitHub asset byte-identical to the commit-addressed one. It is Ghostty's minisign signature with the release key from Ghostty's `PACKAGING.md` (`RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV`, key id `F5787DDB42320225`). +- **`ghostty-vt-small.wasm.json`:** the sidecar: commit, source URL, size, sha256, signature source, public key, the signature's key id and trusted comment, licence, and the wasm's exports and `ghostty_build_info`. + +Checked offline by `src/client/vendor/ghostty-vt.test.ts` (size, sha256, signature, key) on every `bun test`, and by `bun scripts/update-ghostty-vt.ts --verify`. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar, and writes the licence to `dist/THIRD_PARTY_LICENSES`. + +The wasm, signature, sidecar and `LICENSE` are written by `bun scripts/update-ghostty-vt.ts --commit `, the only step that uses the network, and are not edited by hand. A pin bump is a deliberate change: update the commit above and re-run parity and the binding's tests (ADR 041). diff --git a/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm b/src/client/vendor/ghostty-vt/ghostty-vt-small.wasm new file mode 100644 index 0000000000000000000000000000000000000000..3df159e389974c94ce5edfa9b838a3d4242430ca GIT binary patch literal 780049 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%0DWGtuWh53W3sM0O$SvRmT1$ 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

7z%tI~EeRy?AizT-YCYea@!<_%Q(q zg+;(N!djv5G1wf~dKi8?gTEgn_>B_5N5>p13}3`p$b=omgA3+CN6tOx@L?+C6LXPxbQ^$(6fMrILk<1{bSXrfTKO3lzMpl$0(}?vk< zCRjaKBUlqyb66`_8(4c-M_6ZAH&}OAFBty(#?TK1gJ5B>p|IhwQLu2>1Xv_&3M>jX z0~QUN2aAC%g2lpKgT=vCz~W)6VF|DgV2Q9TF#P!)KfnXJTnuXADpujBhQ^O7xKB9z`|h(cE>_KH}X8d z~FYo>!Iy zkuQjRLF9Q6T?qL?$QMGMSDnGg2O}Shd|~7ZBVQQ#!pP?|et`QuE(i3=vaekbYvk5! zYxp+gZ-VJ}uJ8h${Z+ldFBx19EqKzu0agcgBkV%ko`HDI_cr;je*>GpWN^N>!Rd^j z>4fXuZ;m0K1p5S*0^1Esh3$i-!G3^czz)Okce3-Ga~Z=+4qi4Chn0q13abdK466>i z8dej=BMXnb^Jotzd0n?O`2ZonhTz-C@08`12b>KNJjtg~5ixhQmg|!eJ9& zk+3PSDA){GG;AI$2DS(m3wsR~2U`J)hpmPsz&?N_!nVNh=Xd-759o3+tbyFAh%(C> z&Ygj$AWdNHVclV2uy9xuECv<_OMoQ_|4z2{%}!q#wQ9qfz}myQ!@^+UuqapzEDn|c zOM<1sGVD{P;_#JWwP8(Q?P1+vVX$yml`Ix1YQFP9K-yc zL;f4bs=#X?fjjYCVCSTZ{CMlpe4AS$7+2~cyW#Wpz;P6}P&$Zoo*+Ic2$?sp zL~yG=@F|>pC~AyP{qT*4tF14CPtov=hXwZ2J2!%tM!u5u0?!+B<&mJg`EupXj~`Na zzC`f-=vlHFFVyp%g5^h@zp`KniJV^|__ADf4ED{j_~#unBo1{dJlN3QPw$b24~8|? z%)`t`ND6A;CR8E&;Pd8jT8A8F+VmJQ;RU2kk0CRdHY0|-hIG&X2}z*uFCm}Mqn|YT zff8~Iz6W3K;0?hV!FVS;9@zD!VfB!%?(ksvTW zB4Ep4$uMvfgyO$Y#1o2mLJ?0W;&H-5JfVmu6!C;2o>0W&gp7DX5l<-M2}L}ih{u@* z;t54Op@=6G@q{8CPAwGigd(0$#1o2mLJ<$5b0Uer{}G5M0`Wv39!CPi6M+CCFjxdS zjzA}lEGXnxIU*2G1mcN6JdRX|Cj#+AAf5=s6M=Xf`4CS8;)y^!5r`)O@letT#1nyd zA`njm;)y^!j?9iSjE0Y77&!DOJj)QzGQ_hC@i?2;*-BJ~(eMF| zc#;uMGU7=_JjsYB8SyyF0P!Rvo@B(6jChg}kFzuoPcq_3Mm)&~BpHJ`%LSb!qhT^C zB?BZQ9{huWBc5c$lZ<$h5f3noD~4N%$}k!{5RV7(co2^V@purA2l1de3>@)z5RV7( zcn}Y|gn=U-590A49uMNdFfee$<3T(g#N$CQ7#aqS4n1h>K}CLzjbB~k*U0#_Fn+Cy zUk7S{uL?A8-dx(YZ7ZEScb1+#d&;0egJjsSVa``L_;ntBC5K;|;a6prFJCUJSFe^2 zKm1UVl9FWCu3fTk-#&c2J6*Q)z=3JY`Vzsj3kD7a*oCnv1BZgZq2RxGD8P}=Tyenj zdWe?^yjtNURDKw*NcaIAUK{gzxG;>LQs)Pfiox(AwDWOr-al4963!db@)PG}VVA

xW24#ui+^+=I(XdR&P`y5Sp1$PD;0ytHfz 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 Y`_eyJ9R8)w { }); const httpServer = http.createServer((req, res) => { - handleRequest(req, res, wasmPath, clientDistPath, shutdown).catch((err) => { + handleRequest(req, res, wasmPaths, clientDistPath, shutdown).catch((err) => { console.error('[webtty] unhandled route error:', err); if (!res.headersSent) { res.writeHead(500); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 3734611..4834963 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -62,6 +62,28 @@ describe('server — routes', () => { expect(createHash('sha256').update(body).digest('hex')).toBe(sidecar.sha256); }); + test('GET /ghostty-vt-small.wasm serves the official wasm byte for byte', async () => { + const sidecar = JSON.parse( + fs.readFileSync( + path.resolve(__dirname, '../client/vendor/ghostty-vt/ghostty-vt-small.wasm.json'), + 'utf8', + ), + ) as { size: number; sha256: string }; + const res = await fetch(`${baseUrl}/ghostty-vt-small.wasm`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/wasm'); + const body = new Uint8Array(await res.arrayBuffer()); + expect(body.byteLength).toBe(sidecar.size); + expect(createHash('sha256').update(body).digest('hex')).toBe(sidecar.sha256); + }); + + test('wasm routes serve only the listed files', async () => { + for (const p of ['/ghostty-vt.wasm.json', '/ghostty-vt-small.wasm.minisig']) { + const res = await fetch(`${baseUrl}${p}`); + expect(res.status).toBe(404); + } + }); + test('GET / redirects to /s/main when no sessions exist', async () => { const res = await fetch(`${baseUrl}/`, { redirect: 'manual' }); expect(res.status).toBe(302); diff --git a/src/server/routes.ts b/src/server/routes.ts index 52d79ed..ae8a112 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -72,14 +72,14 @@ export function readJson(req: http.IncomingMessage): Promise { * * @param req - The incoming HTTP request. * @param res - The HTTP response object. - * @param wasmPath - Path to the ghostty-vt.wasm file. + * @param wasmPaths - Served wasm files: URL path (`/ghostty-vt.wasm`) → file path. * @param clientDistPath - Path to the client dist directory. * @param onStop - Callback invoked when `POST /api/server/stop` is received. */ export async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, - wasmPath: string, + wasmPaths: Readonly>, clientDistPath: string, onStop: () => void, ): Promise { @@ -471,7 +471,8 @@ export async function handleRequest( return; } - if (pathname === '/ghostty-vt.wasm') { + const wasmPath = Object.hasOwn(wasmPaths, pathname) ? wasmPaths[pathname] : undefined; + if (wasmPath) { serveFile(wasmPath, res); return; } diff --git a/src/server/static.test.ts b/src/server/static.test.ts index 26a9baa..b2d9b78 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -1,7 +1,7 @@ import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { findGhosttyWasm, mimeType, serveFile } from './static'; +import { findGhosttyWasm, findGhosttyWasms, mimeType, serveFile } from './static'; describe('mimeType', () => { test('returns correct type for known extensions', () => { @@ -63,6 +63,53 @@ describe('findGhosttyWasm', () => { }); }); +describe('findGhosttyWasm — official ghostty-vt-small.wasm', () => { + const VENDORED = path.join('src', 'client', 'vendor', 'ghostty-vt', 'ghostty-vt-small.wasm'); + const BUILT = path.join('dist', 'ghostty-vt-small.wasm'); + + test('prefers the wasm copied into dist/ by the build', () => { + const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => String(p).endsWith(BUILT)); + expect(findGhosttyWasm('ghostty-vt-small.wasm').endsWith(BUILT)).toBe(true); + existsSpy.mockRestore(); + }); + + test('falls back to the vendored wasm when dist/ has none', () => { + const realExistsSync = fs.existsSync.bind(fs); + const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => { + const s = String(p); + return s.endsWith(BUILT) ? false : realExistsSync(s); + }); + const wasmPath = findGhosttyWasm('ghostty-vt-small.wasm'); + expect(wasmPath.endsWith(VENDORED)).toBe(true); + expect(realExistsSync(wasmPath)).toBe(true); + existsSpy.mockRestore(); + }); + + test('exits naming the missing file', () => { + const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); + const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + findGhosttyWasm('ghostty-vt-small.wasm'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith('Error: Could not find ghostty-vt-small.wasm.'); + existsSpy.mockRestore(); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); +}); + +describe('findGhosttyWasms', () => { + test('maps both served wasm URLs to their files', () => { + const paths = findGhosttyWasms(); + expect(Object.keys(paths).sort()).toEqual(['/ghostty-vt-small.wasm', '/ghostty-vt.wasm']); + expect(path.basename(paths['/ghostty-vt.wasm'] as string)).toBe('ghostty-vt.wasm'); + expect(path.basename(paths['/ghostty-vt-small.wasm'] as string)).toBe('ghostty-vt-small.wasm'); + for (const p of Object.values(paths)) expect(fs.existsSync(p)).toBe(true); + }); +}); + describe('serveFile', () => { test('serves file with correct content type', async () => { const existsSpy = spyOn(fs, 'readFile').mockImplementation((( diff --git a/src/server/static.ts b/src/server/static.ts index 7eda026..213813a 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -31,25 +31,52 @@ export function mimeType(filePath: string): string { } /** - * Locates `ghostty-vt.wasm`, preferring the copy the build puts in `dist/` (npm install) + * The wasm files the server serves at `GET /`, each with the directory under + * `src/client/vendor/` it is vendored in: the Coder engine's `ghostty-vt.wasm` (ADR 041) + * and the official libghostty-vt `ghostty-vt-small.wasm` (ADR 042). + */ +export const GHOSTTY_WASM_FILES = { + 'ghostty-vt.wasm': 'ghostty-web', + 'ghostty-vt-small.wasm': 'ghostty-vt', +} as const; + +export type GhosttyWasmFile = keyof typeof GHOSTTY_WASM_FILES; + +/** + * Locates a served wasm file, preferring the copy the build puts in `dist/` (npm install) * over the vendored source copy (local dev). Exits the process if neither exists. * - * @returns The absolute path to `ghostty-vt.wasm`. + * @param file - The wasm file name; defaults to the Coder engine's `ghostty-vt.wasm`. + * @returns The absolute path to the wasm file. * @throws Exits the process with code 1 if the wasm cannot be found. */ -export function findGhosttyWasm(): string { +export function findGhosttyWasm(file: GhosttyWasmFile = 'ghostty-vt.wasm'): string { const projectRoot = path.join(__dirname, '..', '..'); const candidates = [ - path.join(projectRoot, 'dist', 'ghostty-vt.wasm'), - path.join(projectRoot, 'src', 'client', 'vendor', 'ghostty-web', 'ghostty-vt.wasm'), + path.join(projectRoot, 'dist', file), + path.join(projectRoot, 'src', 'client', 'vendor', GHOSTTY_WASM_FILES[file], file), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } - console.error('Error: Could not find ghostty-vt.wasm.'); + console.error(`Error: Could not find ${file}.`); process.exit(1); } +/** + * Locates every file in {@link GHOSTTY_WASM_FILES}, keyed by the URL path it is served at. + * + * @returns A map from `/` to the absolute path {@link findGhosttyWasm} resolves. + * @throws Exits the process with code 1 if any wasm cannot be found. + */ +export function findGhosttyWasms(): Record { + const paths: Record = {}; + for (const file of Object.keys(GHOSTTY_WASM_FILES) as GhosttyWasmFile[]) { + paths[`/${file}`] = findGhosttyWasm(file); + } + return paths; +} + /** * Reads `filePath` from disk and writes it to `res` with the correct Content-Type header. * Responds with 404 if the file cannot be read. diff --git a/src/version.test.ts b/src/version.test.ts index ae9064f..363392d 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -51,11 +51,15 @@ describe('getVersion — bundled dist', () => { external: ['@lydell/node-pty', 'ws'], }); if (!result.success) throw new Error(`bundle failed: ${result.logs.join('\n')}`); - // The server needs ghostty-vt.wasm at startup; the published package ships it in dist/. + // The server needs both wasm files at startup; the published package ships them in dist/. fs.copyFileSync( path.join(REPO_ROOT, 'src/client/vendor/ghostty-web/ghostty-vt.wasm'), path.join(pkgRoot, 'dist/ghostty-vt.wasm'), ); + fs.copyFileSync( + path.join(REPO_ROOT, 'src/client/vendor/ghostty-vt/ghostty-vt-small.wasm'), + path.join(pkgRoot, 'dist/ghostty-vt-small.wasm'), + ); cliEntry = path.join(pkgRoot, 'dist/cli/index.js'); serverEntry = path.join(pkgRoot, 'dist/server/index.js'); }); From ed488d406739680316c03a96236b0c28adfd1b98 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 01:15:37 -0400 Subject: [PATCH 013/295] feat(client): add the official libghostty-vt binding OfficialTerminal implements the GhosttyTerminal surface on Ghostty's official wasm: rows via render-state next_dirty + CELLS_RAW, layouts from ghostty_type_json, callbacks (WRITE_PTY, BELL, TITLE_CHANGED, SIZE, DEVICE_ATTRIBUTES) through a borrowed fn-table trampoline, mode 2027 on. Not wired into the client yet. Parity tests replay vim/nvim/less/ls/top/ sgr/unicode/seq captures through both engines cell for cell. --- docs/adrs/042.client.official-vt-engine.md | 16 +- docs/specs/client.md | 10 +- scripts/build.ts | 7 + src/client/vendor/engine-parity.test.ts | 459 ++ src/client/vendor/fixtures/less.bin | 79 + src/client/vendor/fixtures/ls.bin | 81 + src/client/vendor/fixtures/nvim.bin | 117 + src/client/vendor/fixtures/seq.bin | 5000 +++++++++++++++++ src/client/vendor/fixtures/sgr.bin | 18 + src/client/vendor/fixtures/top.bin | 1 + src/client/vendor/fixtures/uni.bin | 14 + src/client/vendor/fixtures/vim.bin | 3 + .../vendor/ghostty-web/LICENSE.ghostty-vt-js | 21 + src/client/vendor/ghostty-web/PROVENANCE.md | 2 + src/client/vendor/ghostty-web/fn-table.ts | 228 + src/client/vendor/ghostty-web/ghostty.ts | 2046 ++++++- src/client/vendor/ghostty-web/index.ts | 6 +- src/client/vendor/ghostty-web/types.ts | 11 + src/client/vendor/official-terminal.test.ts | 571 ++ 19 files changed, 8642 insertions(+), 48 deletions(-) create mode 100644 src/client/vendor/engine-parity.test.ts create mode 100644 src/client/vendor/fixtures/less.bin create mode 100644 src/client/vendor/fixtures/ls.bin create mode 100644 src/client/vendor/fixtures/nvim.bin create mode 100644 src/client/vendor/fixtures/seq.bin create mode 100644 src/client/vendor/fixtures/sgr.bin create mode 100644 src/client/vendor/fixtures/top.bin create mode 100644 src/client/vendor/fixtures/uni.bin create mode 100644 src/client/vendor/fixtures/vim.bin create mode 100644 src/client/vendor/ghostty-web/LICENSE.ghostty-vt-js create mode 100644 src/client/vendor/ghostty-web/fn-table.ts create mode 100644 src/client/vendor/official-terminal.test.ts diff --git a/docs/adrs/042.client.official-vt-engine.md b/docs/adrs/042.client.official-vt-engine.md index 12aa8ba..f895a4e 100644 --- a/docs/adrs/042.client.official-vt-engine.md +++ b/docs/adrs/042.client.official-vt-engine.md @@ -63,7 +63,9 @@ src/client/vendor/ ghostty-web/ ghostty.ts ← both engines: Coder (existing) and official (new) fn-table.ts ← function-table trampoline, borrowed (see below) + LICENSE.ghostty-vt-js ← fn-table.ts's MIT licence ghostty-vt.test.ts ← offline sha256, size and signature check + official-terminal.test.ts ← the official binding on its own (cells, modes, replies, keys) engine-parity.test.ts, fixtures/ ← both engines on the PTY captures and the #180 payload src/client/engine.ts ← DEFAULT_ENGINE and the `?engine` override scripts/update-ghostty-vt.ts ← fetch and verify a new pin (network; run by hand) @@ -84,10 +86,10 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the **Binding (official).** - Rows: a refresh runs `ghostty_render_state_update`, then `ghostty_render_state_row_iterator_next_dirty` and one `ghostty_render_state_row_get_multi` per dirty row for `ROW_DATA_CELLS_RAW` and `ROW_DATA_VIEWPORT_Y`. Each row is decoded straight into JS-owned `GhosttyCell` objects, with bit positions from the `GhosttyCell` descriptor in `ghostty_type_json` and colours and flags from a per-row style cache (`ghostty_render_state_row_cells_get` `STYLE`, only for a style id not yet seen). Graphemes are read only for cells tagged as graphemes. The JS copy is indexed by viewport row. At the bottom (`viewportY` 0) the viewport is the active screen, so `getViewport`, `getLine(n)` and `isRowDirty(n)` read copy row `n`. -- Refresh and dirty rules. The renderer and the Coder-style reads call `update()` many times per frame (`renderer.ts` `render` through `getCursor`, `needsFullRedraw` and `getLine`; selection through `getLine` once per row) and rely on dirty state persisting until `clearDirty()`/`markClean()`. The binding keeps a generation counter, bumped by `write`, `resize`, a `setCellPixelSize` that calls `ghostty_terminal_resize`, a viewport move in `setViewportOffset`, and `reset`. The viewport moves a page read makes, and its move back, do not bump it, or the page cache would clear itself. `update()` refreshes only when the generation has changed since the last refresh; otherwise it returns the stored dirty state without an export call. So `getLine`, `getViewport` and `getCursor` always reflect the latest write, as they do on Coder, and a fully dirty frame decodes each row once however often `update()` is called. Each refresh ends with `ghostty_render_state_clean`, which clears both the global `DIRTY` and every per-row `ROW_OPTION_DIRTY` (`render.h`: `update` does not clear either layer), so the next refresh decodes only rows changed since. The rows it decoded go into a JS dirty set, and the global state it returned is kept as the highest seen (none, partial, full). Both accumulate until `markClean()` empties them. Renders outside the render loop (`writeInternal`'s `awaitingEcho` render, `index.ts`'s `visibilitychange` repaint, ADR 040's `renderLine`) go through the same `update()`. +- Refresh and dirty rules. The renderer and the Coder-style reads call `update()` many times per frame (`renderer.ts` `render` through `getCursor`, `needsFullRedraw` and `getLine`; selection through `getLine` once per row) and rely on dirty state persisting until `clearDirty()`/`markClean()`. The binding keeps a generation counter, bumped by `write`, `resize`, a `setCellPixelSize` that calls `ghostty_terminal_resize`, a viewport move in `setViewportOffset`, and `reset`. The viewport moves a page read makes, and its move back, do not bump it. A second counter, the content generation, is bumped by all of these except the viewport move; it keys the page cache below. `update()` refreshes only when the generation has changed since the last refresh; otherwise it returns the stored dirty state without an export call. So `getLine`, `getViewport` and `getCursor` always reflect the latest write, as they do on Coder, and a fully dirty frame decodes each row once however often `update()` is called. Each refresh ends with `ghostty_render_state_clean`, which clears both the global `DIRTY` and every per-row `ROW_OPTION_DIRTY` (`render.h`: `update` does not clear either layer), so the next refresh decodes only rows changed since. Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no row dirty when only the defaults change (OSC 10/11/110/111 and DECSCNM leave `DIRTY` false, because its renderer applies them globally; a palette change is `FULL`). So each refresh compares the default colours and the 256-entry palette with the previous refresh and, if any changed, decodes every row and reports full. The rows it decoded go into a JS dirty set, and the global state it returned is kept as the highest seen (none, partial, full). Both accumulate until `markClean()` empties them. Renders outside the render loop (`writeInternal`'s `awaitingEcho` render, `index.ts`'s `visibilitychange` repaint, ADR 040's `renderLine`) go through the same `update()`. - Cursor: `getCursor` reads `DATA_CURSOR_X`/`DATA_CURSOR_Y` (active-screen coordinates), not the render state's viewport cursor. -- Scrollback: `getScrollbackLength` reads `DATA_SCROLLBACK_ROWS` (S below). Writes never leave the view scrolled, but `terminal.ts` `writeInternal` calls `wasmTerm.write` first and `scrollToBottom()` after it (xterm behaviour), and `smooth-scroll.ts` `onOutput` then drops its sub-line offset. So `OfficialTerminal.write` itself moves Ghostty's viewport to `BOTTOM` before `vt_write` whenever it is scrolled, sets its offset to 0 and marks every row dirty; pruning and screen switches then happen with the viewport at the bottom, and any render before `scrollToBottom` (the `awaitingEcho` render) passes a stale `viewportY` that `setViewportOffset` moves back. A scrolled frame (k = `floor(viewportY)` > 0) follows user scrolling, and S changes under it only through resize and reflow. `setViewportOffset` moves Ghostty's viewport with `ghostty_terminal_scroll_viewport` `ROW` to row S − k (`ROW` counts from the top of scrollback, `viewportY` from the bottom) whenever k or S differs from the last move, which also covers a resize while scrolled. The frame is read through the same render-state path, and copy row y maps to `getScrollbackLine(S − k + y)` for y < k and to `getLine(y − k)` for y ≥ k. Those reads are served from the copy. `isRowDirty` returns true for every row while scrolled, since the renderer repaints all rows then anyway. Back at the bottom (`viewportY` 0, including the snap after a write), the binding scrolls Ghostty's viewport with `BOTTOM` and marks every row dirty for the next frame. -- Reads outside the viewport (`getLine(n)` for n ≥ rows − k, scrollback rows above the viewport, smooth scroll's row above the top, selection copy and the buffer API) are read a page at a time through the render state, not cell by cell. On a miss the binding moves Ghostty's viewport with `scroll_viewport` `ROW` to the page of `rows` rows that holds the row, reads the page with `CELLS_RAW` through a second render state, and moves the viewport back before returning: to `BOTTOM` when the offset is 0, to `ROW` S − k otherwise. `scroll_viewport` `ROW` n with n ≥ S snaps to the active screen (`PageList.zig` `scroll`), so a page starts at min(n, S) and rows are mapped from that start. Pages are cached until the generation changes. A render state's update consumes the terminal's dirty state (page and row dirty bits and the terminal-wide full-redraw flags; `render.zig` `beginUpdate` assumes it is the only consumer), so the second state would hide changes from the primary. Before a page read the binding therefore refreshes the primary when the generation has changed, so the primary has captured every pending dirty bit and flag (and the palette) before the second state runs; this ordering is what keeps the primary correct. The reverse also holds: the second state cannot trust dirty bits the primary has already cleared, and `beginUpdate` rebuilds a row only on a pin, screen or size change or a dirty bit (`render.zig`), so a page read at the same row after an in-place write, or after pruning recycled a page at the same address, would return stale cells. The page-read state is therefore freed and recreated whenever the generation has changed, so every miss rebuilds every row it reads. Copying a selection over N rows costs about N / rows page reads plus one decode per row, and smooth scroll's row above the top is a cache hit on most frames. `ghostty_terminal_grid_ref` is kept for single-cell lookups (hyperlink hover, a grapheme outside the copy). Per-cell `grid_ref` costs one lookup plus cell, style and grapheme calls per cell (`grid_ref.h` notes the lookup's cost): a 3,000-row selection at 200 columns would be over 1.8 million export calls, and step 4 raises scrollback to 10,000 lines. +- Scrollback: `getScrollbackLength` reads `DATA_SCROLLBACK_ROWS` (S below). Writes never leave the view scrolled, but `terminal.ts` `writeInternal` calls `wasmTerm.write` first and `scrollToBottom()` after it (xterm behaviour), and `smooth-scroll.ts` `onOutput` then drops its sub-line offset. So `OfficialTerminal.write` itself moves Ghostty's viewport to `BOTTOM` before `vt_write` whenever it is scrolled, sets its offset to 0 and marks every row dirty; pruning and screen switches then happen with the viewport at the bottom, and any render before `scrollToBottom` (the `awaitingEcho` render) passes a stale `viewportY` that `setViewportOffset` moves back. A scrolled frame (k = `floor(viewportY)` > 0) follows user scrolling, and S changes under it only through resize and reflow. `setViewportOffset` moves Ghostty's viewport with `ghostty_terminal_scroll_viewport` `ROW` to row S − k (`ROW` counts from the top of scrollback, `viewportY` from the bottom) whenever k or S differs from the last move. Reflow moves Ghostty's viewport with the rows it shows even when S is unchanged, so a resize while scrolled moves it back to `ROW` S − k itself, before any read. The frame is read through the same render-state path, and copy row y maps to `getScrollbackLine(S − k + y)` for y < k and to `getLine(y − k)` for y ≥ k. Those reads are served from the copy. `isRowDirty` returns true for every row while scrolled, since the renderer repaints all rows then anyway. Back at the bottom (`viewportY` 0, including the snap after a write), the binding scrolls Ghostty's viewport with `BOTTOM` and marks every row dirty for the next frame. +- Reads outside the viewport (`getLine(n)` for n ≥ rows − k, scrollback rows above the viewport, smooth scroll's row above the top, selection copy and the buffer API) are read a page at a time through the render state, not cell by cell. On a miss the binding moves Ghostty's viewport with `scroll_viewport` `ROW` to the page of `rows` rows that holds the row, reads the page with `CELLS_RAW` through a second render state, and moves the viewport back before returning: to `BOTTOM` when the offset is 0, to `ROW` S − k otherwise. `scroll_viewport` `ROW` n with n ≥ S snaps to the active screen (`PageList.zig` `scroll`), so a page starts at min(n, S) and rows are mapped from that start. Pages are cached until the content generation changes, so a viewport move keeps them: while scrolled by 0 < k < rows, the renderer's `getViewport` reads the k active rows below the view from the cache instead of decoding them again on every step. A render state's update consumes the terminal's dirty state (page and row dirty bits and the terminal-wide full-redraw flags; `render.zig` `beginUpdate` assumes it is the only consumer), so the second state would hide changes from the primary. Before a page read the binding therefore refreshes the primary when the generation has changed, so the primary has captured every pending dirty bit and flag (and the palette) before the second state runs; this ordering is what keeps the primary correct. The reverse also holds: the second state cannot trust dirty bits the primary has already cleared, and `beginUpdate` rebuilds a row only on a pin, screen or size change or a dirty bit (`render.zig`), so a page read at the same row after an in-place write, or after pruning recycled a page at the same address, would return stale cells. The page-read state is therefore freed and recreated whenever the content generation has changed, so every miss after a content change rebuilds every row it reads. A viewport move alone changes no row, pin or size of the rows already read, so the state is kept across it. Copying a selection over N rows costs about N / rows page reads plus one decode per row, and smooth scroll's row above the top is a cache hit on most frames. `ghostty_terminal_grid_ref` is kept for single-cell lookups (hyperlink hover, a grapheme outside the copy). Per-cell `grid_ref` costs one lookup plus cell, style and grapheme calls per cell (`grid_ref.h` notes the lookup's cost): a 3,000-row selection at 200 columns would be over 1.8 million export calls, and step 4 raises scrollback to 10,000 lines. - Callbacks through `fn-table.ts`, installed per terminal and released after `ghostty_terminal_free`. None re-enters `vt_write`; each copies what it needs and returns. - `WRITE_PTY`: copies the bytes into the queue that `readResponse` drains, the path `terminal.ts` already uses for Coder replies. Replies also arise outside `write` (the mode 2048 report from `ghostty_terminal_resize`), so `terminal.ts` drains at the three points listed above. - `BELL`, `TITLE_CHANGED`: set flags. The title is read with `DATA_TITLE` after `vt_write` returns, copied, and handed out by `readEffects`. @@ -96,10 +98,10 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the - `Terminal.resize()`, which already returns early when columns and rows are unchanged: it computes the pixel size and calls `wasmTerm.resize(cols, rows, w, h)`, one `ghostty_terminal_resize` with the new grid and pixels. - A new `Terminal.syncCellPixelSize()`, called at the end of `index.ts` `fit()`. Every metrics or DPR change reaches `fit()`: font zoom calls it directly after setting `fontSize`, and the `document.fonts.ready` remeasure and `watchDevicePixelRatio` go through `scheduleFit`. If the grid changed, `resize` has already sent the new pixels and this does nothing; if only the pixels changed, `setCellPixelSize` makes one call at the current grid. - `setCellPixelSize` returns at once when the size equals the one Ghostty last received. Nothing is flushed from the render loop, so the order of the fit's and the render loop's `requestAnimationFrame` callbacks does not matter. A unit test drives `OfficialTerminal` over a fake wasm exports object through the calls `terminal.ts` and `fit()` make, and asserts one `ghostty_terminal_resize` for a zoom that changes grid and pixels, one for a zoom or DPR change that keeps the grid, and none for repeated fits. The refresh, scrolled-view and page-read logic is tested on the real wasm in `engine-parity.test.ts`, against the Coder engine: scrolled reads (`getLine`, `getScrollbackLine`, rendered rows) at several k, a write while scrolled, a resize while scrolled, a write that dirties active rows followed by a page read and then a render, and a scrolled page read, an in-place write, a render with the stale `viewportY`, then the same page read again. + `setCellPixelSize` returns at once when the size equals the one Ghostty last received. Nothing is flushed from the render loop, so the order of the fit's and the render loop's `requestAnimationFrame` callbacks does not matter. A unit test drives `OfficialTerminal` over the real wasm exports with `ghostty_terminal_resize` counted, through the calls `terminal.ts` and `fit()` make, and asserts one `ghostty_terminal_resize` for a zoom that changes grid and pixels, one for a zoom or DPR change that keeps the grid, and none for repeated fits. The refresh, scrolled-view and page-read logic is tested on the real wasm in `engine-parity.test.ts`, against the Coder engine: scrolled reads (`getLine`, `getScrollbackLine`, rendered rows) at several k, a write while scrolled, a resize while scrolled, a write that dirties active rows followed by a page read and then a render, a scrolled page read, an in-place write, a render with the stale `viewportY`, then the same page read again, and the offset stepped through 0 < k < rows, k = rows and beyond and back, before and after an in-place write. - `DEVICE_ATTRIBUTES`: primary conformance 62 with feature 22, secondary type 1, firmware 10, ROM 0, tertiary unit id 0. This gives `ESC[?62;22c` and `ESC[>1;10;0c`. - `ENQUIRY`, `XTVERSION`, clipboard, notification and the other effects are not installed (ADR 041 future work). -- Setup: `MODE_DEFAULT` with mode 2027 on, so RIS keeps grapheme clustering. The theme's foreground, background, cursor and palette are set through `COLOR_FOREGROUND`, `COLOR_BACKGROUND`, `COLOR_CURSOR` and `COLOR_PALETTE`. The colour values in `GhosttyTerminalConfig` cannot be used as they are: `terminal.ts` `parseColorToHex` encodes an unset colour as 0 ("use default" in `types.ts`), and the default theme's background `#000000` (`src/config.ts`) also becomes 0. `buildWasmConfig` therefore adds a mask of the theme keys that parsed (foreground, background, cursor and the 16 palette entries), which Coder ignores. A key in the mask is set as given, so `#000000` is black. An unset foreground, background or cursor keeps Ghostty's default, and unset palette entries and entries 16–255 come from `COLOR_PALETTE_DEFAULT`. +- Setup: `MODE_DEFAULT` with mode 2027 on, so RIS keeps grapheme clustering. The theme's foreground, background, cursor and palette are set through `COLOR_FOREGROUND`, `COLOR_BACKGROUND`, `COLOR_CURSOR` and `COLOR_PALETTE`. The colour values in `GhosttyTerminalConfig` cannot be used as they are: `terminal.ts` `parseColorToHex` encodes an unset colour as 0 ("use default" in `types.ts`), and the default theme's background `#000000` (`src/config.ts`) also becomes 0. `buildWasmConfig` therefore adds a mask of the theme keys that parsed (foreground, background, cursor and the 16 palette entries), which Coder ignores. A key in the mask is set as given, so `#000000` is black. Foreground and background are always both set: libghostty-vt has no default for them, and its render state copies the default colours only when both are set (`render.zig`, the `bg_fg` block), otherwise keeping white on black and ignoring the given colour, OSC 10/11 and DECSCNM. An unset one falls back to the Coder engine's effective default (foreground `0xcccccc`, background `0x000000`). An unset cursor stays unset, and unset palette entries and entries 16–255 come from `COLOR_PALETTE_DEFAULT`. - Scrollback budget unchanged: the same `Math.ceil(config.scrollback / 80)` goes to `SCROLLBACK_MAX_BYTES`. `scrollback: 0`, unlimited on the Coder wasm, becomes 32 MiB, because 0 bytes disables scrollback and the binding never passes NULL for both limits. `SCROLLBACK_MAX_LINES` stays unset until step 4. - Keys: the official `KeyEncoder` translates ghostty-web's `Key` and `KeyEncoderOption` values to the official `GhosttyKey` and `GhosttyKeyEncoderOption` values from `ghostty_type_json`. It matches by name, with a fixed rename table for the 44 keys whose names differ (e.g. `GRAVE` → `BACKQUOTE`, `ZERO` → `DIGIT_0`, `UP` → `ARROW_UP`, `KP_*` → `NUMPAD_*`) and for `KITTY_KEYBOARD_FLAGS` → `KITTY_FLAGS`. Twenty-nine keys have different numbers in the two builds, so no value is passed through untranslated. It allocates with `ghostty_wasm_alloc` and `ghostty_wasm_free`. @@ -116,7 +118,7 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the | Area | Kind | Check | |---|---|---| | Browsers | vs Coder | Load, type, render, select and copy, paste, IME, wheel and mouse apps in Chrome, Firefox and Safari | -| Resize/reflow | vs Coder | Normal-screen reflow of wrapped lines when shrinking and growing; alternate screen; font zoom and DPR change re-report cell pixels | +| Resize/reflow | vs Coder | Normal-screen reflow of wrapped lines when shrinking and growing; alternate screen; font zoom and DPR change re-report cell pixels. After growing, the Coder wasm's `getScrollbackLine` returns stale cells past the old width, so there the official engine is checked against the written text | | Round trips | official | DA1, DA2, DA3; DSR `5n`/`6n`; XTWINOPS `14/16/18/19 t` answered once, in device pixels; a mode 2048 report delivered on resize, font zoom and DPR change, exactly once each and without waiting for PTY output; OSC 0/2 title; OSC 4/10/11 against the theme, including OSC 11 replying `rgb:0000/0000/0000` on the default config; DECRQM 2027 reports set | | #180 | official | The ADR 041 stress payload: 0 stale rows over 300 repetitions on the official engine, in Bun (`engine-parity.test.ts`) and in Chrome | | Parity | vs Coder | ls, vim, nvim, less, top, SGR/256/truecolor, Unicode and `seq 5000` captures match cell for cell (text, fg, bg, flags, width, cursor) with 512-byte and 1-byte writes; the Hindi conjunct in the Unicode capture is the one listed difference | @@ -144,7 +146,7 @@ The captures are re-recorded from a clean `HOME` before they are committed as fi - `?engine=official` runs the official wasm with real effects: bell and title from Ghostty, query replies through `WRITE_PTY`, and size replies from Ghostty with device-pixel cells. - Apps on the official engine see new replies: DA3, XTWINOPS `19 t`, mode 2048 reports, OSC 4/10/11 colour reports, DECRQM and XTVERSION `libghostty`. DA1 and DA2 are unchanged. ENQ stays silent: it needs the `ENQUIRY` callback, which this step does not install, although ADR 041's Consequences list ENQ among the new replies. - Scrollback under the default config holds −7% to +25% of the Coder engine's rows, depending on size and payload (see Context), until step 4. -- The repo carries 780,049 more bytes of wasm, plus the signature, the sidecar, `fn-table.ts` and the fixtures. Pages using the Coder engine download nothing new. +- The repo carries 780,049 more bytes of wasm, plus the signature, the sidecar, `fn-table.ts` and the fixtures. Pages using the Coder engine download no new wasm; `client-browser.js` grows by about 37 KB minified (108 → 145 KB, uncompressed) for the official binding, which is bundled whichever engine runs. - `ghostty.ts` holds two engines until step 5 deletes the Coder one. - The default stays `coder`, and nothing changes for users who do not add `?engine=official`. diff --git a/docs/specs/client.md b/docs/specs/client.md index f4d0d53..46f6fdd 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -44,7 +44,10 @@ src/client/ vendor/ ghostty-web/ ← coder/ghostty-web lib/ at 1858a59 (non-test TS, unchanged), MIT LICENSE, PROVENANCE.md; Ghostty's LICENSE.ghostty; Coder ghostty-vt.wasm + ghostty-vt.wasm.json sidecar (npm version, Ghostty commit + patch, - licence, size, sha256), checked by vendor/ghostty-web.test.ts + licence, size, sha256), checked by vendor/ghostty-web.test.ts; + ghostty.ts holds every wasm call for both engines (Coder and official, ADR 042); + fn-table.ts is the function-table trampoline borrowed from ghostty-vt-js (LICENSE.ghostty-vt-js) + fixtures/ ← PTY captures replayed into both engines by vendor/engine-parity.test.ts ghostty-vt/ ← official ghostty-vt-small.wasm (Ghostty 05dcf887, ReleaseSmall) + .minisig + sidecar (commit, URL, size, sha256, signature, key, licence), Ghostty LICENSE, PROVENANCE.md; checked offline by vendor/ghostty-vt.test.ts (size, sha256, minisign signature) @@ -58,11 +61,10 @@ Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.c ``` src/client/ engine.ts ← DEFAULT_ENGINE and the ?engine=coder|official override - vendor/ - ghostty-web/ ← all wasm calls for both engines in ghostty.ts; fn-table.ts (borrowed trampoline); - Coder wasm + sidecar until the Coder engine is removed ``` +The official engine (`OfficialTerminal`) is built and tested but not yet selectable; the Coder wasm and sidecar stay until the Coder engine is removed. + ## Page `GET /s/:id` returns `dist/client.html` — a static HTML shell. On load, `client-browser.js`: diff --git a/scripts/build.ts b/scripts/build.ts index b3faf65..ffa4bee 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -86,6 +86,13 @@ fs.writeFileSync( '', fs.readFileSync(path.resolve(officialDir, 'LICENSE'), 'utf8').trim(), '', + '---', + '', + 'ghostty-vt-js (fn-table.ts, bundled in client-browser.js)', + 'https://github.com/gkoreli/ghostty-vt-js', + '', + fs.readFileSync(path.resolve(vendorDir, 'LICENSE.ghostty-vt-js'), 'utf8').trim(), + '', ].join('\n'), ); diff --git a/src/client/vendor/engine-parity.test.ts b/src/client/vendor/engine-parity.test.ts new file mode 100644 index 0000000..f8d36b3 --- /dev/null +++ b/src/client/vendor/engine-parity.test.ts @@ -0,0 +1,459 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; + +// Both engines (ADR 042) on the same input, cell for cell. +// +// fixtures/*.bin are PTY captures at 120x40 (TERM=xterm-256color, LANG=en_US.UTF-8), +// recorded from an empty HOME in a scratch directory holding only a four-line +// sample.c: vim and nvim editing sample.c (`vim -u NONE`, `nvim --clean`), `man ls` +// in less, `ls -laG /usr/bin | head -80`, `top` showing one `sleep` process, +// `seq 5000`, an SGR/256-colour/truecolor test and a Unicode test. + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CODER_WASM = path.join(HERE, 'ghostty-web/ghostty-vt.wasm'); +const OFFICIAL_WASM = path.join(HERE, 'ghostty-vt/ghostty-vt-small.wasm'); +const FIXTURES = path.join(HERE, 'fixtures'); +const COLS = 120; +const ROWS = 40; +const THEME = { scrollbackLimit: 10000, fgColor: 0xd4d4d4, bgColor: 0x1e1e1e }; + +interface Cell { + text: string; + fg: number; + bg: number; + flags: number; + width: number; +} + +type Row = Cell[]; + +interface Engines { + coder: GhosttyTerminal; + official: GhosttyTerminal; +} + +/** + * A fresh instance of each engine. The Coder wasm can crash on a terminal created + * after another one that held multi-codepoint graphemes was freed (ADR 041), so + * every Coder terminal gets its own instance and is never freed. + */ +async function engines(cols = COLS, rows = ROWS, config: object = THEME): Promise { + const coder = (await Ghostty.load(CODER_WASM)).createTerminal(cols, rows, config); + const official = (await Ghostty.load(OFFICIAL_WASM, 'official')).createTerminal( + cols, + rows, + config, + ); + return { coder, official }; +} + +function toRow(line: ReturnType, grapheme: (x: number) => string): Row { + return (line ?? []).map((c, x) => ({ + text: c.grapheme_len > 0 ? grapheme(x) : c.codepoint ? String.fromCodePoint(c.codepoint) : '', + fg: (c.fg_r << 16) | (c.fg_g << 8) | c.fg_b, + bg: (c.bg_r << 16) | (c.bg_g << 8) | c.bg_b, + flags: c.flags, + width: c.width, + })); +} + +/** The active screen as the renderer reads it: one getViewport per frame. */ +function screen(t: GhosttyTerminal): { grid: Row[]; cursor: { x: number; y: number } } { + t.update(); + const { cols, rows } = t.getDimensions(); + const cells = t.getViewport(); + const grid: Row[] = []; + for (let y = 0; y < rows; y++) { + grid.push(toRow(cells.slice(y * cols, (y + 1) * cols), (x) => t.getGraphemeString(y, x))); + } + const { x, y } = t.getCursor(); + t.markClean(); + return { grid, cursor: { x, y } }; +} + +const activeRow = (t: GhosttyTerminal, y: number) => + toRow(t.getLine(y), (x) => t.getGraphemeString(y, x)); +const scrollbackRow = (t: GhosttyTerminal, i: number) => + toRow(t.getScrollbackLine(i), (x) => t.getScrollbackGraphemeString(i, x)); + +/** The rows a renderer shows at viewportY = k, the way renderer.ts picks them. */ +function shownRows(t: GhosttyTerminal, k: number): Row[] { + t.setViewportOffset?.(k); + const sb = t.getScrollbackLength(); + const rows: Row[] = []; + for (let y = 0; y < t.rows; y++) { + rows.push(y < k ? scrollbackRow(t, sb - k + y) : activeRow(t, y - k)); + } + return rows; +} + +const text = (row: Row) => + row + .map((c) => (c.width === 0 ? '' : c.text || ' ')) + .join('') + .trimEnd(); + +interface Difference { + y: number; + x: number; + field: keyof Cell; + coder: string | number; + official: string | number; +} + +function diffRows(coder: Row[], official: Row[]): Difference[] { + const out: Difference[] = []; + const rows = Math.max(coder.length, official.length); + for (let y = 0; y < rows; y++) { + const a = coder[y] ?? []; + const b = official[y] ?? []; + for (let x = 0; x < Math.max(a.length, b.length); x++) { + for (const field of ['text', 'fg', 'bg', 'flags', 'width'] as const) { + if (a[x]?.[field] !== b[x]?.[field]) { + out.push({ y, x, field, coder: a[x]?.[field] ?? '-', official: b[x]?.[field] ?? '-' }); + } + } + } + } + return out; +} + +/** + * The one listed difference (ADR 041, ADR 042): Ghostty at 05dcf887 clusters the + * Hindi conjunct स्ते (U+0938 U+094D U+0924 U+0947) as one width-2 cell, where the + * Coder wasm keeps two width-1 cells, स् and ते. Both take two columns, so nothing + * after it moves. + */ +function isHindiConjunct(d: Difference, coder: Row[], official: Row[]): boolean { + const a = coder[d.y]; + const b = official[d.y]; + const lead = (x: number) => + a[x]?.text === 'स्' && a[x]?.width === 1 && b[x]?.text === 'स्ते' && b[x]?.width === 2; + const tail = (x: number) => + a[x]?.text === 'ते' && a[x]?.width === 1 && b[x]?.text === '' && b[x]?.width === 0; + return (d.field === 'text' || d.field === 'width') && (lead(d.x) || (lead(d.x - 1) && tail(d.x))); +} + +interface ParityResult { + checkpoints: number; + cells: number; + unexpected: Difference[]; + known: number; + cursorDiffs: number; +} + +/** Feeds a capture to both engines in `chunk`-byte writes, comparing every 512 bytes and at the end. */ +async function replay(name: string, chunk: number): Promise { + const data = new Uint8Array(fs.readFileSync(path.join(FIXTURES, `${name}.bin`))); + const { coder, official } = await engines(); + const result: ParityResult = { + checkpoints: 0, + cells: 0, + unexpected: [], + known: 0, + cursorDiffs: 0, + }; + const compare = () => { + const a = screen(coder); + const b = screen(official); + result.checkpoints++; + result.cells += COLS * ROWS; + for (const d of diffRows(a.grid, b.grid)) { + if (isHindiConjunct(d, a.grid, b.grid)) result.known++; + else if (result.unexpected.length < 10) result.unexpected.push(d); + } + if (a.cursor.x !== b.cursor.x || a.cursor.y !== b.cursor.y) result.cursorDiffs++; + }; + for (let i = 0; i < data.length; i += chunk) { + const piece = data.subarray(i, Math.min(i + chunk, data.length)); + coder.write(piece); + official.write(piece); + if ((i + chunk) % 512 === 0) compare(); + } + compare(); + official.free(); + return result; +} + +const CAPTURES = ['ls', 'vim', 'nvim', 'less', 'top', 'sgr', 'uni', 'seq']; + +describe('engine parity on PTY captures at 120x40', () => { + for (const name of CAPTURES) { + for (const chunk of [512, 1]) { + test(`${name}, ${chunk}-byte writes`, async () => { + const result = await replay(name, chunk); + expect(result.checkpoints).toBeGreaterThan(0); + expect(result.unexpected).toEqual([]); + expect(result.cursorDiffs).toBe(0); + // The Hindi conjunct is the one listed difference, and only uni.bin has it. + if (name === 'uni') expect(result.known).toBeGreaterThan(0); + else expect(result.known).toBe(0); + }); + } + } +}); + +describe('engine parity on soft wraps', () => { + test('isRowWrapped marks the rows that continue the row above', async () => { + const { coder, official } = await engines(10, 6); + const write = (data: string) => { + coder.write(data); + official.write(data); + }; + const wrapped = (t: GhosttyTerminal) => Array.from({ length: 6 }, (_, y) => t.isRowWrapped(y)); + write(`${'W'.repeat(25)}\r\nshort`); + expect(wrapped(coder)).toEqual([false, true, true, false, false, false]); + expect(wrapped(official)).toEqual(wrapped(coder)); + // A line filling the row exactly, with the cursor parked at the margin, does not wrap yet. + write(`\r\n${'X'.repeat(10)}`); + expect(wrapped(official)).toEqual(wrapped(coder)); + write('Y'); + expect(wrapped(official)).toEqual(wrapped(coder)); + official.free(); + }); +}); + +describe('engine parity on scrolled views', () => { + const lines = (from: number, to: number) => { + let s = ''; + for (let i = from; i <= to; i++) { + s += `\x1b[38;5;${i % 256}mline ${i}\x1b[0m ${'x'.repeat(i % 50)}\r\n`; + } + return s; + }; + // Unlimited scrollback on both, so both keep the same rows. + const config = { ...THEME, scrollbackLimit: 0 }; + + test('getLine, getScrollbackLine and the shown rows match at several offsets', async () => { + const { coder, official } = await engines(80, 24, config); + coder.write(lines(1, 300)); + official.write(lines(1, 300)); + expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); + const sb = coder.getScrollbackLength(); + for (const k of [0, 1, 5, 23, 24, 25, 100, sb]) { + expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); + for (const y of [0, 10, 23]) { + expect(diffRows([activeRow(coder, y)], [activeRow(official, y)])).toEqual([]); + } + } + official.setViewportOffset?.(40); + for (let i = 0; i < sb; i += 7) { + expect(diffRows([scrollbackRow(coder, i)], [scrollbackRow(official, i)])).toEqual([]); + } + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + official.free(); + }); + + test('a write while scrolled snaps to the bottom, and a stale offset moves back', async () => { + const { coder, official } = await engines(80, 24, config); + coder.write(lines(1, 200)); + official.write(lines(1, 200)); + shownRows(official, 30); + coder.write(lines(201, 210)); + official.write(lines(201, 210)); + expect(official.isRowDirty(0)).toBe(true); + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + // The awaitingEcho render passes the stale viewportY. + expect(diffRows(shownRows(coder, 30), shownRows(official, 30))).toEqual([]); + expect(diffRows(shownRows(coder, 0), shownRows(official, 0))).toEqual([]); + official.free(); + }); + + test('a resize while scrolled reflows the same rows', async () => { + const { coder, official } = await engines(80, 24, config); + coder.write(lines(1, 200)); + official.write(lines(1, 200)); + shownRows(official, 30); + // Shrinking rewraps the long lines on both engines. + coder.resize(60, 20); + official.resize(60, 20); + expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); + expect(diffRows(shownRows(coder, 30), shownRows(official, 30))).toEqual([]); + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + // Growing unwraps them again. Known difference: after growing, the Coder wasm's + // getScrollbackLine returns stale cells past the old width (e.g. "line 1 x" then + // garbage codepoints, or "line 142" with 48 x's instead of 42), so the official + // engine is checked against the text that was written instead. + shownRows(official, 30); + coder.resize(100, 30); + official.resize(100, 30); + expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); + const sb = official.getScrollbackLength(); + const written = (i: number) => `line ${i} ${'x'.repeat(i % 50)}`.trimEnd(); + const expected = Array.from({ length: 30 }, (_, y) => written(sb - 30 + y + 1)); + expect(shownRows(official, 30).map(text)).toEqual(expected); + expect(shownRows(coder, 30).map(text)).not.toEqual(expected); + for (let i = 0; i < sb; i++) expect(text(scrollbackRow(official, i))).toBe(written(i + 1)); + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + official.free(); + }); + + test('a page read between a write and the next render loses no dirty rows', async () => { + const { coder, official } = await engines(80, 24, config); + coder.write(lines(1, 100)); + official.write(lines(1, 100)); + screen(official); + coder.write('\x1b[5;3HCHANGED'); + official.write('\x1b[5;3HCHANGED'); + // A selection copy or buffer read above the viewport before the frame. + expect(diffRows([scrollbackRow(coder, 3)], [scrollbackRow(official, 3)])).toEqual([]); + expect(official.isRowDirty(4)).toBe(true); + expect(official.isRowDirty(10)).toBe(false); + expect(text(activeRow(official, 4))).toContain('CHANGED'); + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + official.free(); + }); + + test('stepping the offset through the screen keeps pages across moves, not writes', async () => { + const { coder, official } = await engines(80, 24, config); + coder.write(lines(1, 300)); + official.write(lines(1, 300)); + const sb = coder.getScrollbackLength(); + const walk = (ks: number[]) => { + for (const k of ks) { + expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); + // The renderer's active rows come from one getViewport per frame. + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + } + }; + const steps = [0, 1, 2, 12, 22, 23, 24, 25, 47, 48, 49, 100, sb, 30, 23, 1, 0]; + walk(steps); + // An in-place write below the view, then the same walk: no page from before it. + for (const t of [coder, official]) { + t.setViewportOffset?.(10); + t.write('\x1b[24;1HAFTER\x1b[1;1HTOP'); + } + walk(steps); + official.free(); + }); + + test('a page read after an in-place write does not return stale cells', async () => { + const { coder, official } = await engines(80, 24, config); + coder.write(lines(1, 100)); + official.write(lines(1, 100)); + // Scrolled by 10: the bottom 10 active rows are below the view, read as a page. + shownRows(official, 10); + expect(text(activeRow(official, 23))).toBe(text(activeRow(coder, 23))); + coder.write('\x1b[24;1HIN PLACE'); + official.write('\x1b[24;1HIN PLACE'); + expect(diffRows(shownRows(coder, 10), shownRows(official, 10))).toEqual([]); + expect(text(activeRow(official, 23))).toStartWith('IN PLACE'); + expect(diffRows([activeRow(coder, 23)], [activeRow(official, 23)])).toEqual([]); + official.free(); + }); +}); + +describe('coder/ghostty-web#180 stress payload on the official engine', () => { + // Port of buildStressPayload() from coder/ghostty-web lib/viewport-corruption.test.ts + // (PR #180; MIT, Copyright (c) 2025 Coder, see ghostty-web/LICENSE). + const ESC = '\x1b'; + function buildStressPayload(): string { + const lines: string[] = []; + let n = 0; + const marked = (content: string) => { + const marker = `R00L${n.toString().padStart(3, '0')}`; + lines.push(`${ESC}[38;5;${(n * 17) % 256}m${marker}${ESC}[0m ${content}`); + n++; + }; + const blank = () => { + lines.push(''); + n++; + }; + marked(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`); + marked(`${ESC}[1mTerminal rendering scroll-growth regression${ESC}[0m`); + blank(); + marked(`${ESC}[1m256-color palette${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let c = ''; + for (let i = 0; i < 32; i++) c += `${ESC}[48;5;${row * 32 + i}m ${ESC}[0m`; + marked(c); + } + blank(); + marked(`${ESC}[1mTruecolor gradients${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let c = ''; + for (let i = 0; i < 80; i++) { + const r = Math.floor(Math.sin(i * 0.08 + row) * 127 + 128); + const g = Math.floor(Math.sin(i * 0.08 + row + 2) * 127 + 128); + const b = Math.floor(Math.sin(i * 0.08 + row + 4) * 127 + 128); + c += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`; + } + marked(c); + } + blank(); + marked(`${ESC}[1mCombined styles${ESC}[0m`); + marked( + `${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ` + + `${ESC}[7mReverse${ESC}[0m ${ESC}[9mStrike${ESC}[0m`, + ); + marked( + `${ESC}[1;31mBold Red${ESC}[0m ${ESC}[3;32mItalic Green${ESC}[0m ` + + `${ESC}[4;34mUnderline Blue${ESC}[0m ${ESC}[38;5;201mPalette Pink${ESC}[0m`, + ); + blank(); + marked(`${ESC}[1mSoft-wrap metadata seed${ESC}[0m`); + marked('wrap '.repeat(48)); + blank(); + marked(`${ESC}[1mUnicode box drawing${ESC}[0m`); + for (const line of [ + '┌──────────┬──────────┬──────────┐', + '│ Cell A │ Cell B │ Cell C │', + '├──────────┼──────────┼──────────┤', + '│ Cell D │ Cell E │ Cell F │', + '└──────────┴──────────┴──────────┘', + ]) { + marked(line); + } + blank(); + for (let section = 0; section < 8; section++) { + marked(`${ESC}[1mColor grid ${String.fromCharCode(65 + section)}${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let c = ''; + for (let i = 0; i < 70; i++) { + const idx = (section * 64 + row * 8 + i) % 256; + c += + (i + row) % 3 === 0 + ? `${ESC}[38;2;${(idx * 7) % 256};${(idx * 13) % 256};${(idx * 23) % 256}m*${ESC}[0m` + : `${ESC}[38;5;${idx}m*${ESC}[0m`; + } + marked(c); + } + blank(); + } + marked(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`); + marked(`${ESC}[32m✓${ESC}[0m Test complete`); + blank(); + return `${lines.join('\r\n')}\r\n`; + } + + test('0 stale rows over 300 repetitions', async () => { + const cols = 160; + const rows = 39; + const t = (await Ghostty.load(OFFICIAL_WASM, 'official')).createTerminal(cols, rows, { + scrollbackLimit: 10_000_000, + }); + const payload = new TextEncoder().encode(buildStressPayload()); + const failures: string[] = []; + let baseline: string[] | null = null; + for (let rep = 0; rep < 300; rep++) { + t.write(payload); + const { grid } = screen(t); + const shown = grid.map(text); + shown.forEach((row, y) => { + const markers = new Set(row.match(/R\d{2}L\d{3}/g) ?? []); + if (markers.size > 1) failures.push(`rep ${rep} row ${y}: merged ${[...markers]}`); + // isRowWrapped(y + 1): the next row continues this one, so this one wraps. + if (row.length === 0 && y + 1 < rows && t.isRowWrapped(y + 1)) + failures.push(`rep ${rep} row ${y}: blank, wrapped`); + }); + baseline ??= shown; + const changed = shown.findIndex((row, y) => row !== baseline?.[y]); + if (changed !== -1) failures.push(`rep ${rep} row ${changed} differs from rep 0`); + } + expect(failures.slice(0, 5)).toEqual([]); + t.free(); + }); +}); diff --git a/src/client/vendor/fixtures/less.bin b/src/client/vendor/fixtures/less.bin new file mode 100644 index 0000000..c1a82f9 --- /dev/null +++ b/src/client/vendor/fixtures/less.bin @@ -0,0 +1,79 @@ +[?1049h[?1h= LS(1) General Commands Manual LS(1) + +NAME + ls – list directory contents + +SYNOPSIS + ls [-@ABCFGHILOPRSTUWabcdefghiklmnopqrstuvwxy1%,] [--color=when] [-D format] [file ...] + +DESCRIPTION + For each operand that names a file of a type other than directory, ls displays its name as well as any requested, + associated information. For each operand that names a file of type directory, ls displays the names of files + contained within that directory, as well as any requested, associated information. + + If no operands are given, the contents of the current directory are displayed. If more than one operand is + given, non-directory operands are displayed first; directory and non-directory operands are sorted separately and + in lexicographical order. + + The following options are available: + + -@ Display extended attribute keys and sizes in long (-l) output. + + -A Include directory entries whose names begin with a dot (‘.’) except for . and ... Automatically set for + the super-user unless -I is specified. + + -B Force printing of non-printable characters (as defined by ctype(3) and current locale settings) in file + names as \xxx, where xxx is the numeric value of the character in octal. This option is not defined in + IEEE Std 1003.1-2008 (“POSIX.1”). + + -C Force multi-column output; this is the default when output is to a terminal. + + -D format + When printing in the long (-l) format, use format to format the date and time output. The argument + format is a string used by strftime(3). Depending on the choice of format string, this may result in a + different number of columns in the output. This option overrides the -T option. This option is not + defined in IEEE Std 1003.1-2008 (“POSIX.1”). + + -F Display a slash (‘/’) immediately after each pathname that is a directory, an asterisk (‘*’) after each + that is executable, an at sign (‘@’) after each symbolic link, an equals sign (‘=’) after each socket, a + percent sign (‘%’) after each whiteout, and a vertical bar (‘|’) after each that is a FIFO. +:  + -G Enable colorized output. This option is equivalent to defining CLICOLOR or COLORTERM in the environment + and setting --color=auto. (See below.) This functionality can be compiled out by removing the + definition of COLORLS. This option is not defined in IEEE Std 1003.1-2008 (“POSIX.1”). + + -H Symbolic links on the command line are followed. This option is assumed if none of the -F, -d, or -l + options are specified. + + -I Prevent -A from being automatically set for the super-user. This option is not defined in IEEE Std + 1003.1-2008 (“POSIX.1”). + + -L Follow all symbolic links to final target and list the file or directory the link references rather than + the link itself. This option cancels the -P option. + + -O Include the file flags in a long (-l) output. This option is incompatible with IEEE Std 1003.1-2008 + (“POSIX.1”). See chflags(1) for a list of file flags and their meanings. + + -P If argument is a symbolic link, list the link itself rather than the object the link references. This + option cancels the -H and -L options. + + -R Recursively list subdirectories encountered. + + -S Sort by size (largest file first) before sorting the operands in lexicographical order. + + -T When printing in the long (-l) format, display complete time information for the file, including month, + day, hour, minute, second, and year. The -D option gives even more control over the output format. This + option is not defined in IEEE Std 1003.1-2008 (“POSIX.1”). + + -U Use time when file was created for sorting or printing. This option is not defined in IEEE Std + 1003.1-2008 (“POSIX.1”). + + -W Display whiteouts when scanning directories. This option is not defined in IEEE Std 1003.1-2008 + (“POSIX.1”). + + -X When listing recursively, do not descend into directories that would cross file system boundaries. More + specifically, this option will prevent descending into directories that have a different device number. + + -a Include directory entries whose names begin with a dot (‘.’). + +: [?1l>[?1049l \ No newline at end of file diff --git a/src/client/vendor/fixtures/ls.bin b/src/client/vendor/fixtures/ls.bin new file mode 100644 index 0000000..ecccc51 --- /dev/null +++ b/src/client/vendor/fixtures/ls.bin @@ -0,0 +1,81 @@ +total 172192 +drwxr-xr-x 926 root wheel 29632 Sep 24 22:06 . +drwxr-xr-x@ 11 root wheel 352 Sep 24 22:06 .. +-rwxr-xr-x 1 root wheel 346976 Sep 24 22:06 aa +-rwxr-xr-x 16 root wheel 135200 Sep 24 22:06 actool +-rwxr-xr-x 1 root wheel 222304 Sep 24 22:06 aea +-rwxr-xr-x 1 root wheel 170864 Sep 24 22:06 afclip +-rwxr-xr-x 1 root wheel 360864 Sep 24 22:06 afconvert +-rwxr-xr-x 1 root wheel 135712 Sep 24 22:06 afhash +-rwxr-xr-x 1 root wheel 219616 Sep 24 22:06 afida +-rwxr-xr-x 1 root wheel 225664 Sep 24 22:06 afinfo +-rwxr-xr-x 1 root wheel 227328 Sep 24 22:06 afktool +-rwxr-xr-x 1 root wheel 173312 Sep 24 22:06 afplay +-rwxr-xr-x 1 root wheel 596144 Sep 24 22:06 afscexpand +-rwxr-xr-x 1 root wheel 120736 Sep 24 22:06 agentxtrap +-rwxr-xr-x 16 root wheel 135200 Sep 24 22:06 agvtool +-rwxr-xr-x 15 root wheel 120 Sep 24 22:06 alias +-rwxr-xr-x 1 root wheel 173072 Sep 24 22:06 app-sso +-rwxr-xr-x 1 root wheel 168848 Sep 24 22:06 applesingle +-rwxr-xr-x 1 root wheel 102208 Sep 24 22:06 apply +-r-xr-xr-x 4 root wheel 32136 Sep 24 22:06 apropos +-rwxr-xr-x 78 root wheel 118640 Sep 24 22:06 ar +-rwxr-xr-x 1 root wheel 120992 Sep 24 22:06 arch +-rwxr-xr-x 1 root wheel 205104 Sep 24 22:06 archiveutil +-rwxr-xr-x 78 root wheel 118640 Sep 24 22:06 as +-rwxr-xr-x 78 root wheel 118640 Sep 24 22:06 asa +-rwxr-xr-x 1 root wheel 171472 Sep 24 22:06 AssetCacheLocatorUtil +-rwxr-xr-x 1 root wheel 227120 Sep 24 22:06 AssetCacheManagerUtil +-rwxr-xr-x 1 root wheel 172560 Sep 24 22:06 AssetCacheTetheratorUtil +-rwxr-xr-x 1 root wheel 4022880 Sep 24 22:06 assetutil +-r-sr-xr-x 2 root wheel 170416 Sep 24 22:06 at +-rwxr-xr-x 1 root wheel 211392 Sep 24 22:06 atos +-r-sr-xr-x 2 root wheel 170416 Sep 24 22:06 atq +-r-sr-xr-x 2 root wheel 170416 Sep 24 22:06 atrm +-rwxr-xr-x 1 root wheel 137776 Sep 24 22:06 atsutil +-rwxr-xr-x 1 root wheel 136128 Sep 24 22:06 automationmodetool +-rwxr-xr-x 1 root wheel 170752 Sep 24 22:06 automator +lrwxr-xr-x 1 root wheel 18 Sep 24 22:06 auval -> /usr/bin/auvaltool +-rwxr-xr-x 1 root wheel 399488 Sep 24 22:06 auvaltool +-rwxr-xr-x 1 root wheel 565600 Sep 24 22:06 avbanalyse +-rwxr-xr-x 1 root wheel 171376 Sep 24 22:06 avbcapture +-rwxr-xr-x 1 root wheel 238064 Sep 24 22:06 avbdiagnose +-rwxr-xr-x 1 root wheel 419616 Sep 24 22:06 avbutil +-rwxr-xr-x 1 root wheel 226656 Sep 24 22:06 avconvert +-rwxr-xr-x 1 root wheel 247296 Sep 24 22:06 avmediainfo +-rwxr-xr-x 1 root wheel 136864 Sep 24 22:06 avmetareadwrite +-rwxr-xr-x 1 root wheel 301568 Sep 24 22:06 awk +-rwxr-xr-x 3 root wheel 136192 Sep 24 22:06 b64decode +-rwxr-xr-x 3 root wheel 136192 Sep 24 22:06 b64encode +-rwxr-xr-x 1 root wheel 134224 Sep 24 22:06 banner +-rwxr-xr-x 3 root wheel 136192 Sep 24 22:06 base64 +-rwxr-xr-x 1 root wheel 101376 Sep 24 22:06 basename +-r-xr-xr-x 1 root wheel 6574 Sep 24 22:06 bashbug +-r-sr-xr-x 2 root wheel 170416 Sep 24 22:06 batch +-rwxr-xr-x 2 root wheel 332928 Sep 24 22:06 bc +-rwxr-xr-x 15 root wheel 120 Sep 24 22:06 bg +-rwxr-xr-x 1 root wheel 169280 Sep 24 22:06 binhex +-r-xr-xr-x 2 root wheel 3850 Sep 24 22:06 binhex.pl +-r-xr-xr-x 2 root wheel 3850 Sep 24 22:06 binhex5.34.pl +-rwxr-xr-x 3 root wheel 136192 Sep 24 22:06 bintrans +-rwxr-xr-x 1 root wheel 342624 Sep 24 22:06 bioutil +-rwxr-xr-x 78 root wheel 118640 Sep 24 22:06 bison +-rwxr-xr-x 1 root wheel 1958 Sep 24 22:06 bitesize.d +-rwxr-xr-x 78 root wheel 118640 Sep 24 22:06 bm4 +-rwxr-xr-x 1 root wheel 196320 Sep 24 22:06 bputil +-rwxr-xr-x 1 root wheel 542368 Sep 24 22:06 brctl +-rwxr-xr-x 1 root wheel 274448 Sep 24 22:06 bsdtar +-rwxr-xr-x 1 root wheel 134672 Sep 24 22:06 bspatch +-r-xr-xr-x 1 root wheel 580 Sep 24 22:06 bundle +-r-xr-xr-x 1 root wheel 582 Sep 24 22:06 bundler +-rwxr-xr-x 3 root wheel 168912 Sep 24 22:06 bunzip2 +-rwxr-xr-x 3 root wheel 168912 Sep 24 22:06 bzcat +lrwxr-xr-x 1 root wheel 6 Sep 24 22:06 bzcmp -> bzdiff +-rwxr-xr-x 1 root wheel 2121 Sep 24 22:06 bzdiff +-rwxr-xr-x 7 root wheel 153376 Sep 24 22:06 bzegrep +-rwxr-xr-x 2 root wheel 153376 Sep 24 22:06 bzfgrep +-rwxr-xr-x 7 root wheel 153376 Sep 24 22:06 bzgrep +-rwxr-xr-x 3 root wheel 168912 Sep 24 22:06 bzip2 +-rwxr-xr-x 1 root wheel 101696 Sep 24 22:06 bzip2recover +lrwxr-xr-x 1 root wheel 6 Sep 24 22:06 bzless -> bzmore +ls: stdout: Undefined error: 0 diff --git a/src/client/vendor/fixtures/nvim.bin b/src/client/vendor/fixtures/nvim.bin new file mode 100644 index 0000000..e033732 --- /dev/null +++ b/src/client/vendor/fixtures/nvim.bin @@ -0,0 +1,117 @@ +[?1049h[?1h=[?2004h[?69$p[?2026$p[?2027$p[?2031$p[?2048$p[4:3mP$qm\[?u]11;?[?25h]11;?[?25l(B + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [1 q[?1002h[?1006h[?25h[?25l[?1004h[?25h[?25l(BE1568: Terminal did not respond to DSR request for 'background' color. Startup may be slower. :help 'ttyfast'(Bline one +(Bint(B main((Bvoid(B) { (Breturn(B (B0(B; } + tabbed +the end +(B~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +(Bsample.c 1,1 All[2 q[2 q(Bline one +(Bint(B main((Bvoid(B) { (Breturn(B (B0(B; } + tabbed +the end +(B~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +(Bsample.c 1,1 All +(BE1568: Terminal did not respond to DSR request for 'background' color. Startup may be slower. :help 'ttyfast'(B[?25h[?25l(B23,1-8(Bx [?25h[?25l dl[4 q[?25h[?25l tabbed(B[+] 3,1 [2 q [?25h[?25l(B-- INSERT --(Binserted text(B4[6 q[?25h[?25l(B (B3[2 q[?25h[?25l (B:q![2 q[?1002l[?1006l [?25h[?25l[2 q[?1002h[?1006h[?25h[?25l[2 q[?1002l[?1006l(B[?25h[0 q[?1l>[>4;0m[?2004l[?1004l[?25h[?25l[?1049l[?25h \ No newline at end of file diff --git a/src/client/vendor/fixtures/seq.bin b/src/client/vendor/fixtures/seq.bin new file mode 100644 index 0000000..7d17149 --- /dev/null +++ b/src/client/vendor/fixtures/seq.bin @@ -0,0 +1,5000 @@ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397 +1398 +1399 +1400 +1401 +1402 +1403 +1404 +1405 +1406 +1407 +1408 +1409 +1410 +1411 +1412 +1413 +1414 +1415 +1416 +1417 +1418 +1419 +1420 +1421 +1422 +1423 +1424 +1425 +1426 +1427 +1428 +1429 +1430 +1431 +1432 +1433 +1434 +1435 +1436 +1437 +1438 +1439 +1440 +1441 +1442 +1443 +1444 +1445 +1446 +1447 +1448 +1449 +1450 +1451 +1452 +1453 +1454 +1455 +1456 +1457 +1458 +1459 +1460 +1461 +1462 +1463 +1464 +1465 +1466 +1467 +1468 +1469 +1470 +1471 +1472 +1473 +1474 +1475 +1476 +1477 +1478 +1479 +1480 +1481 +1482 +1483 +1484 +1485 +1486 +1487 +1488 +1489 +1490 +1491 +1492 +1493 +1494 +1495 +1496 +1497 +1498 +1499 +1500 +1501 +1502 +1503 +1504 +1505 +1506 +1507 +1508 +1509 +1510 +1511 +1512 +1513 +1514 +1515 +1516 +1517 +1518 +1519 +1520 +1521 +1522 +1523 +1524 +1525 +1526 +1527 +1528 +1529 +1530 +1531 +1532 +1533 +1534 +1535 +1536 +1537 +1538 +1539 +1540 +1541 +1542 +1543 +1544 +1545 +1546 +1547 +1548 +1549 +1550 +1551 +1552 +1553 +1554 +1555 +1556 +1557 +1558 +1559 +1560 +1561 +1562 +1563 +1564 +1565 +1566 +1567 +1568 +1569 +1570 +1571 +1572 +1573 +1574 +1575 +1576 +1577 +1578 +1579 +1580 +1581 +1582 +1583 +1584 +1585 +1586 +1587 +1588 +1589 +1590 +1591 +1592 +1593 +1594 +1595 +1596 +1597 +1598 +1599 +1600 +1601 +1602 +1603 +1604 +1605 +1606 +1607 +1608 +1609 +1610 +1611 +1612 +1613 +1614 +1615 +1616 +1617 +1618 +1619 +1620 +1621 +1622 +1623 +1624 +1625 +1626 +1627 +1628 +1629 +1630 +1631 +1632 +1633 +1634 +1635 +1636 +1637 +1638 +1639 +1640 +1641 +1642 +1643 +1644 +1645 +1646 +1647 +1648 +1649 +1650 +1651 +1652 +1653 +1654 +1655 +1656 +1657 +1658 +1659 +1660 +1661 +1662 +1663 +1664 +1665 +1666 +1667 +1668 +1669 +1670 +1671 +1672 +1673 +1674 +1675 +1676 +1677 +1678 +1679 +1680 +1681 +1682 +1683 +1684 +1685 +1686 +1687 +1688 +1689 +1690 +1691 +1692 +1693 +1694 +1695 +1696 +1697 +1698 +1699 +1700 +1701 +1702 +1703 +1704 +1705 +1706 +1707 +1708 +1709 +1710 +1711 +1712 +1713 +1714 +1715 +1716 +1717 +1718 +1719 +1720 +1721 +1722 +1723 +1724 +1725 +1726 +1727 +1728 +1729 +1730 +1731 +1732 +1733 +1734 +1735 +1736 +1737 +1738 +1739 +1740 +1741 +1742 +1743 +1744 +1745 +1746 +1747 +1748 +1749 +1750 +1751 +1752 +1753 +1754 +1755 +1756 +1757 +1758 +1759 +1760 +1761 +1762 +1763 +1764 +1765 +1766 +1767 +1768 +1769 +1770 +1771 +1772 +1773 +1774 +1775 +1776 +1777 +1778 +1779 +1780 +1781 +1782 +1783 +1784 +1785 +1786 +1787 +1788 +1789 +1790 +1791 +1792 +1793 +1794 +1795 +1796 +1797 +1798 +1799 +1800 +1801 +1802 +1803 +1804 +1805 +1806 +1807 +1808 +1809 +1810 +1811 +1812 +1813 +1814 +1815 +1816 +1817 +1818 +1819 +1820 +1821 +1822 +1823 +1824 +1825 +1826 +1827 +1828 +1829 +1830 +1831 +1832 +1833 +1834 +1835 +1836 +1837 +1838 +1839 +1840 +1841 +1842 +1843 +1844 +1845 +1846 +1847 +1848 +1849 +1850 +1851 +1852 +1853 +1854 +1855 +1856 +1857 +1858 +1859 +1860 +1861 +1862 +1863 +1864 +1865 +1866 +1867 +1868 +1869 +1870 +1871 +1872 +1873 +1874 +1875 +1876 +1877 +1878 +1879 +1880 +1881 +1882 +1883 +1884 +1885 +1886 +1887 +1888 +1889 +1890 +1891 +1892 +1893 +1894 +1895 +1896 +1897 +1898 +1899 +1900 +1901 +1902 +1903 +1904 +1905 +1906 +1907 +1908 +1909 +1910 +1911 +1912 +1913 +1914 +1915 +1916 +1917 +1918 +1919 +1920 +1921 +1922 +1923 +1924 +1925 +1926 +1927 +1928 +1929 +1930 +1931 +1932 +1933 +1934 +1935 +1936 +1937 +1938 +1939 +1940 +1941 +1942 +1943 +1944 +1945 +1946 +1947 +1948 +1949 +1950 +1951 +1952 +1953 +1954 +1955 +1956 +1957 +1958 +1959 +1960 +1961 +1962 +1963 +1964 +1965 +1966 +1967 +1968 +1969 +1970 +1971 +1972 +1973 +1974 +1975 +1976 +1977 +1978 +1979 +1980 +1981 +1982 +1983 +1984 +1985 +1986 +1987 +1988 +1989 +1990 +1991 +1992 +1993 +1994 +1995 +1996 +1997 +1998 +1999 +2000 +2001 +2002 +2003 +2004 +2005 +2006 +2007 +2008 +2009 +2010 +2011 +2012 +2013 +2014 +2015 +2016 +2017 +2018 +2019 +2020 +2021 +2022 +2023 +2024 +2025 +2026 +2027 +2028 +2029 +2030 +2031 +2032 +2033 +2034 +2035 +2036 +2037 +2038 +2039 +2040 +2041 +2042 +2043 +2044 +2045 +2046 +2047 +2048 +2049 +2050 +2051 +2052 +2053 +2054 +2055 +2056 +2057 +2058 +2059 +2060 +2061 +2062 +2063 +2064 +2065 +2066 +2067 +2068 +2069 +2070 +2071 +2072 +2073 +2074 +2075 +2076 +2077 +2078 +2079 +2080 +2081 +2082 +2083 +2084 +2085 +2086 +2087 +2088 +2089 +2090 +2091 +2092 +2093 +2094 +2095 +2096 +2097 +2098 +2099 +2100 +2101 +2102 +2103 +2104 +2105 +2106 +2107 +2108 +2109 +2110 +2111 +2112 +2113 +2114 +2115 +2116 +2117 +2118 +2119 +2120 +2121 +2122 +2123 +2124 +2125 +2126 +2127 +2128 +2129 +2130 +2131 +2132 +2133 +2134 +2135 +2136 +2137 +2138 +2139 +2140 +2141 +2142 +2143 +2144 +2145 +2146 +2147 +2148 +2149 +2150 +2151 +2152 +2153 +2154 +2155 +2156 +2157 +2158 +2159 +2160 +2161 +2162 +2163 +2164 +2165 +2166 +2167 +2168 +2169 +2170 +2171 +2172 +2173 +2174 +2175 +2176 +2177 +2178 +2179 +2180 +2181 +2182 +2183 +2184 +2185 +2186 +2187 +2188 +2189 +2190 +2191 +2192 +2193 +2194 +2195 +2196 +2197 +2198 +2199 +2200 +2201 +2202 +2203 +2204 +2205 +2206 +2207 +2208 +2209 +2210 +2211 +2212 +2213 +2214 +2215 +2216 +2217 +2218 +2219 +2220 +2221 +2222 +2223 +2224 +2225 +2226 +2227 +2228 +2229 +2230 +2231 +2232 +2233 +2234 +2235 +2236 +2237 +2238 +2239 +2240 +2241 +2242 +2243 +2244 +2245 +2246 +2247 +2248 +2249 +2250 +2251 +2252 +2253 +2254 +2255 +2256 +2257 +2258 +2259 +2260 +2261 +2262 +2263 +2264 +2265 +2266 +2267 +2268 +2269 +2270 +2271 +2272 +2273 +2274 +2275 +2276 +2277 +2278 +2279 +2280 +2281 +2282 +2283 +2284 +2285 +2286 +2287 +2288 +2289 +2290 +2291 +2292 +2293 +2294 +2295 +2296 +2297 +2298 +2299 +2300 +2301 +2302 +2303 +2304 +2305 +2306 +2307 +2308 +2309 +2310 +2311 +2312 +2313 +2314 +2315 +2316 +2317 +2318 +2319 +2320 +2321 +2322 +2323 +2324 +2325 +2326 +2327 +2328 +2329 +2330 +2331 +2332 +2333 +2334 +2335 +2336 +2337 +2338 +2339 +2340 +2341 +2342 +2343 +2344 +2345 +2346 +2347 +2348 +2349 +2350 +2351 +2352 +2353 +2354 +2355 +2356 +2357 +2358 +2359 +2360 +2361 +2362 +2363 +2364 +2365 +2366 +2367 +2368 +2369 +2370 +2371 +2372 +2373 +2374 +2375 +2376 +2377 +2378 +2379 +2380 +2381 +2382 +2383 +2384 +2385 +2386 +2387 +2388 +2389 +2390 +2391 +2392 +2393 +2394 +2395 +2396 +2397 +2398 +2399 +2400 +2401 +2402 +2403 +2404 +2405 +2406 +2407 +2408 +2409 +2410 +2411 +2412 +2413 +2414 +2415 +2416 +2417 +2418 +2419 +2420 +2421 +2422 +2423 +2424 +2425 +2426 +2427 +2428 +2429 +2430 +2431 +2432 +2433 +2434 +2435 +2436 +2437 +2438 +2439 +2440 +2441 +2442 +2443 +2444 +2445 +2446 +2447 +2448 +2449 +2450 +2451 +2452 +2453 +2454 +2455 +2456 +2457 +2458 +2459 +2460 +2461 +2462 +2463 +2464 +2465 +2466 +2467 +2468 +2469 +2470 +2471 +2472 +2473 +2474 +2475 +2476 +2477 +2478 +2479 +2480 +2481 +2482 +2483 +2484 +2485 +2486 +2487 +2488 +2489 +2490 +2491 +2492 +2493 +2494 +2495 +2496 +2497 +2498 +2499 +2500 +2501 +2502 +2503 +2504 +2505 +2506 +2507 +2508 +2509 +2510 +2511 +2512 +2513 +2514 +2515 +2516 +2517 +2518 +2519 +2520 +2521 +2522 +2523 +2524 +2525 +2526 +2527 +2528 +2529 +2530 +2531 +2532 +2533 +2534 +2535 +2536 +2537 +2538 +2539 +2540 +2541 +2542 +2543 +2544 +2545 +2546 +2547 +2548 +2549 +2550 +2551 +2552 +2553 +2554 +2555 +2556 +2557 +2558 +2559 +2560 +2561 +2562 +2563 +2564 +2565 +2566 +2567 +2568 +2569 +2570 +2571 +2572 +2573 +2574 +2575 +2576 +2577 +2578 +2579 +2580 +2581 +2582 +2583 +2584 +2585 +2586 +2587 +2588 +2589 +2590 +2591 +2592 +2593 +2594 +2595 +2596 +2597 +2598 +2599 +2600 +2601 +2602 +2603 +2604 +2605 +2606 +2607 +2608 +2609 +2610 +2611 +2612 +2613 +2614 +2615 +2616 +2617 +2618 +2619 +2620 +2621 +2622 +2623 +2624 +2625 +2626 +2627 +2628 +2629 +2630 +2631 +2632 +2633 +2634 +2635 +2636 +2637 +2638 +2639 +2640 +2641 +2642 +2643 +2644 +2645 +2646 +2647 +2648 +2649 +2650 +2651 +2652 +2653 +2654 +2655 +2656 +2657 +2658 +2659 +2660 +2661 +2662 +2663 +2664 +2665 +2666 +2667 +2668 +2669 +2670 +2671 +2672 +2673 +2674 +2675 +2676 +2677 +2678 +2679 +2680 +2681 +2682 +2683 +2684 +2685 +2686 +2687 +2688 +2689 +2690 +2691 +2692 +2693 +2694 +2695 +2696 +2697 +2698 +2699 +2700 +2701 +2702 +2703 +2704 +2705 +2706 +2707 +2708 +2709 +2710 +2711 +2712 +2713 +2714 +2715 +2716 +2717 +2718 +2719 +2720 +2721 +2722 +2723 +2724 +2725 +2726 +2727 +2728 +2729 +2730 +2731 +2732 +2733 +2734 +2735 +2736 +2737 +2738 +2739 +2740 +2741 +2742 +2743 +2744 +2745 +2746 +2747 +2748 +2749 +2750 +2751 +2752 +2753 +2754 +2755 +2756 +2757 +2758 +2759 +2760 +2761 +2762 +2763 +2764 +2765 +2766 +2767 +2768 +2769 +2770 +2771 +2772 +2773 +2774 +2775 +2776 +2777 +2778 +2779 +2780 +2781 +2782 +2783 +2784 +2785 +2786 +2787 +2788 +2789 +2790 +2791 +2792 +2793 +2794 +2795 +2796 +2797 +2798 +2799 +2800 +2801 +2802 +2803 +2804 +2805 +2806 +2807 +2808 +2809 +2810 +2811 +2812 +2813 +2814 +2815 +2816 +2817 +2818 +2819 +2820 +2821 +2822 +2823 +2824 +2825 +2826 +2827 +2828 +2829 +2830 +2831 +2832 +2833 +2834 +2835 +2836 +2837 +2838 +2839 +2840 +2841 +2842 +2843 +2844 +2845 +2846 +2847 +2848 +2849 +2850 +2851 +2852 +2853 +2854 +2855 +2856 +2857 +2858 +2859 +2860 +2861 +2862 +2863 +2864 +2865 +2866 +2867 +2868 +2869 +2870 +2871 +2872 +2873 +2874 +2875 +2876 +2877 +2878 +2879 +2880 +2881 +2882 +2883 +2884 +2885 +2886 +2887 +2888 +2889 +2890 +2891 +2892 +2893 +2894 +2895 +2896 +2897 +2898 +2899 +2900 +2901 +2902 +2903 +2904 +2905 +2906 +2907 +2908 +2909 +2910 +2911 +2912 +2913 +2914 +2915 +2916 +2917 +2918 +2919 +2920 +2921 +2922 +2923 +2924 +2925 +2926 +2927 +2928 +2929 +2930 +2931 +2932 +2933 +2934 +2935 +2936 +2937 +2938 +2939 +2940 +2941 +2942 +2943 +2944 +2945 +2946 +2947 +2948 +2949 +2950 +2951 +2952 +2953 +2954 +2955 +2956 +2957 +2958 +2959 +2960 +2961 +2962 +2963 +2964 +2965 +2966 +2967 +2968 +2969 +2970 +2971 +2972 +2973 +2974 +2975 +2976 +2977 +2978 +2979 +2980 +2981 +2982 +2983 +2984 +2985 +2986 +2987 +2988 +2989 +2990 +2991 +2992 +2993 +2994 +2995 +2996 +2997 +2998 +2999 +3000 +3001 +3002 +3003 +3004 +3005 +3006 +3007 +3008 +3009 +3010 +3011 +3012 +3013 +3014 +3015 +3016 +3017 +3018 +3019 +3020 +3021 +3022 +3023 +3024 +3025 +3026 +3027 +3028 +3029 +3030 +3031 +3032 +3033 +3034 +3035 +3036 +3037 +3038 +3039 +3040 +3041 +3042 +3043 +3044 +3045 +3046 +3047 +3048 +3049 +3050 +3051 +3052 +3053 +3054 +3055 +3056 +3057 +3058 +3059 +3060 +3061 +3062 +3063 +3064 +3065 +3066 +3067 +3068 +3069 +3070 +3071 +3072 +3073 +3074 +3075 +3076 +3077 +3078 +3079 +3080 +3081 +3082 +3083 +3084 +3085 +3086 +3087 +3088 +3089 +3090 +3091 +3092 +3093 +3094 +3095 +3096 +3097 +3098 +3099 +3100 +3101 +3102 +3103 +3104 +3105 +3106 +3107 +3108 +3109 +3110 +3111 +3112 +3113 +3114 +3115 +3116 +3117 +3118 +3119 +3120 +3121 +3122 +3123 +3124 +3125 +3126 +3127 +3128 +3129 +3130 +3131 +3132 +3133 +3134 +3135 +3136 +3137 +3138 +3139 +3140 +3141 +3142 +3143 +3144 +3145 +3146 +3147 +3148 +3149 +3150 +3151 +3152 +3153 +3154 +3155 +3156 +3157 +3158 +3159 +3160 +3161 +3162 +3163 +3164 +3165 +3166 +3167 +3168 +3169 +3170 +3171 +3172 +3173 +3174 +3175 +3176 +3177 +3178 +3179 +3180 +3181 +3182 +3183 +3184 +3185 +3186 +3187 +3188 +3189 +3190 +3191 +3192 +3193 +3194 +3195 +3196 +3197 +3198 +3199 +3200 +3201 +3202 +3203 +3204 +3205 +3206 +3207 +3208 +3209 +3210 +3211 +3212 +3213 +3214 +3215 +3216 +3217 +3218 +3219 +3220 +3221 +3222 +3223 +3224 +3225 +3226 +3227 +3228 +3229 +3230 +3231 +3232 +3233 +3234 +3235 +3236 +3237 +3238 +3239 +3240 +3241 +3242 +3243 +3244 +3245 +3246 +3247 +3248 +3249 +3250 +3251 +3252 +3253 +3254 +3255 +3256 +3257 +3258 +3259 +3260 +3261 +3262 +3263 +3264 +3265 +3266 +3267 +3268 +3269 +3270 +3271 +3272 +3273 +3274 +3275 +3276 +3277 +3278 +3279 +3280 +3281 +3282 +3283 +3284 +3285 +3286 +3287 +3288 +3289 +3290 +3291 +3292 +3293 +3294 +3295 +3296 +3297 +3298 +3299 +3300 +3301 +3302 +3303 +3304 +3305 +3306 +3307 +3308 +3309 +3310 +3311 +3312 +3313 +3314 +3315 +3316 +3317 +3318 +3319 +3320 +3321 +3322 +3323 +3324 +3325 +3326 +3327 +3328 +3329 +3330 +3331 +3332 +3333 +3334 +3335 +3336 +3337 +3338 +3339 +3340 +3341 +3342 +3343 +3344 +3345 +3346 +3347 +3348 +3349 +3350 +3351 +3352 +3353 +3354 +3355 +3356 +3357 +3358 +3359 +3360 +3361 +3362 +3363 +3364 +3365 +3366 +3367 +3368 +3369 +3370 +3371 +3372 +3373 +3374 +3375 +3376 +3377 +3378 +3379 +3380 +3381 +3382 +3383 +3384 +3385 +3386 +3387 +3388 +3389 +3390 +3391 +3392 +3393 +3394 +3395 +3396 +3397 +3398 +3399 +3400 +3401 +3402 +3403 +3404 +3405 +3406 +3407 +3408 +3409 +3410 +3411 +3412 +3413 +3414 +3415 +3416 +3417 +3418 +3419 +3420 +3421 +3422 +3423 +3424 +3425 +3426 +3427 +3428 +3429 +3430 +3431 +3432 +3433 +3434 +3435 +3436 +3437 +3438 +3439 +3440 +3441 +3442 +3443 +3444 +3445 +3446 +3447 +3448 +3449 +3450 +3451 +3452 +3453 +3454 +3455 +3456 +3457 +3458 +3459 +3460 +3461 +3462 +3463 +3464 +3465 +3466 +3467 +3468 +3469 +3470 +3471 +3472 +3473 +3474 +3475 +3476 +3477 +3478 +3479 +3480 +3481 +3482 +3483 +3484 +3485 +3486 +3487 +3488 +3489 +3490 +3491 +3492 +3493 +3494 +3495 +3496 +3497 +3498 +3499 +3500 +3501 +3502 +3503 +3504 +3505 +3506 +3507 +3508 +3509 +3510 +3511 +3512 +3513 +3514 +3515 +3516 +3517 +3518 +3519 +3520 +3521 +3522 +3523 +3524 +3525 +3526 +3527 +3528 +3529 +3530 +3531 +3532 +3533 +3534 +3535 +3536 +3537 +3538 +3539 +3540 +3541 +3542 +3543 +3544 +3545 +3546 +3547 +3548 +3549 +3550 +3551 +3552 +3553 +3554 +3555 +3556 +3557 +3558 +3559 +3560 +3561 +3562 +3563 +3564 +3565 +3566 +3567 +3568 +3569 +3570 +3571 +3572 +3573 +3574 +3575 +3576 +3577 +3578 +3579 +3580 +3581 +3582 +3583 +3584 +3585 +3586 +3587 +3588 +3589 +3590 +3591 +3592 +3593 +3594 +3595 +3596 +3597 +3598 +3599 +3600 +3601 +3602 +3603 +3604 +3605 +3606 +3607 +3608 +3609 +3610 +3611 +3612 +3613 +3614 +3615 +3616 +3617 +3618 +3619 +3620 +3621 +3622 +3623 +3624 +3625 +3626 +3627 +3628 +3629 +3630 +3631 +3632 +3633 +3634 +3635 +3636 +3637 +3638 +3639 +3640 +3641 +3642 +3643 +3644 +3645 +3646 +3647 +3648 +3649 +3650 +3651 +3652 +3653 +3654 +3655 +3656 +3657 +3658 +3659 +3660 +3661 +3662 +3663 +3664 +3665 +3666 +3667 +3668 +3669 +3670 +3671 +3672 +3673 +3674 +3675 +3676 +3677 +3678 +3679 +3680 +3681 +3682 +3683 +3684 +3685 +3686 +3687 +3688 +3689 +3690 +3691 +3692 +3693 +3694 +3695 +3696 +3697 +3698 +3699 +3700 +3701 +3702 +3703 +3704 +3705 +3706 +3707 +3708 +3709 +3710 +3711 +3712 +3713 +3714 +3715 +3716 +3717 +3718 +3719 +3720 +3721 +3722 +3723 +3724 +3725 +3726 +3727 +3728 +3729 +3730 +3731 +3732 +3733 +3734 +3735 +3736 +3737 +3738 +3739 +3740 +3741 +3742 +3743 +3744 +3745 +3746 +3747 +3748 +3749 +3750 +3751 +3752 +3753 +3754 +3755 +3756 +3757 +3758 +3759 +3760 +3761 +3762 +3763 +3764 +3765 +3766 +3767 +3768 +3769 +3770 +3771 +3772 +3773 +3774 +3775 +3776 +3777 +3778 +3779 +3780 +3781 +3782 +3783 +3784 +3785 +3786 +3787 +3788 +3789 +3790 +3791 +3792 +3793 +3794 +3795 +3796 +3797 +3798 +3799 +3800 +3801 +3802 +3803 +3804 +3805 +3806 +3807 +3808 +3809 +3810 +3811 +3812 +3813 +3814 +3815 +3816 +3817 +3818 +3819 +3820 +3821 +3822 +3823 +3824 +3825 +3826 +3827 +3828 +3829 +3830 +3831 +3832 +3833 +3834 +3835 +3836 +3837 +3838 +3839 +3840 +3841 +3842 +3843 +3844 +3845 +3846 +3847 +3848 +3849 +3850 +3851 +3852 +3853 +3854 +3855 +3856 +3857 +3858 +3859 +3860 +3861 +3862 +3863 +3864 +3865 +3866 +3867 +3868 +3869 +3870 +3871 +3872 +3873 +3874 +3875 +3876 +3877 +3878 +3879 +3880 +3881 +3882 +3883 +3884 +3885 +3886 +3887 +3888 +3889 +3890 +3891 +3892 +3893 +3894 +3895 +3896 +3897 +3898 +3899 +3900 +3901 +3902 +3903 +3904 +3905 +3906 +3907 +3908 +3909 +3910 +3911 +3912 +3913 +3914 +3915 +3916 +3917 +3918 +3919 +3920 +3921 +3922 +3923 +3924 +3925 +3926 +3927 +3928 +3929 +3930 +3931 +3932 +3933 +3934 +3935 +3936 +3937 +3938 +3939 +3940 +3941 +3942 +3943 +3944 +3945 +3946 +3947 +3948 +3949 +3950 +3951 +3952 +3953 +3954 +3955 +3956 +3957 +3958 +3959 +3960 +3961 +3962 +3963 +3964 +3965 +3966 +3967 +3968 +3969 +3970 +3971 +3972 +3973 +3974 +3975 +3976 +3977 +3978 +3979 +3980 +3981 +3982 +3983 +3984 +3985 +3986 +3987 +3988 +3989 +3990 +3991 +3992 +3993 +3994 +3995 +3996 +3997 +3998 +3999 +4000 +4001 +4002 +4003 +4004 +4005 +4006 +4007 +4008 +4009 +4010 +4011 +4012 +4013 +4014 +4015 +4016 +4017 +4018 +4019 +4020 +4021 +4022 +4023 +4024 +4025 +4026 +4027 +4028 +4029 +4030 +4031 +4032 +4033 +4034 +4035 +4036 +4037 +4038 +4039 +4040 +4041 +4042 +4043 +4044 +4045 +4046 +4047 +4048 +4049 +4050 +4051 +4052 +4053 +4054 +4055 +4056 +4057 +4058 +4059 +4060 +4061 +4062 +4063 +4064 +4065 +4066 +4067 +4068 +4069 +4070 +4071 +4072 +4073 +4074 +4075 +4076 +4077 +4078 +4079 +4080 +4081 +4082 +4083 +4084 +4085 +4086 +4087 +4088 +4089 +4090 +4091 +4092 +4093 +4094 +4095 +4096 +4097 +4098 +4099 +4100 +4101 +4102 +4103 +4104 +4105 +4106 +4107 +4108 +4109 +4110 +4111 +4112 +4113 +4114 +4115 +4116 +4117 +4118 +4119 +4120 +4121 +4122 +4123 +4124 +4125 +4126 +4127 +4128 +4129 +4130 +4131 +4132 +4133 +4134 +4135 +4136 +4137 +4138 +4139 +4140 +4141 +4142 +4143 +4144 +4145 +4146 +4147 +4148 +4149 +4150 +4151 +4152 +4153 +4154 +4155 +4156 +4157 +4158 +4159 +4160 +4161 +4162 +4163 +4164 +4165 +4166 +4167 +4168 +4169 +4170 +4171 +4172 +4173 +4174 +4175 +4176 +4177 +4178 +4179 +4180 +4181 +4182 +4183 +4184 +4185 +4186 +4187 +4188 +4189 +4190 +4191 +4192 +4193 +4194 +4195 +4196 +4197 +4198 +4199 +4200 +4201 +4202 +4203 +4204 +4205 +4206 +4207 +4208 +4209 +4210 +4211 +4212 +4213 +4214 +4215 +4216 +4217 +4218 +4219 +4220 +4221 +4222 +4223 +4224 +4225 +4226 +4227 +4228 +4229 +4230 +4231 +4232 +4233 +4234 +4235 +4236 +4237 +4238 +4239 +4240 +4241 +4242 +4243 +4244 +4245 +4246 +4247 +4248 +4249 +4250 +4251 +4252 +4253 +4254 +4255 +4256 +4257 +4258 +4259 +4260 +4261 +4262 +4263 +4264 +4265 +4266 +4267 +4268 +4269 +4270 +4271 +4272 +4273 +4274 +4275 +4276 +4277 +4278 +4279 +4280 +4281 +4282 +4283 +4284 +4285 +4286 +4287 +4288 +4289 +4290 +4291 +4292 +4293 +4294 +4295 +4296 +4297 +4298 +4299 +4300 +4301 +4302 +4303 +4304 +4305 +4306 +4307 +4308 +4309 +4310 +4311 +4312 +4313 +4314 +4315 +4316 +4317 +4318 +4319 +4320 +4321 +4322 +4323 +4324 +4325 +4326 +4327 +4328 +4329 +4330 +4331 +4332 +4333 +4334 +4335 +4336 +4337 +4338 +4339 +4340 +4341 +4342 +4343 +4344 +4345 +4346 +4347 +4348 +4349 +4350 +4351 +4352 +4353 +4354 +4355 +4356 +4357 +4358 +4359 +4360 +4361 +4362 +4363 +4364 +4365 +4366 +4367 +4368 +4369 +4370 +4371 +4372 +4373 +4374 +4375 +4376 +4377 +4378 +4379 +4380 +4381 +4382 +4383 +4384 +4385 +4386 +4387 +4388 +4389 +4390 +4391 +4392 +4393 +4394 +4395 +4396 +4397 +4398 +4399 +4400 +4401 +4402 +4403 +4404 +4405 +4406 +4407 +4408 +4409 +4410 +4411 +4412 +4413 +4414 +4415 +4416 +4417 +4418 +4419 +4420 +4421 +4422 +4423 +4424 +4425 +4426 +4427 +4428 +4429 +4430 +4431 +4432 +4433 +4434 +4435 +4436 +4437 +4438 +4439 +4440 +4441 +4442 +4443 +4444 +4445 +4446 +4447 +4448 +4449 +4450 +4451 +4452 +4453 +4454 +4455 +4456 +4457 +4458 +4459 +4460 +4461 +4462 +4463 +4464 +4465 +4466 +4467 +4468 +4469 +4470 +4471 +4472 +4473 +4474 +4475 +4476 +4477 +4478 +4479 +4480 +4481 +4482 +4483 +4484 +4485 +4486 +4487 +4488 +4489 +4490 +4491 +4492 +4493 +4494 +4495 +4496 +4497 +4498 +4499 +4500 +4501 +4502 +4503 +4504 +4505 +4506 +4507 +4508 +4509 +4510 +4511 +4512 +4513 +4514 +4515 +4516 +4517 +4518 +4519 +4520 +4521 +4522 +4523 +4524 +4525 +4526 +4527 +4528 +4529 +4530 +4531 +4532 +4533 +4534 +4535 +4536 +4537 +4538 +4539 +4540 +4541 +4542 +4543 +4544 +4545 +4546 +4547 +4548 +4549 +4550 +4551 +4552 +4553 +4554 +4555 +4556 +4557 +4558 +4559 +4560 +4561 +4562 +4563 +4564 +4565 +4566 +4567 +4568 +4569 +4570 +4571 +4572 +4573 +4574 +4575 +4576 +4577 +4578 +4579 +4580 +4581 +4582 +4583 +4584 +4585 +4586 +4587 +4588 +4589 +4590 +4591 +4592 +4593 +4594 +4595 +4596 +4597 +4598 +4599 +4600 +4601 +4602 +4603 +4604 +4605 +4606 +4607 +4608 +4609 +4610 +4611 +4612 +4613 +4614 +4615 +4616 +4617 +4618 +4619 +4620 +4621 +4622 +4623 +4624 +4625 +4626 +4627 +4628 +4629 +4630 +4631 +4632 +4633 +4634 +4635 +4636 +4637 +4638 +4639 +4640 +4641 +4642 +4643 +4644 +4645 +4646 +4647 +4648 +4649 +4650 +4651 +4652 +4653 +4654 +4655 +4656 +4657 +4658 +4659 +4660 +4661 +4662 +4663 +4664 +4665 +4666 +4667 +4668 +4669 +4670 +4671 +4672 +4673 +4674 +4675 +4676 +4677 +4678 +4679 +4680 +4681 +4682 +4683 +4684 +4685 +4686 +4687 +4688 +4689 +4690 +4691 +4692 +4693 +4694 +4695 +4696 +4697 +4698 +4699 +4700 +4701 +4702 +4703 +4704 +4705 +4706 +4707 +4708 +4709 +4710 +4711 +4712 +4713 +4714 +4715 +4716 +4717 +4718 +4719 +4720 +4721 +4722 +4723 +4724 +4725 +4726 +4727 +4728 +4729 +4730 +4731 +4732 +4733 +4734 +4735 +4736 +4737 +4738 +4739 +4740 +4741 +4742 +4743 +4744 +4745 +4746 +4747 +4748 +4749 +4750 +4751 +4752 +4753 +4754 +4755 +4756 +4757 +4758 +4759 +4760 +4761 +4762 +4763 +4764 +4765 +4766 +4767 +4768 +4769 +4770 +4771 +4772 +4773 +4774 +4775 +4776 +4777 +4778 +4779 +4780 +4781 +4782 +4783 +4784 +4785 +4786 +4787 +4788 +4789 +4790 +4791 +4792 +4793 +4794 +4795 +4796 +4797 +4798 +4799 +4800 +4801 +4802 +4803 +4804 +4805 +4806 +4807 +4808 +4809 +4810 +4811 +4812 +4813 +4814 +4815 +4816 +4817 +4818 +4819 +4820 +4821 +4822 +4823 +4824 +4825 +4826 +4827 +4828 +4829 +4830 +4831 +4832 +4833 +4834 +4835 +4836 +4837 +4838 +4839 +4840 +4841 +4842 +4843 +4844 +4845 +4846 +4847 +4848 +4849 +4850 +4851 +4852 +4853 +4854 +4855 +4856 +4857 +4858 +4859 +4860 +4861 +4862 +4863 +4864 +4865 +4866 +4867 +4868 +4869 +4870 +4871 +4872 +4873 +4874 +4875 +4876 +4877 +4878 +4879 +4880 +4881 +4882 +4883 +4884 +4885 +4886 +4887 +4888 +4889 +4890 +4891 +4892 +4893 +4894 +4895 +4896 +4897 +4898 +4899 +4900 +4901 +4902 +4903 +4904 +4905 +4906 +4907 +4908 +4909 +4910 +4911 +4912 +4913 +4914 +4915 +4916 +4917 +4918 +4919 +4920 +4921 +4922 +4923 +4924 +4925 +4926 +4927 +4928 +4929 +4930 +4931 +4932 +4933 +4934 +4935 +4936 +4937 +4938 +4939 +4940 +4941 +4942 +4943 +4944 +4945 +4946 +4947 +4948 +4949 +4950 +4951 +4952 +4953 +4954 +4955 +4956 +4957 +4958 +4959 +4960 +4961 +4962 +4963 +4964 +4965 +4966 +4967 +4968 +4969 +4970 +4971 +4972 +4973 +4974 +4975 +4976 +4977 +4978 +4979 +4980 +4981 +4982 +4983 +4984 +4985 +4986 +4987 +4988 +4989 +4990 +4991 +4992 +4993 +4994 +4995 +4996 +4997 +4998 +4999 +5000 diff --git a/src/client/vendor/fixtures/sgr.bin b/src/client/vendor/fixtures/sgr.bin new file mode 100644 index 0000000..34aa321 --- /dev/null +++ b/src/client/vendor/fixtures/sgr.bin @@ -0,0 +1,18 @@ +bold faint italic under [4:3mcurly blink inverse invis strike over all + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 + 30 B 31 B 32 B 33 B 34 B 35 B 36 B 37 B 90 B 91 B 92 B 93 B 94 B 95 B 96 B 97 B + 40 41 42 43 44 45 46 47 100 101 102 103 104 105 106 107 + 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 + 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 + 88 89 90 91 92 93 94 95 96 97 98 99100101102103104105106107108109110111112113114115116117118119120121122123 +124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 +160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 +196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 + 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 +ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOP +truecolor fg+bg [38:2::255:0:128mcolon-rgb underline-color + bg to eol  +after ED with red bg +tab stops here | +]8;;https://example.com\hyperlink]8;;\ plain diff --git a/src/client/vendor/fixtures/top.bin b/src/client/vendor/fixtures/top.bin new file mode 100644 index 0000000..0caedbe --- /dev/null +++ b/src/client/vendor/fixtures/top.bin @@ -0,0 +1 @@ +[?1049h(B[?7h[?1034h[?1h=Processes: 659 total, 4 running, 655 sleeping, 4264 threads00:26:31 Load Avg: 2.94, 3.85, 4.59 CPU usage: 10.87% user, 11.11% sys, 78.0% idle SharedLibs: 709M resident, 165M data, 115M linkedit. MemRegions: 433859 total, 10G resident, 303M private, 2290M shared. PhysMem: 33G used (2801M wired, 11G compressor), 2488M unused. VM: 293T vsize, 6144M framework vsize, 28304951(0) swapins, 31074471(0) swapouts. Networks: packets: 65163509/43G in, 46551790/30G out. Disks: 105902340/1410G read, 63910802/1257G written. PIDCOMMAND(B%CPU(B#THMEMTIMESTATE 6352sleep0.01864K00:00.00sleeping 3625.136.33% sys, 78.52MemRegions: 0 total, 0B resident, 303M private, 2290M shared.504348194145593457.646.8912019 total, 28M resident, 303M private, 2290M shared.227358[?1049l [?1l> \ No newline at end of file diff --git a/src/client/vendor/fixtures/uni.bin b/src/client/vendor/fixtures/uni.bin new file mode 100644 index 0000000..e15d1a6 --- /dev/null +++ b/src/client/vendor/fixtures/uni.bin @@ -0,0 +1,14 @@ +ASCII: The quick brown fox +CJK: 你好世界 日本語テキスト 한국어 fullwidth +Emoji: 😀 🎉 👍 🚀 ❤️ ✅ ☀️ +ZWJ: 👨‍👩‍👧‍👦 🏳️‍🌈 👩🏽‍💻 🧑‍🤝‍🧑 +Flags: 🇺🇸 🇯🇵 🇩🇪 +Skin: 👍🏻 👍🏿 +Combining: é ñ ǻ Z͑͛̍ +NFC: é ñ å +Box: ┌─┬─┐│ │ │└─┴─┘ ━━━ ▀▄█░▒▓ +Math: ∑∫√∞≠≤ ⎛⎞ 𝔸𝕓𝒸 +Thai: สวัสดี Hindi: नमस्ते Arabic: مرحبا +VS16: ☺︎ ☺️ ♥ ♥️ +Wrap: 中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中 +红色绿底 🎨粗 diff --git a/src/client/vendor/fixtures/vim.bin b/src/client/vendor/fixtures/vim.bin new file mode 100644 index 0000000..9271456 --- /dev/null +++ b/src/client/vendor/fixtures/vim.bin @@ -0,0 +1,3 @@ +[?1049h[>4;2m[?1h=[?2004h[?1004h[?12h[?12l[?25l"sample.c" 4L, 54B▽ Pzz\[0%m [>c]10;?]11;?line one +int main(void) { return 0; } tabbedthe end +~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ 1,1All[?25h[?4m[?25l2[?25h[?25l3,1-8[?25h[?25lx dl tabbed[?25h[?25l [?25h[?25l-- INSERT --3,1Topinserted text3,14All[?25h[?25l^[[?25h[?25l 3,13All[?25h[?25l:q! [?2004l[>4;m[?1004l[?2004l[?1l>[?1049l[?25h[>4;m \ No newline at end of file diff --git a/src/client/vendor/ghostty-web/LICENSE.ghostty-vt-js b/src/client/vendor/ghostty-web/LICENSE.ghostty-vt-js new file mode 100644 index 0000000..7f88fce --- /dev/null +++ b/src/client/vendor/ghostty-web/LICENSE.ghostty-vt-js @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ghostty-vt-js contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 812d7a5..c598945 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -20,3 +20,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. +- `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. +- `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/fn-table.ts b/src/client/vendor/ghostty-web/fn-table.ts new file mode 100644 index 0000000..fd7a14d --- /dev/null +++ b/src/client/vendor/ghostty-web/fn-table.ts @@ -0,0 +1,228 @@ +/* + * Copied from gkoreli/ghostty-vt-js (https://github.com/gkoreli/ghostty-vt-js) + * at commit 248de81a188b71a1919d3c5c5bce2f99a836b5b1, + * path packages/ghostty-vt-js/src/wasm/fn-table.ts, unmodified below this header + * (ADR 041, ADR 042). webtty's ghostty.ts is the only importer: it passes in the + * official wasm's __indirect_function_table and installs the terminal callbacks. + * + * MIT License + * + * Copyright (c) 2026 ghostty-vt-js contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * JS → WASM function-pointer installation (the "synthetic module" trick). + * + * # Why this file exists + * + * libghostty-vt's effect callbacks (`GHOSTTY_TERMINAL_OPT_WRITE_PTY`, `_BELL`, + * `_TITLE_CHANGED`, ... — `terminal.h:60-95`) are C function pointers. In WASM, + * a "C function pointer" is an index into the module's indirect function table + * (`__indirect_function_table`, exported by our `ghostty-vt.wasm` build). + * + * The naive way to put a JS function into that table is `WebAssembly.Function` + * (the js-types / Type Reflection proposal), which as of 2026-07 is still NOT + * shipped by default in stable Chrome or Bun (V8 gates it behind + * `--experimental-wasm-type-reflection`; JSC doesn't expose it). Wasm 3.0 + * (2025-09) did not change this — typed function references are a *core spec* + * feature; synthesizing a typed wasm function from JS lives in the separate + * JS-API proposal. + * + * # The trick (works in every runtime, today) + * + * `WebAssembly.Function`'s only job is to give a JS function a wasm type. But a + * ~50-byte synthetic module that IMPORTS a JS function and immediately + * RE-EXPORTS it produces an "Exported Function" — which carries a declared wasm + * type and is therefore legal to store in a `funcref` table in every engine: + * + * ``` + * (module + * (import "e" "f" (func $f (param i32 i32 ...) (result ...))) + * (export "f" (func $f))) + * ``` + * + * We assemble that module's binary by hand (it has no code section — just + * type/import/export), instantiate it with the JS function as the import, grow + * `__indirect_function_table`, store the export, and hand the index to + * `ghostty_terminal_set()` as the "function pointer". + * + * # Rules (violate these and you get engine traps or corrupted state) + * + * 1. **The wasm signature must match the C typedef exactly.** Pointers, + * lengths, handles and `void*` are all `i32` on wasm32; `size_t` is `i32`; + * a C `bool` return is `i32`. A mismatch traps with "indirect call type + * mismatch" at *call* time (i.e. mid-`vt_write`), not at install time. + * 2. **Table indices are per-instance.** An index acquired from one WASM + * instance's table means nothing to another instance. Never cache indices + * across instances. + * 3. **Indices must outlive the consumer.** If a table slot is released (or + * overwritten) while a terminal still has it registered as a callback, the + * next triggering VT sequence traps. Release slots only after clearing the + * registration (`ghostty_terminal_set(term, OPT, 0)`) or freeing the + * terminal. + * 4. **Slots are recycled, not shrunk.** `WebAssembly.Table` cannot shrink. + * {@link FnTable} keeps a free-list so long-lived hosts (a browser tab + * opening many terminals) don't leak table slots. + * 5. **What the callback may do is governed by the *callee's* contract**, not + * by this file. For libghostty-vt effects see `../browser-terminal/vt/effects.ts` + * — most importantly: never re-enter `ghostty_terminal_vt_write` from + * inside a callback. + * + * This file is platform-agnostic (no DOM, no Node APIs) — it works identically + * in browsers, Node, and Bun. + */ + +/** WASM value type byte codes (binary format §5.3.1). */ +export const WasmValType = { + I32: 0x7f, + I64: 0x7e, + F32: 0x7d, + F64: 0x7c, +} as const; + +export type WasmValTypeCode = (typeof WasmValType)[keyof typeof WasmValType]; + +/** Unsigned LEB128 encoding (wasm binary format integers). */ +function uleb(n: number): number[] { + const bytes: number[] = []; + do { + let b = n & 0x7f; + n >>>= 7; + if (n !== 0) b |= 0x80; + bytes.push(b); + } while (n !== 0); + return bytes; +} + +function section(id: number, body: number[]): number[] { + return [id, ...uleb(body.length), ...body]; +} + +/** + * Assemble the binary for `(module (import "e" "f" (func )) (export "f" (func 0)))`. + * + * Sections: type (id 1), import (id 2), export (id 7). No code section — the + * one function is the import itself, re-exported. + */ +function buildReExportModule(params: readonly WasmValTypeCode[], results: readonly WasmValTypeCode[]): Uint8Array { + const typeSection = section(1, [ + 0x01, // one type entry + 0x60, // func type + ...uleb(params.length), + ...params, + ...uleb(results.length), + ...results, + ]); + // import "e" "f" (func, type index 0) + const importSection = section(2, [0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00]); + // export "f" = func index 0 + const exportSection = section(7, [0x01, 0x01, 0x66, 0x00, 0x00]); + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic "\0asm" + 0x01, 0x00, 0x00, 0x00, // version 1 + ...typeSection, + ...importSection, + ...exportSection, + ]); +} + +/** + * Wrap a JS function as a *typed wasm exported function* — the + * `WebAssembly.Function` substitute described in the file header. + * + * Synchronous by design: the synthetic module is tiny and + * `new WebAssembly.Module` on <100 bytes is microseconds. (Some engines warn + * about sync compilation of *large* modules on the main thread; this is 50 + * bytes.) + * + * @param jsFn - The JS function to wrap. It receives the raw wasm values + * (numbers) — pointer arguments must be dereferenced against the target + * instance's `memory` by the caller. + * @param params - Parameter types matching the C typedef (rule 1 above). + * @param results - Result types (empty for `void`). + */ +export function wrapAsWasmFunction( + jsFn: (...args: number[]) => number | void, + params: readonly WasmValTypeCode[], + results: readonly WasmValTypeCode[] = [], +): WebAssembly.ExportValue & CallableFunction { + const module = new WebAssembly.Module(buildReExportModule(params, results) as BufferSource); + const instance = new WebAssembly.Instance(module, { e: { f: jsFn } }); + return instance.exports.f as WebAssembly.ExportValue & CallableFunction; +} + +/** A table slot holding an installed JS callback. See {@link FnTable.install}. */ +export interface InstalledFn { + /** + * The table index — pass this as the C "function pointer" value (e.g. the + * `value` argument of `ghostty_terminal_set`). + */ + readonly index: number; + /** + * Return the slot to the free-list. Only call after no C-side consumer can + * invoke the pointer anymore (rule 3 in the file header). + */ + release(): void; +} + +/** + * Slot allocator over a module's `__indirect_function_table`. + * + * One `FnTable` per WASM instance (rule 2). Grows the table on demand and + * recycles released slots (rule 4). Create it once next to the instance and + * share it among all consumers of that instance. + */ +export class FnTable { + private readonly table: WebAssembly.Table; + private readonly freeSlots: number[] = []; + + constructor(table: WebAssembly.Table) { + this.table = table; + } + + /** + * Install a JS function into the table and return its index. + * + * @param jsFn - JS callback receiving raw wasm values (numbers). + * @param params - Wasm parameter types matching the C typedef exactly. + * @param results - Wasm result types (empty for `void`). + */ + install( + jsFn: (...args: number[]) => number | void, + params: readonly WasmValTypeCode[], + results: readonly WasmValTypeCode[] = [], + ): InstalledFn { + const wrapped = wrapAsWasmFunction(jsFn, params, results); + const index = this.freeSlots.pop() ?? this.table.grow(1); + this.table.set(index, wrapped); + + let released = false; + return { + index, + release: () => { + if (released) return; + released = true; + this.table.set(index, null); + this.freeSlots.push(index); + }, + }; + } +} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index f079885..c564bc9 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -4,8 +4,14 @@ * High-performance terminal emulation using Ghostty's battle-tested VT100 parser. * The key optimization is the RenderState API which provides a pre-computed * snapshot of all render data in a single update call. + * + * webtty (ADR 042): two engines behind one `GhosttyTerminal` interface. The Coder + * engine (`CoderTerminal`) runs ghostty-web's patched wasm; the official engine + * (`OfficialTerminal`) runs Ghostty's unpatched libghostty-vt wasm. Every wasm + * export call and wasm memory access for both engines is in this file. */ +import { FnTable, type InstalledFn, WasmValType } from './fn-table'; import { CellFlags, type Cursor, @@ -14,6 +20,8 @@ import { type GhosttyCell, type GhosttyTerminalConfig, type GhosttyWasmExports, + Key, + KeyAction, KeyEncoderOption, type KeyEvent, type KittyKeyFlags, @@ -36,38 +44,107 @@ export { type RenderStateCursor, }; +/** The terminal engines (ADR 042). */ +export type EngineName = 'coder' | 'official'; + +/** Bell and title changes since the last `readEffects` (ADR 042). */ +export interface TerminalEffects { + bell: boolean; + title: string | null; +} + /** - * Main Ghostty WASM wrapper class + * The terminal surface the renderer, selection, buffer, input handler and + * terminal.ts use. Rows are in the Coder engine's coordinates on both engines: + * `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` use active-screen + * rows, and `getScrollbackLine(i)` counts from the oldest scrollback row. */ -export class Ghostty { - private exports: GhosttyWasmExports; - private memory: WebAssembly.Memory; +export interface GhosttyTerminal { + readonly cols: number; + readonly rows: number; + /** True when the engine answers XTWINOPS size queries and mode 2048 itself. */ + readonly answersSizeQueries: boolean; - constructor(wasmInstance: WebAssembly.Instance) { - this.exports = wasmInstance.exports as GhosttyWasmExports; - this.memory = this.exports.memory; - } + // Lifecycle + write(data: string | Uint8Array): void; + /** Grid resize; the device-pixel cell size is used by the official engine only. */ + resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void; + /** Device-pixel cell size for size reports. A no-op on the Coder engine. */ + setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void; + /** + * The view's `viewportY` (lines scrolled up from the bottom). The renderer calls + * it first in every render. Only the official engine implements it. + */ + setViewportOffset?(linesFromBottom: number): void; + free(): void; + + // Render state + update(): DirtyState; + getCursor(): RenderStateCursor; + getColors(): RenderStateColors; + isRowDirty(y: number): boolean; + markClean(): void; + getViewport(): GhosttyCell[]; + getLine(y: number): GhosttyCell[] | null; + isDirty(): boolean; + needsFullRedraw(): boolean; + clearDirty(): void; + getDimensions(): { cols: number; rows: number }; + + // Modes + isAlternateScreen(): boolean; + hasBracketedPaste(): boolean; + hasFocusEvents(): boolean; + hasMouseTracking(): boolean; + getMode(mode: number, isAnsi?: boolean): boolean; + + // Scrollback, graphemes, hyperlinks + getScrollbackLength(): number; + getScrollbackLine(offset: number): GhosttyCell[] | null; + isRowWrapped(row: number): boolean; + getHyperlinkUri(row: number, col: number): string | null; + getScrollbackHyperlinkUri(offset: number, col: number): string | null; + getGrapheme(row: number, col: number): number[] | null; + getGraphemeString(row: number, col: number): string; + getScrollbackGrapheme(offset: number, col: number): number[] | null; + getScrollbackGraphemeString(offset: number, col: number): string; + + // Replies and effects + hasResponse(): boolean; + readResponse(): string | null; + /** Bell and title changes since the last call. The Coder engine reports none. */ + readEffects(): TerminalEffects; +} - createKeyEncoder(): KeyEncoder { - return new KeyEncoder(this.exports); - } +/** Converts keyboard events into terminal escape sequences. */ +export interface KeyEncoder { + setOption(option: KeyEncoderOption, value: boolean | number): void; + setKittyFlags(flags: KittyKeyFlags): void; + encode(event: KeyEvent): Uint8Array; + dispose(): void; +} - createTerminal( - cols: number = 80, - rows: number = 24, - config?: GhosttyTerminalConfig - ): GhosttyTerminal { - return new GhosttyTerminal(this.exports, this.memory, cols, rows, config); - } +/** + * A loaded engine: one wasm instance that creates terminals and key encoders. + */ +export abstract class Ghostty { + abstract readonly engine: EngineName; - static async load(wasmPath?: string): Promise { + abstract createKeyEncoder(): KeyEncoder; + + abstract createTerminal(cols?: number, rows?: number, config?: GhosttyTerminalConfig): GhosttyTerminal; + + static async load(wasmPath?: string, engine: EngineName = 'coder'): Promise { // If explicit path provided, use it if (wasmPath) { - return Ghostty.loadFromPath(wasmPath); + return Ghostty.loadFromPath(wasmPath, engine); } // Resolve path relative to this module - const moduleUrl = new URL('../ghostty-vt.wasm', import.meta.url); + const moduleUrl = new URL( + engine === 'official' ? '../ghostty-vt/ghostty-vt-small.wasm' : '../ghostty-vt.wasm', + import.meta.url + ); // Build paths to try, prioritizing file system paths for Node/Bun const defaultPaths: string[] = []; @@ -88,7 +165,7 @@ export class Ghostty { let lastError: Error | null = null; for (const path of defaultPaths) { try { - return await Ghostty.loadFromPath(path); + return await Ghostty.loadFromPath(path, engine); } catch (e) { lastError = e instanceof Error ? e : new Error(String(e)); } @@ -96,7 +173,7 @@ export class Ghostty { throw lastError || new Error('Failed to load Ghostty WASM'); } - private static async loadFromPath(path: string): Promise { + private static async loadFromPath(path: string, engine: EngineName): Promise { let wasmBytes: ArrayBuffer | undefined; // Try Bun.file first (for Bun environments) @@ -139,6 +216,11 @@ export class Ghostty { } const wasmModule = await WebAssembly.compile(wasmBytes); + if (engine === 'official') { + // The official wasm imports nothing. + const instance = await WebAssembly.instantiate(wasmModule, {}); + return new OfficialGhostty(instance.exports as unknown as OfficialWasmExports); + } const wasmInstance = await WebAssembly.instantiate(wasmModule, { env: { log: (ptr: number, len: number) => { @@ -151,14 +233,41 @@ export class Ghostty { }, }, }); - return new Ghostty(wasmInstance); + return new CoderGhostty(wasmInstance); + } +} + +/** + * The Coder engine: ghostty-web's patched wasm. + */ +class CoderGhostty extends Ghostty { + readonly engine = 'coder' as const; + private exports: GhosttyWasmExports; + private memory: WebAssembly.Memory; + + constructor(wasmInstance: WebAssembly.Instance) { + super(); + this.exports = wasmInstance.exports as GhosttyWasmExports; + this.memory = this.exports.memory; + } + + createKeyEncoder(): KeyEncoder { + return new CoderKeyEncoder(this.exports); + } + + createTerminal( + cols: number = 80, + rows: number = 24, + config?: GhosttyTerminalConfig + ): GhosttyTerminal { + return new CoderTerminal(this.exports, this.memory, cols, rows, config); } } /** * Key Encoder - converts keyboard events into terminal escape sequences */ -export class KeyEncoder { +class CoderKeyEncoder implements KeyEncoder { private exports: GhosttyWasmExports; private encoder: number = 0; @@ -251,7 +360,8 @@ export class KeyEncoder { * - ONE call to get all cells (getViewport) * - No per-row WASM boundary crossings! */ -export class GhosttyTerminal { +export class CoderTerminal implements GhosttyTerminal { + readonly answersSizeQueries = false; private exports: GhosttyWasmExports; private memory: WebAssembly.Memory; private handle: TerminalHandle; @@ -347,7 +457,8 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); } - resize(cols: number, rows: number): void { + /** The pixel size is for the official engine; the Coder wasm has no use for it. */ + resize(cols: number, rows: number, _cellWidthPx?: number, _cellHeightPx?: number): void { if (cols === this._cols && rows === this._rows) return; this._cols = cols; this._rows = rows; @@ -356,6 +467,14 @@ export class GhosttyTerminal { this.initCellPool(); } + /** The Coder wasm does not answer size queries (kitty.ts does), so it needs no pixels. */ + setCellPixelSize(_cellWidthPx: number, _cellHeightPx: number): void {} + + /** The Coder wasm reports no effects; terminal.ts scans the stream for BEL and OSC 0/2. */ + readEffects(): TerminalEffects { + return { bell: false, title: null }; + } + free(): void { if (this.viewportBufferPtr) { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); @@ -445,7 +564,7 @@ export class GhosttyTerminal { */ getViewport(): GhosttyCell[] { const totalCells = this._cols * this._rows; - const neededSize = totalCells * GhosttyTerminal.CELL_SIZE; + const neededSize = totalCells * CoderTerminal.CELL_SIZE; // Ensure buffer is allocated if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { @@ -550,7 +669,7 @@ export class GhosttyTerminal { * @param offset 0 = oldest line, (length-1) = most recent scrollback line */ getScrollbackLine(offset: number): GhosttyCell[] | null { - const neededSize = this._cols * GhosttyTerminal.CELL_SIZE; + const neededSize = this._cols * CoderTerminal.CELL_SIZE; // Ensure buffer is allocated if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { @@ -577,11 +696,11 @@ export class GhosttyTerminal { // Parse cells const cells: GhosttyCell[] = []; const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); - const view = new DataView(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); + const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * CoderTerminal.CELL_SIZE); + const view = new DataView(buffer, this.viewportBufferPtr, count * CoderTerminal.CELL_SIZE); for (let i = 0; i < count; i++) { - const cellOffset = i * GhosttyTerminal.CELL_SIZE; + const cellOffset = i * CoderTerminal.CELL_SIZE; cells.push({ codepoint: view.getUint32(cellOffset, true), fg_r: u8[cellOffset + 4], @@ -769,11 +888,11 @@ export class GhosttyTerminal { private parseCellsIntoPool(ptr: number, count: number): void { const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); - const view = new DataView(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); + const u8 = new Uint8Array(buffer, ptr, count * CoderTerminal.CELL_SIZE); + const view = new DataView(buffer, ptr, count * CoderTerminal.CELL_SIZE); for (let i = 0; i < count; i++) { - const offset = i * GhosttyTerminal.CELL_SIZE; + const offset = i * CoderTerminal.CELL_SIZE; const cell = this.cellPool[i]; cell.codepoint = view.getUint32(offset, true); cell.fg_r = u8[offset + 4]; @@ -881,3 +1000,1860 @@ export class GhosttyTerminal { this.graphemeBuffer = null; } } + +// ============================================================================ +// Official libghostty-vt engine (ADR 042) +// ============================================================================ +// +// Rules (ADR 041): +// - Struct offsets, enum values and the packed GhosttyCell bit positions come from +// the wasm's own `ghostty_type_json` manifest at load. A missing name throws, so a +// pin bump that renames or moves a field fails loudly instead of misreading memory. +// The one hard-coded encoding is GhosttyMode (`modes.h` `ghostty_mode_new`: the +// mode number in bits 0-14, bit 15 set for ANSI modes), which the manifest only +// records as a u16. +// - No view of wasm memory outlives an export call: memory growth detaches the +// ArrayBuffer. Views are rebuilt after every call (`u8()`, `dv()`), CELLS_RAW rows +// are copied into a JS-owned array before anything else is called, and callbacks +// copy their payload before returning. +// - Callbacks never re-enter `vt_write`. + +/** Exports of the official wasm the binding calls (pointers, handles, size_t: i32). */ +interface OfficialWasmExports { + memory: WebAssembly.Memory; + __indirect_function_table: WebAssembly.Table; + ghostty_type_json(): number; + ghostty_wasm_alloc(len: number): number; + ghostty_wasm_free(ptr: number, len: number): void; + ghostty_wasm_alloc_opaque(): number; + ghostty_wasm_free_opaque(ptr: number): void; + ghostty_wasm_take_opaque(slot: number): number; + ghostty_terminal_new(allocator: number, out: number, cols: number, rows: number): number; + ghostty_terminal_free(term: number): void; + ghostty_terminal_resize( + term: number, + cols: number, + rows: number, + cellWidthPx: number, + cellHeightPx: number + ): number; + ghostty_terminal_set(term: number, option: number, value: number): number; + ghostty_terminal_get(term: number, data: number, out: number): number; + ghostty_terminal_get_multi( + term: number, + count: number, + keys: number, + values: number, + outWritten: number + ): number; + ghostty_terminal_vt_write(term: number, ptr: number, len: number): void; + /** The GhosttyTerminalScrollViewport struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_scroll_viewport(term: number, behavior: number): void; + /** The GhosttyPoint struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_grid_ref(term: number, point: number, outRef: number): number; + ghostty_grid_ref_row(ref: number, outRow: number): number; + ghostty_grid_ref_hyperlink_uri(ref: number, buf: number, len: number, outLen: number): number; + /** GhosttyRow is a u64 passed by value (a BigInt on the JS side). */ + ghostty_row_get(row: bigint, data: number, out: number): number; + ghostty_render_state_new(allocator: number, out: number): number; + ghostty_render_state_free(state: number): void; + ghostty_render_state_update(state: number, term: number): number; + ghostty_render_state_clean(state: number): number; + ghostty_render_state_get(state: number, data: number, out: number): number; + ghostty_render_state_get_multi( + state: number, + count: number, + keys: number, + values: number, + outWritten: number + ): number; + ghostty_render_state_row_iterator_new(allocator: number, out: number): number; + ghostty_render_state_row_iterator_free(iterator: number): void; + ghostty_render_state_row_iterator_next(iterator: number): number; + ghostty_render_state_row_iterator_next_dirty(iterator: number, outY: number): number; + ghostty_render_state_row_get_multi( + iterator: number, + count: number, + keys: number, + values: number, + outWritten: number + ): number; + ghostty_render_state_row_cells_new(allocator: number, out: number): number; + ghostty_render_state_row_cells_free(cells: number): void; + ghostty_render_state_row_cells_select(cells: number, x: number): number; + ghostty_render_state_row_cells_get(cells: number, data: number, out: number): number; + ghostty_key_encoder_new(allocator: number, out: number): number; + ghostty_key_encoder_free(encoder: number): void; + ghostty_key_encoder_setopt(encoder: number, option: number, value: number): void; + ghostty_key_encoder_encode( + encoder: number, + event: number, + buf: number, + len: number, + outLen: number + ): number; + ghostty_key_event_new(allocator: number, out: number): number; + ghostty_key_event_free(event: number): void; + ghostty_key_event_set_action(event: number, action: number): void; + ghostty_key_event_set_key(event: number, key: number): void; + ghostty_key_event_set_mods(event: number, mods: number): void; + ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; +} + +interface ManifestBits { + lsb: number; + width: number; + arms?: Record } | null>; +} + +interface ManifestType { + kind: string; + size: number; + fields?: Record; + values?: Record; + bits?: Record; +} + +/** A bit field of a packed value: shift right by `lsb`, then mask. */ +interface BitField { + lsb: number; + mask: number; +} + +/** Reads the bits at `lsb` (masked) from a 64-bit value split into two u32 words. */ +function readBits(lo: number, hi: number, lsb: number, mask: number): number { + if (lsb >= 32) return (hi >>> (lsb - 32)) & mask; + if (lsb === 0) return lo & mask; + return ((lo >>> lsb) | (hi << (32 - lsb))) & mask; +} + +/** Struct layouts and enum values from the wasm's `ghostty_type_json` manifest. */ +class VtLayout { + private readonly types: Record; + + constructor(types: Record) { + this.types = types; + } + + type(name: string): ManifestType { + const t = this.types[name]; + if (!t) throw new Error(`libghostty-vt manifest has no type ${name}`); + return t; + } + + size(name: string): number { + return this.type(name).size; + } + + offset(type: string, field: string): number { + const f = this.type(type).fields?.[field]; + if (!f) throw new Error(`libghostty-vt manifest has no field ${type}.${field}`); + return f.offset; + } + + enums(name: string): Record { + const values = this.type(name).values; + if (!values) throw new Error(`libghostty-vt manifest type ${name} is not an enum`); + return values; + } + + value(name: string, key: string): number { + const v = this.enums(name)[key]; + if (v === undefined) throw new Error(`libghostty-vt manifest has no value ${name}.${key}`); + return v; + } + + bitField(bits: Record | undefined, path: string): BitField { + const b = bits?.[path]; + if (!b) throw new Error(`libghostty-vt manifest has no bit field ${path}`); + return { lsb: b.lsb, mask: b.width >= 32 ? 0xffffffff : 2 ** b.width - 1 }; + } + + /** The bits of one arm of a packed union field, e.g. GhosttyCell.content.CODEPOINT. */ + armBits(type: string, field: string, arm: string): Record { + const a = this.type(type).bits?.[field]?.arms?.[arm]; + if (!a) throw new Error(`libghostty-vt manifest has no arm ${type}.${field}.${arm}`); + return a.bits; + } +} + +/** + * Ghostty's key names for the ghostty-web `Key` names that differ (ADR 042). Every + * other `Key` keeps its name; values are always translated by name because 29 keys + * have different numbers in the two builds. + */ +const OFFICIAL_KEY_NAMES: Readonly> = { + GRAVE: 'BACKQUOTE', + ZERO: 'DIGIT_0', + ONE: 'DIGIT_1', + TWO: 'DIGIT_2', + THREE: 'DIGIT_3', + FOUR: 'DIGIT_4', + FIVE: 'DIGIT_5', + SIX: 'DIGIT_6', + SEVEN: 'DIGIT_7', + EIGHT: 'DIGIT_8', + NINE: 'DIGIT_9', + UP: 'ARROW_UP', + DOWN: 'ARROW_DOWN', + LEFT: 'ARROW_LEFT', + RIGHT: 'ARROW_RIGHT', + KP_0: 'NUMPAD_0', + KP_1: 'NUMPAD_1', + KP_2: 'NUMPAD_2', + KP_3: 'NUMPAD_3', + KP_4: 'NUMPAD_4', + KP_5: 'NUMPAD_5', + KP_6: 'NUMPAD_6', + KP_7: 'NUMPAD_7', + KP_8: 'NUMPAD_8', + KP_9: 'NUMPAD_9', + KP_PLUS: 'NUMPAD_ADD', + KP_BACKSPACE: 'NUMPAD_BACKSPACE', + KP_CLEAR: 'NUMPAD_CLEAR', + KP_CLEAR_ENTRY: 'NUMPAD_CLEAR_ENTRY', + KP_COMMA: 'NUMPAD_COMMA', + KP_PERIOD: 'NUMPAD_DECIMAL', + KP_DIVIDE: 'NUMPAD_DIVIDE', + KP_ENTER: 'NUMPAD_ENTER', + KP_EQUAL: 'NUMPAD_EQUAL', + KP_MEMORY_ADD: 'NUMPAD_MEMORY_ADD', + KP_MEMORY_CLEAR: 'NUMPAD_MEMORY_CLEAR', + KP_MEMORY_RECALL: 'NUMPAD_MEMORY_RECALL', + KP_MEMORY_STORE: 'NUMPAD_MEMORY_STORE', + KP_MEMORY_SUBTRACT: 'NUMPAD_MEMORY_SUBTRACT', + KP_MULTIPLY: 'NUMPAD_MULTIPLY', + KP_PAREN_LEFT: 'NUMPAD_PAREN_LEFT', + KP_PAREN_RIGHT: 'NUMPAD_PAREN_RIGHT', + KP_MINUS: 'NUMPAD_SUBTRACT', + KP_SEPARATOR: 'NUMPAD_SEPARATOR', +}; + +/** Ghostty's key encoder option names for the ghostty-web names that differ. */ +const OFFICIAL_ENCODER_OPTION_NAMES: Readonly> = { + KITTY_KEYBOARD_FLAGS: 'KITTY_FLAGS', +}; + +/** Maps a numeric TS enum's values to the official enum's values, by name. */ +function translateEnum( + ours: Record, + layout: VtLayout, + official: string, + renames: Readonly> +): Map { + const map = new Map(); + for (const [name, value] of Object.entries(ours)) { + if (typeof value !== 'number') continue; + map.set(value, layout.value(official, renames[name] ?? name)); + } + return map; +} + +/** Enum values, offsets and bit fields the official binding uses, resolved once per instance. */ +function resolveOfficialLayout(layout: VtLayout) { + const opt = (k: string) => layout.value('GhosttyTerminalOption', k); + const tdata = (k: string) => layout.value('GhosttyTerminalData', k); + const cellBits = layout.type('GhosttyCell').bits; + const cp = layout.armBits('GhosttyCell', 'content', 'CODEPOINT'); + const cpg = layout.armBits('GhosttyCell', 'content', 'CODEPOINT_GRAPHEME'); + const pal = layout.armBits('GhosttyCell', 'content', 'BG_COLOR_PALETTE'); + const rgb = layout.armBits('GhosttyCell', 'content', 'BG_COLOR_RGB'); + const codepoint = layout.bitField(cp, 'codepoint'); + const graphemeCodepoint = layout.bitField(cpg, 'codepoint'); + if (codepoint.lsb !== graphemeCodepoint.lsb || codepoint.mask !== graphemeCodepoint.mask) { + throw new Error('libghostty-vt GhosttyCell codepoint arms differ'); + } + return { + opt: { + WRITE_PTY: opt('WRITE_PTY'), + BELL: opt('BELL'), + TITLE_CHANGED: opt('TITLE_CHANGED'), + SIZE: opt('SIZE'), + DEVICE_ATTRIBUTES: opt('DEVICE_ATTRIBUTES'), + COLOR_FOREGROUND: opt('COLOR_FOREGROUND'), + COLOR_BACKGROUND: opt('COLOR_BACKGROUND'), + COLOR_CURSOR: opt('COLOR_CURSOR'), + COLOR_PALETTE: opt('COLOR_PALETTE'), + SCROLLBACK_MAX_BYTES: opt('SCROLLBACK_MAX_BYTES'), + MODE_DEFAULT: opt('MODE_DEFAULT'), + }, + tdata: { + CURSOR_X: tdata('CURSOR_X'), + CURSOR_Y: tdata('CURSOR_Y'), + ACTIVE_SCREEN: tdata('ACTIVE_SCREEN'), + MOUSE_TRACKING: tdata('MOUSE_TRACKING'), + TITLE: tdata('TITLE'), + SCROLLBACK_ROWS: tdata('SCROLLBACK_ROWS'), + COLOR_PALETTE_DEFAULT: tdata('COLOR_PALETTE_DEFAULT'), + MODE: tdata('MODE'), + }, + screenAlternate: layout.value('GhosttyTerminalScreen', 'ALTERNATE'), + rs: { + DIRTY: layout.value('GhosttyRenderStateData', 'DIRTY'), + ROW_ITERATOR: layout.value('GhosttyRenderStateData', 'ROW_ITERATOR'), + CURSOR: layout.value('GhosttyRenderStateData', 'CURSOR'), + COLORS: layout.value('GhosttyRenderStateData', 'COLORS'), + }, + dirty: { + FALSE: layout.value('GhosttyRenderStateDirty', 'FALSE'), + PARTIAL: layout.value('GhosttyRenderStateDirty', 'PARTIAL'), + FULL: layout.value('GhosttyRenderStateDirty', 'FULL'), + }, + row: { + CELLS: layout.value('GhosttyRenderStateRowData', 'CELLS'), + CELLS_RAW: layout.value('GhosttyRenderStateRowData', 'CELLS_RAW'), + VIEWPORT_Y: layout.value('GhosttyRenderStateRowData', 'VIEWPORT_Y'), + }, + cellData: { + STYLE: layout.value('GhosttyRenderStateRowCellsData', 'STYLE'), + GRAPHEMES_LEN: layout.value('GhosttyRenderStateRowCellsData', 'GRAPHEMES_LEN'), + GRAPHEMES_BUF: layout.value('GhosttyRenderStateRowCellsData', 'GRAPHEMES_BUF'), + }, + rowWrapContinuation: layout.value('GhosttyRowData', 'WRAP_CONTINUATION'), + cell: { + tag: layout.bitField(cellBits, 'content_tag'), + content: layout.bitField(cellBits, 'content'), + style: layout.bitField(cellBits, 'style_id'), + wide: layout.bitField(cellBits, 'wide'), + hyperlink: layout.bitField(cellBits, 'hyperlink'), + codepoint, + paletteIndex: layout.bitField(pal, 'index'), + r: layout.bitField(rgb, 'r'), + g: layout.bitField(rgb, 'g'), + b: layout.bitField(rgb, 'b'), + }, + tag: { + CODEPOINT: layout.value('GhosttyCellContentTag', 'CODEPOINT'), + CODEPOINT_GRAPHEME: layout.value('GhosttyCellContentTag', 'CODEPOINT_GRAPHEME'), + BG_COLOR_PALETTE: layout.value('GhosttyCellContentTag', 'BG_COLOR_PALETTE'), + BG_COLOR_RGB: layout.value('GhosttyCellContentTag', 'BG_COLOR_RGB'), + }, + wide: { + NARROW: layout.value('GhosttyCellWide', 'NARROW'), + WIDE: layout.value('GhosttyCellWide', 'WIDE'), + }, + styleColor: { + NONE: layout.value('GhosttyStyleColorTag', 'NONE'), + PALETTE: layout.value('GhosttyStyleColorTag', 'PALETTE'), + RGB: layout.value('GhosttyStyleColorTag', 'RGB'), + tag: layout.offset('GhosttyStyleColor', 'tag'), + value: layout.offset('GhosttyStyleColor', 'value'), + palette: layout.offset('GhosttyStyleColorValue', 'palette'), + rgb: layout.offset('GhosttyStyleColorValue', 'rgb'), + }, + style: { + size: layout.size('GhosttyStyle'), + sizeField: layout.offset('GhosttyStyle', 'size'), + fg: layout.offset('GhosttyStyle', 'fg_color'), + bg: layout.offset('GhosttyStyle', 'bg_color'), + bold: layout.offset('GhosttyStyle', 'bold'), + italic: layout.offset('GhosttyStyle', 'italic'), + faint: layout.offset('GhosttyStyle', 'faint'), + blink: layout.offset('GhosttyStyle', 'blink'), + inverse: layout.offset('GhosttyStyle', 'inverse'), + invisible: layout.offset('GhosttyStyle', 'invisible'), + strikethrough: layout.offset('GhosttyStyle', 'strikethrough'), + underline: layout.offset('GhosttyStyle', 'underline'), + }, + cursor: { + size: layout.size('GhosttyRenderStateCursor'), + sizeField: layout.offset('GhosttyRenderStateCursor', 'size'), + visible: layout.offset('GhosttyRenderStateCursor', 'visible'), + blinking: layout.offset('GhosttyRenderStateCursor', 'blinking'), + visualStyle: layout.offset('GhosttyRenderStateCursor', 'visual_style'), + }, + cursorStyle: { + BAR: layout.value('GhosttyRenderStateCursorVisualStyle', 'BAR'), + BLOCK: layout.value('GhosttyRenderStateCursorVisualStyle', 'BLOCK'), + UNDERLINE: layout.value('GhosttyRenderStateCursorVisualStyle', 'UNDERLINE'), + }, + colors: { + size: layout.size('GhosttyRenderStateColors'), + sizeField: layout.offset('GhosttyRenderStateColors', 'size'), + background: layout.offset('GhosttyRenderStateColors', 'background'), + foreground: layout.offset('GhosttyRenderStateColors', 'foreground'), + cursor: layout.offset('GhosttyRenderStateColors', 'cursor'), + cursorHasValue: layout.offset('GhosttyRenderStateColors', 'cursor_has_value'), + palette: layout.offset('GhosttyRenderStateColors', 'palette'), + rgbSize: layout.size('GhosttyColorRgb'), + r: layout.offset('GhosttyColorRgb', 'r'), + g: layout.offset('GhosttyColorRgb', 'g'), + b: layout.offset('GhosttyColorRgb', 'b'), + }, + cellsView: { + size: layout.size('GhosttyCellsView'), + ptr: layout.offset('GhosttyCellsView', 'ptr'), + len: layout.offset('GhosttyCellsView', 'len'), + }, + cellSize: layout.size('GhosttyCell'), + scroll: { + size: layout.size('GhosttyTerminalScrollViewport'), + tag: layout.offset('GhosttyTerminalScrollViewport', 'tag'), + value: layout.offset('GhosttyTerminalScrollViewport', 'value'), + BOTTOM: layout.value('GhosttyTerminalScrollViewportTag', 'BOTTOM'), + ROW: layout.value('GhosttyTerminalScrollViewportTag', 'ROW'), + }, + point: { + size: layout.size('GhosttyPoint'), + tag: layout.offset('GhosttyPoint', 'tag'), + value: layout.offset('GhosttyPoint', 'value'), + x: layout.offset('GhosttyPointCoordinate', 'x'), + y: layout.offset('GhosttyPointCoordinate', 'y'), + ACTIVE: layout.value('GhosttyPointTag', 'ACTIVE'), + HISTORY: layout.value('GhosttyPointTag', 'HISTORY'), + }, + gridRef: { + size: layout.size('GhosttyGridRef'), + sizeField: layout.offset('GhosttyGridRef', 'size'), + }, + string: { + size: layout.size('GhosttyString'), + ptr: layout.offset('GhosttyString', 'ptr'), + len: layout.offset('GhosttyString', 'len'), + }, + mode: { + size: layout.size('GhosttyTerminalModeConfig'), + mode: layout.offset('GhosttyTerminalModeConfig', 'mode'), + value: layout.offset('GhosttyTerminalModeConfig', 'value'), + }, + sizeReport: { + rows: layout.offset('GhosttySizeReportSize', 'rows'), + columns: layout.offset('GhosttySizeReportSize', 'columns'), + cellWidth: layout.offset('GhosttySizeReportSize', 'cell_width'), + cellHeight: layout.offset('GhosttySizeReportSize', 'cell_height'), + }, + da: { + primary: layout.offset('GhosttyDeviceAttributes', 'primary'), + secondary: layout.offset('GhosttyDeviceAttributes', 'secondary'), + tertiary: layout.offset('GhosttyDeviceAttributes', 'tertiary'), + conformance: layout.offset('GhosttyDeviceAttributesPrimary', 'conformance_level'), + features: layout.offset('GhosttyDeviceAttributesPrimary', 'features'), + numFeatures: layout.offset('GhosttyDeviceAttributesPrimary', 'num_features'), + deviceType: layout.offset('GhosttyDeviceAttributesSecondary', 'device_type'), + firmware: layout.offset('GhosttyDeviceAttributesSecondary', 'firmware_version'), + rom: layout.offset('GhosttyDeviceAttributesSecondary', 'rom_cartridge'), + unitId: layout.offset('GhosttyDeviceAttributesTertiary', 'unit_id'), + }, + result: { + SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), + OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), + }, + }; +} + +type OfficialLayout = ReturnType; + +/** + * The official engine: Ghostty's unpatched libghostty-vt wasm (ADR 041, ADR 042). + */ +export class OfficialGhostty extends Ghostty { + readonly engine = 'official' as const; + /** @internal The wasm exports; only this file calls them. */ + readonly ex: OfficialWasmExports; + /** @internal */ + readonly L: OfficialLayout; + /** @internal One function table per instance; terminals install their callbacks in it. */ + readonly fnTable: FnTable; + /** @internal ghostty-web `Key` value → official `GhosttyKey` value. */ + readonly keys: Map; + /** @internal ghostty-web `KeyEncoderOption` → official `GhosttyKeyEncoderOption`. */ + readonly encoderOptions: Map; + /** @internal ghostty-web `KeyAction` → official `GhosttyKeyAction`. */ + readonly keyActions: Map; + /** @internal */ + readonly unidentifiedKey: number; + /** The library's manifest header: library version and build commit. */ + readonly libraryVersion: string; + + constructor(exports: OfficialWasmExports) { + super(); + this.ex = exports; + const p = exports.ghostty_type_json(); + const u8 = new Uint8Array(exports.memory.buffer); + let end = p; + while (u8[end] !== 0) end++; + const manifest = JSON.parse(new TextDecoder().decode(u8.subarray(p, end))) as { + library_version?: string; + types: Record; + }; + const layout = new VtLayout(manifest.types); + this.L = resolveOfficialLayout(layout); + this.libraryVersion = manifest.library_version ?? ''; + this.fnTable = new FnTable(exports.__indirect_function_table); + this.keys = translateEnum(Key, layout, 'GhosttyKey', OFFICIAL_KEY_NAMES); + this.encoderOptions = translateEnum( + KeyEncoderOption, + layout, + 'GhosttyKeyEncoderOption', + OFFICIAL_ENCODER_OPTION_NAMES + ); + this.keyActions = translateEnum(KeyAction, layout, 'GhosttyKeyAction', {}); + this.unidentifiedKey = layout.value('GhosttyKey', 'UNIDENTIFIED'); + } + + createKeyEncoder(): KeyEncoder { + return new OfficialKeyEncoder(this); + } + + createTerminal( + cols: number = 80, + rows: number = 24, + config?: GhosttyTerminalConfig + ): OfficialTerminal { + return new OfficialTerminal(this, cols, rows, config); + } + + /** @internal Creates an object through a `GhosttyFoo* out` slot and returns its handle. */ + newHandle(what: string, create: (slot: number) => number): number { + const ex = this.ex; + const slot = ex.ghostty_wasm_alloc_opaque(); + try { + const rc = create(slot); + if (rc !== 0) throw new Error(`libghostty-vt ${what} failed: ${rc}`); + return ex.ghostty_wasm_take_opaque(slot); + } finally { + ex.ghostty_wasm_free_opaque(slot); + } + } +} + +/** Scratch memory: one wasm allocation carved into aligned, named slots. */ +class Scratch { + readonly base: number; + readonly len: number; + private readonly ex: OfficialWasmExports; + + private constructor(ex: OfficialWasmExports, base: number, len: number) { + this.ex = ex; + this.base = base; + this.len = len; + } + + /** Allocates the slots; each value is [size, align]. Returns the slot addresses. */ + static create( + ex: OfficialWasmExports, + slots: Record + ): { scratch: Scratch; at: Record } { + let off = 0; + const offsets = {} as Record; + for (const key of Object.keys(slots) as K[]) { + const [size, align] = slots[key]; + off = Math.ceil(off / align) * align; + offsets[key] = off; + off += size; + } + const len = off + 8; + const raw = ex.ghostty_wasm_alloc(len); + if (!raw) throw new Error('libghostty-vt: out of memory'); + const base = Math.ceil(raw / 8) * 8; + const at = {} as Record; + for (const key of Object.keys(offsets) as K[]) at[key] = base + offsets[key]; + return { scratch: new Scratch(ex, raw, len), at }; + } + + free(): void { + this.ex.ghostty_wasm_free(this.base, this.len); + } +} + +/** A decoded row: JS-owned cells and the strings of its multi-codepoint graphemes. */ +interface RowRef { + cells: GhosttyCell[]; + /** Index of the row's first cell in `cells`. */ + base: number; + /** Column → grapheme string, for cells holding more than one codepoint. */ + graphemes: Map | null; +} + +interface DecodedStyle { + /** 0xRRGGBB, or -1 for the default. */ + fg: number; + bg: number; + flags: number; +} + +const I32 = WasmValType.I32; +/** At most this many pages of rows outside the viewport stay cached per content generation. */ +const PAGE_CACHE_SIZE = 16; +/** `scrollback: 0` (unlimited on the Coder wasm) becomes this byte ceiling. */ +const UNLIMITED_SCROLLBACK_BYTES = 32 * 1024 * 1024; +/** The Coder engine's default scrollback limit when no config is given. */ +const DEFAULT_SCROLLBACK_BYTES = 10000; +/** The Coder engine's default colours, used when the theme leaves one unset. */ +const FALLBACK_FOREGROUND = 0xcccccc; +const FALLBACK_BACKGROUND = 0x000000; +/** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ +const GRAPHEME_SCRATCH = 32; + +function blankCell(): GhosttyCell { + return { + codepoint: 0, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; +} + +function setRgb(cell: GhosttyCell, fg: number, bg: number): void { + cell.fg_r = (fg >>> 16) & 0xff; + cell.fg_g = (fg >>> 8) & 0xff; + cell.fg_b = fg & 0xff; + cell.bg_r = (bg >>> 16) & 0xff; + cell.bg_g = (bg >>> 8) & 0xff; + cell.bg_b = bg & 0xff; +} + +function rgbObject(rgb: number): RGB { + return { r: (rgb >>> 16) & 0xff, g: (rgb >>> 8) & 0xff, b: rgb & 0xff }; +} + +/** + * A terminal on the official libghostty-vt wasm (ADR 042). + * + * Rows are read through the render state: a refresh runs `render_state_update`, + * visits the dirty rows with `row_iterator_next_dirty`, and decodes each row's + * `CELLS_RAW` into JS-owned cells indexed by viewport row. Refreshes happen only + * when the generation (bumped by writes, resizes, pixel-size changes and viewport + * moves) has changed, so the many `update()` calls of one frame decode each row + * once. Rows outside the viewport are read a page at a time through a second + * render state and cached until the content changes (a write, resize or + * pixel-size change), so scrolling reuses them. + */ +export class OfficialTerminal implements GhosttyTerminal { + readonly answersSizeQueries = true; + + private readonly vt: OfficialGhostty; + private readonly ex: OfficialWasmExports; + private readonly L: OfficialLayout; + private term = 0; + private rs = 0; + private iter = 0; + private rowCells = 0; + private pageRs = 0; + private readonly scratch: Scratch; + private readonly at: Record< + | 'iterH' + | 'cellsH' + | 'y' + | 'dirty' + | 'viewY' + | 'view' + | 'cursor' + | 'colors' + | 'style' + | 'rsKeys' + | 'rsVals' + | 'rowKeys' + | 'rowVals' + | 'tKeys' + | 'tVals' + | 'curX' + | 'curY' + | 'sbRows' + | 'screen' + | 'scroll' + | 'point' + | 'gridRef' + | 'row64' + | 'str' + | 'mode' + | 'u32' + | 'u8' + | 'gBuf' + | 'palette', + number + >; + private readonly callbacks: InstalledFn[] = []; + private _cols: number; + private _rows: number; + private cellWidthPx = 0; + private cellHeightPx = 0; + + // Generation and refresh state. `contentGen` is bumped with `generation` by + // everything except a viewport move, which changes what is shown, not the rows. + private generation = 1; + private contentGen = 1; + private refreshedGen = 0; + private infoGen = 0; + private decodeAll = true; + private readonly dirtyRows = new Set(); + private dirtyMax: DirtyState = DirtyState.NONE; + + // The JS copy of the viewport, indexed by viewport row. + private viewCells: GhosttyCell[] = []; + private viewGraphemes: (Map | null)[] = []; + private raw = new Uint32Array(0); + private readonly rowStyles = new Map(); + + // Terminal info, read once per generation. + private cursorX = 0; + private cursorY = 0; + private sbRows = 0; + private altScreen = false; + + // Render-state cursor and colours from the last refresh. + private cursorVisible = true; + private cursorBlinking = false; + private cursorStyle: RenderStateCursor['style'] = 'block'; + private defFg = 0xffffff; + private defBg = 0; + private cursorColor: number | null = null; + private readonly palette = new Uint8Array(256 * 3); + + // Viewport position: `offset` lines scrolled up, last moved at `movedSb` scrollback rows. + private offset = 0; + private movedSb = 0; + + // Rows outside the viewport, keyed by the absolute row their page starts at. + private readonly pages = new Map(); + private pageGen = 0; + + // Effects. + private readonly responses: Uint8Array[] = []; + private bell = false; + private titleChanged = false; + private pendingTitle: string | null = null; + + constructor(vt: OfficialGhostty, cols: number, rows: number, config?: GhosttyTerminalConfig) { + this.vt = vt; + this.ex = vt.ex; + this.L = vt.L; + this._cols = cols; + this._rows = rows; + const ex = this.ex; + const L = this.L; + + this.term = vt.newHandle('terminal_new', (slot) => ex.ghostty_terminal_new(0, slot, cols, rows)); + this.rs = vt.newHandle('render_state_new', (slot) => ex.ghostty_render_state_new(0, slot)); + this.iter = vt.newHandle('row_iterator_new', (slot) => + ex.ghostty_render_state_row_iterator_new(0, slot) + ); + this.rowCells = vt.newHandle('row_cells_new', (slot) => + ex.ghostty_render_state_row_cells_new(0, slot) + ); + + const { scratch, at } = Scratch.create(ex, { + iterH: [4, 4], + cellsH: [4, 4], + y: [4, 4], + dirty: [4, 4], + viewY: [4, 4], + view: [L.cellsView.size, 8], + cursor: [L.cursor.size, 8], + colors: [L.colors.size, 8], + style: [L.style.size, 8], + rsKeys: [12, 4], + rsVals: [12, 4], + rowKeys: [12, 4], + rowVals: [12, 4], + tKeys: [16, 4], + tVals: [16, 4], + curX: [4, 4], + curY: [4, 4], + sbRows: [4, 4], + screen: [4, 4], + scroll: [L.scroll.size, 8], + point: [L.point.size, 8], + gridRef: [L.gridRef.size, 8], + row64: [8, 8], + str: [L.string.size, 8], + mode: [L.mode.size, 8], + u32: [8, 8], + u8: [8, 8], + gBuf: [GRAPHEME_SCRATCH * 4, 8], + palette: [256 * L.colors.rgbSize, 8], + } as const); + this.scratch = scratch; + this.at = at; + + // Fixed scratch contents: handle slots and the key/value arrays of the multi-gets. + const dv = this.dv(); + dv.setUint32(at.iterH, this.iter, true); + dv.setUint32(at.cellsH, this.rowCells, true); + this.setU32s(at.rsKeys, [L.rs.DIRTY, L.rs.CURSOR, L.rs.COLORS]); + this.setU32s(at.rsVals, [at.dirty, at.cursor, at.colors]); + this.setU32s(at.rowKeys, [L.row.CELLS_RAW, L.row.VIEWPORT_Y, L.row.CELLS]); + this.setU32s(at.rowVals, [at.view, at.viewY, at.cellsH]); + this.setU32s(at.tKeys, [ + L.tdata.CURSOR_X, + L.tdata.CURSOR_Y, + L.tdata.SCROLLBACK_ROWS, + L.tdata.ACTIVE_SCREEN, + ]); + this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); + + this.installCallbacks(); + this.applyConfig(config); + this.allocView(); + } + + get cols(): number { + return this._cols; + } + + get rows(): number { + return this._rows; + } + + // ========================================================================== + // Lifecycle + // ========================================================================== + + write(data: string | Uint8Array): void { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + // Writes never leave the view scrolled (terminal.ts scrolls to the bottom after + // writing), so prune and screen switches happen with the viewport at the bottom. + if (this.offset !== 0) { + this.scrollViewport(this.L.scroll.BOTTOM, 0); + this.offset = 0; + this.decodeAll = true; + } + if (bytes.length > 0) { + const ex = this.ex; + const ptr = ex.ghostty_wasm_alloc(bytes.length); + if (!ptr) throw new Error('libghostty-vt: out of memory'); + this.u8().set(bytes, ptr); + ex.ghostty_terminal_vt_write(this.term, ptr, bytes.length); + ex.ghostty_wasm_free(ptr, bytes.length); + } + this.generation++; + this.contentGen++; + if (this.titleChanged) this.readTitle(); + } + + resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { + if (cols === this._cols && rows === this._rows) { + if (cellWidthPx !== undefined && cellHeightPx !== undefined) { + this.setCellPixelSize(cellWidthPx, cellHeightPx); + } + return; + } + this._cols = cols; + this._rows = rows; + if (cellWidthPx !== undefined && cellHeightPx !== undefined) { + this.cellWidthPx = Math.max(0, Math.round(cellWidthPx)); + this.cellHeightPx = Math.max(0, Math.round(cellHeightPx)); + } + // One call per real change: with mode 2048 on, every call sends a size report. + const rc = this.ex.ghostty_terminal_resize( + this.term, + cols, + rows, + this.cellWidthPx, + this.cellHeightPx + ); + if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); + this.generation++; + this.contentGen++; + this.decodeAll = true; + this.allocView(); + if (this.offset > 0) { + // Reflow moves Ghostty's viewport with the rows it shows; put it back k rows + // above the bottom so the copy's top stays at S - k. + this.info(); + this.offset = this.altScreen ? 0 : Math.min(this.offset, this.sbRows); + this.movedSb = this.sbRows; + if (this.offset === 0) this.scrollViewport(this.L.scroll.BOTTOM, 0); + else this.scrollViewport(this.L.scroll.ROW, this.sbRows - this.offset); + this.generation++; + } + } + + setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { + const w = Math.max(0, Math.round(cellWidthPx)); + const h = Math.max(0, Math.round(cellHeightPx)); + if (w === this.cellWidthPx && h === this.cellHeightPx) return; + this.cellWidthPx = w; + this.cellHeightPx = h; + const rc = this.ex.ghostty_terminal_resize(this.term, this._cols, this._rows, w, h); + if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); + this.generation++; + this.contentGen++; + } + + setViewportOffset(linesFromBottom: number): void { + this.info(); + const sb = this.sbRows; + const k = this.altScreen ? 0 : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, sb)); + if (k === this.offset && (k === 0 || sb === this.movedSb)) return; + if (k === 0) this.scrollViewport(this.L.scroll.BOTTOM, 0); + // ROW counts from the top of scrollback, viewportY from the bottom. + else this.scrollViewport(this.L.scroll.ROW, sb - k); + this.offset = k; + this.movedSb = sb; + this.decodeAll = true; + this.generation++; + } + + free(): void { + const ex = this.ex; + if (!this.term) return; + ex.ghostty_terminal_free(this.term); + this.term = 0; + // Slots are released only after nothing can call them (fn-table.ts rule 3). + for (const fn of this.callbacks) fn.release(); + this.callbacks.length = 0; + if (this.pageRs) ex.ghostty_render_state_free(this.pageRs); + ex.ghostty_render_state_row_cells_free(this.rowCells); + ex.ghostty_render_state_row_iterator_free(this.iter); + ex.ghostty_render_state_free(this.rs); + this.pageRs = 0; + this.scratch.free(); + } + + // ========================================================================== + // Render state + // ========================================================================== + + update(): DirtyState { + this.refresh(); + return this.offset > 0 ? DirtyState.FULL : this.dirtyMax; + } + + getCursor(): RenderStateCursor { + this.refresh(); + return { + x: this.cursorX, + y: this.cursorY, + viewportX: this.cursorX, + viewportY: this.cursorY, + visible: this.cursorVisible, + blinking: this.cursorBlinking, + style: this.cursorStyle, + }; + } + + getColors(): RenderStateColors { + this.refresh(); + return { + background: rgbObject(this.defBg), + foreground: rgbObject(this.defFg), + cursor: this.cursorColor === null ? null : rgbObject(this.cursorColor), + }; + } + + isRowDirty(y: number): boolean { + this.refresh(); + // The renderer repaints every row while scrolled anyway. + if (this.offset > 0) return true; + return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); + } + + markClean(): void { + this.dirtyRows.clear(); + this.dirtyMax = DirtyState.NONE; + } + + getViewport(): GhosttyCell[] { + this.refresh(); + if (this.offset === 0) return this.viewCells; + // Scrolled: the active screen is partly below the viewport. + const out: GhosttyCell[] = []; + for (let y = 0; y < this._rows; y++) { + const ref = this.rowAt(this.sbRows + y); + for (let x = 0; x < this._cols; x++) out.push(ref.cells[ref.base + x]); + } + return out; + } + + getLine(y: number): GhosttyCell[] | null { + if (y < 0 || y >= this._rows) return null; + this.refresh(); + return this.copyRow(this.rowAt(this.sbRows + y)); + } + + isDirty(): boolean { + return this.update() !== DirtyState.NONE; + } + + needsFullRedraw(): boolean { + return this.update() === DirtyState.FULL; + } + + clearDirty(): void { + this.markClean(); + } + + getDimensions(): { cols: number; rows: number } { + return { cols: this._cols, rows: this._rows }; + } + + // ========================================================================== + // Modes + // ========================================================================== + + isAlternateScreen(): boolean { + this.info(); + return this.altScreen; + } + + hasBracketedPaste(): boolean { + return this.getMode(2004, false); + } + + hasFocusEvents(): boolean { + return this.getMode(1004, false); + } + + hasMouseTracking(): boolean { + const at = this.at; + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MOUSE_TRACKING, at.u8); + return rc === 0 && this.u8()[at.u8] !== 0; + } + + getMode(mode: number, isAnsi: boolean = false): boolean { + const at = this.at; + const M = this.L.mode; + const dv = this.dv(); + // GhosttyMode (modes.h ghostty_mode_new): number in bits 0-14, bit 15 = ANSI. + dv.setUint16(at.mode + M.mode, (mode & 0x7fff) | (isAnsi ? 0x8000 : 0), true); + dv.setUint8(at.mode + M.value, 0); + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MODE, at.mode); + return rc === 0 && this.u8()[at.mode + M.value] !== 0; + } + + // ========================================================================== + // Scrollback, graphemes, hyperlinks + // ========================================================================== + + getScrollbackLength(): number { + this.info(); + return this.sbRows; + } + + getScrollbackLine(offset: number): GhosttyCell[] | null { + this.refresh(); + if (offset < 0 || offset >= this.sbRows) return null; + return this.copyRow(this.rowAt(offset)); + } + + isRowWrapped(row: number): boolean { + if (row < 0 || row >= this._rows) return false; + const at = this.at; + if (!this.gridRef(this.L.point.ACTIVE, 0, row)) return false; + if (this.ex.ghostty_grid_ref_row(at.gridRef, at.row64) !== 0) return false; + const rowValue = this.dv().getBigUint64(at.row64, true); + if (this.ex.ghostty_row_get(rowValue, this.L.rowWrapContinuation, at.u8) !== 0) return false; + return this.u8()[at.u8] !== 0; + } + + getHyperlinkUri(row: number, col: number): string | null { + if (row < 0 || row >= this._rows) return null; + return this.hyperlinkAt(this.L.point.ACTIVE, col, row); + } + + getScrollbackHyperlinkUri(offset: number, col: number): string | null { + return this.hyperlinkAt(this.L.point.HISTORY, col, offset); + } + + getGrapheme(row: number, col: number): number[] | null { + if (row < 0 || row >= this._rows) return null; + return this.codepointsOf(this.getGraphemeString(row, col)); + } + + getGraphemeString(row: number, col: number): string { + if (row < 0 || row >= this._rows || col < 0 || col >= this._cols) return ' '; + this.refresh(); + return this.graphemeOf(this.rowAt(this.sbRows + row), col); + } + + getScrollbackGrapheme(offset: number, col: number): number[] | null { + this.refresh(); + if (offset < 0 || offset >= this.sbRows) return null; + return this.codepointsOf(this.getScrollbackGraphemeString(offset, col)); + } + + getScrollbackGraphemeString(offset: number, col: number): string { + this.refresh(); + if (offset < 0 || offset >= this.sbRows || col < 0 || col >= this._cols) return ' '; + return this.graphemeOf(this.rowAt(offset), col); + } + + // ========================================================================== + // Replies and effects + // ========================================================================== + + hasResponse(): boolean { + return this.responses.length > 0; + } + + readResponse(): string | null { + if (this.responses.length === 0) return null; + let total = 0; + for (const r of this.responses) total += r.length; + const all = new Uint8Array(total); + let off = 0; + for (const r of this.responses) { + all.set(r, off); + off += r.length; + } + this.responses.length = 0; + return new TextDecoder().decode(all); + } + + readEffects(): TerminalEffects { + const effects = { bell: this.bell, title: this.pendingTitle }; + this.bell = false; + this.pendingTitle = null; + return effects; + } + + // ========================================================================== + // Private: memory helpers + // ========================================================================== + + /** A fresh byte view; never keep one across an export call. */ + private u8(): Uint8Array { + return new Uint8Array(this.ex.memory.buffer); + } + + /** A fresh DataView; never keep one across an export call. */ + private dv(): DataView { + return new DataView(this.ex.memory.buffer); + } + + private setU32s(ptr: number, values: number[]): void { + const dv = this.dv(); + values.forEach((v, i) => dv.setUint32(ptr + i * 4, v, true)); + } + + private rgbAt(ptr: number): number { + const u8 = this.u8(); + const C = this.L.colors; + return (u8[ptr + C.r] << 16) | (u8[ptr + C.g] << 8) | u8[ptr + C.b]; + } + + private writeRgb(ptr: number, rgb: number): void { + const u8 = this.u8(); + const C = this.L.colors; + u8[ptr + C.r] = (rgb >>> 16) & 0xff; + u8[ptr + C.g] = (rgb >>> 8) & 0xff; + u8[ptr + C.b] = rgb & 0xff; + } + + private setOption(option: number, value: number, what: string): void { + const rc = this.ex.ghostty_terminal_set(this.term, option, value); + if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); + } + + private scrollViewport(tag: number, row: number): void { + const at = this.at; + const S = this.L.scroll; + const u8 = this.u8(); + u8.fill(0, at.scroll, at.scroll + S.size); + const dv = this.dv(); + dv.setInt32(at.scroll + S.tag, tag, true); + // The ROW arm is a usize (u32 on wasm32); the 8-byte union is zeroed above. + dv.setUint32(at.scroll + S.value, row, true); + this.ex.ghostty_terminal_scroll_viewport(this.term, at.scroll); + } + + // ========================================================================== + // Private: setup + // ========================================================================== + + private installCallbacks(): void { + const L = this.L; + const table = this.vt.fnTable; + const install = ( + option: number, + what: string, + fn: (...args: number[]) => number | undefined, + params: number, + returnsBool: boolean + ) => { + const installed = table.install( + fn as (...args: number[]) => number | void, + new Array(params).fill(I32), + returnsBool ? [I32] : [] + ); + this.callbacks.push(installed); + this.setOption(option, installed.index, what); + }; + + // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. + install( + L.opt.WRITE_PTY, + 'WRITE_PTY', + (_t, _u, ptr, len) => { + this.responses.push(this.u8().slice(ptr, ptr + len)); + return undefined; + }, + 4, + false + ); + install( + L.opt.BELL, + 'BELL', + () => { + this.bell = true; + return undefined; + }, + 2, + false + ); + // The title is read after vt_write returns (DATA_TITLE), not inside the callback. + install( + L.opt.TITLE_CHANGED, + 'TITLE_CHANGED', + () => { + this.titleChanged = true; + return undefined; + }, + 2, + false + ); + // SIZE(terminal, userdata, out): XTWINOPS 14/16/18/19 t and mode 2048 reports. + install( + L.opt.SIZE, + 'SIZE', + (_t, _u, out) => { + const Z = L.sizeReport; + const dv = this.dv(); + dv.setUint16(out + Z.rows, this._rows, true); + dv.setUint16(out + Z.columns, this._cols, true); + dv.setUint32(out + Z.cellWidth, this.cellWidthPx, true); + dv.setUint32(out + Z.cellHeight, this.cellHeightPx, true); + return 1; + }, + 3, + true + ); + // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, as the Coder wasm answers. + install( + L.opt.DEVICE_ATTRIBUTES, + 'DEVICE_ATTRIBUTES', + (_t, _u, out) => { + const D = L.da; + const dv = this.dv(); + const primary = out + D.primary; + const secondary = out + D.secondary; + dv.setUint16(primary + D.conformance, 62, true); + dv.setUint16(primary + D.features, 22, true); + dv.setUint32(primary + D.numFeatures, 1, true); + dv.setUint16(secondary + D.deviceType, 1, true); + dv.setUint16(secondary + D.firmware, 10, true); + dv.setUint16(secondary + D.rom, 0, true); + dv.setUint32(out + D.tertiary + D.unitId, 0, true); + return 1; + }, + 3, + true + ); + } + + private applyConfig(config: GhosttyTerminalConfig | undefined): void { + const L = this.L; + const at = this.at; + + // Scrollback keeps today's derived byte budget (ADR 042). 0 bytes would disable + // scrollback and NULL would remove the limit, so neither is ever passed. + const limit = config?.scrollbackLimit ?? DEFAULT_SCROLLBACK_BYTES; + this.dv().setUint32(at.u32, limit > 0 ? limit : UNLIMITED_SCROLLBACK_BYTES, true); + this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + + // Grapheme clustering (mode 2027) on by default, so RIS keeps it. + const dv = this.dv(); + dv.setUint16(at.mode + L.mode.mode, 2027, true); + dv.setUint8(at.mode + L.mode.value, 1); + this.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); + + const given = config?.colorsSet; + const isSet = (flag: boolean | undefined, value: number | undefined) => + given ? !!flag && value !== undefined : !!value; + const setColor = (option: number, value: number, what: string) => { + this.writeRgb(at.u32, value); + this.setOption(option, at.u32, what); + }; + // Foreground and background are always both set: Ghostty's render state copies + // the default colours only when both are set (render.zig `bg_fg`), otherwise it + // keeps white on black and ignores the given colour, OSC 10/11 and DECSCNM. + // An unset one falls back to the Coder engine's effective default. + setColor( + L.opt.COLOR_FOREGROUND, + isSet(given?.foreground, config?.fgColor) ? (config?.fgColor ?? 0) : FALLBACK_FOREGROUND, + 'COLOR_FOREGROUND' + ); + setColor( + L.opt.COLOR_BACKGROUND, + isSet(given?.background, config?.bgColor) ? (config?.bgColor ?? 0) : FALLBACK_BACKGROUND, + 'COLOR_BACKGROUND' + ); + if (!config) return; + if (isSet(given?.cursor, config.cursorColor)) { + setColor(L.opt.COLOR_CURSOR, config.cursorColor ?? 0, 'COLOR_CURSOR'); + } + const palette = config.palette ?? []; + const entries = palette + .map((value, i) => ({ value, i })) + .filter(({ value, i }) => i < 16 && isSet(given?.palette?.[i], value)); + if (entries.length > 0) { + // Unset entries and 16-255 keep Ghostty's default palette. + const rc = this.ex.ghostty_terminal_get( + this.term, + L.tdata.COLOR_PALETTE_DEFAULT, + at.palette + ); + if (rc !== 0) throw new Error(`libghostty-vt COLOR_PALETTE_DEFAULT failed: ${rc}`); + for (const { value, i } of entries) this.writeRgb(at.palette + i * L.colors.rgbSize, value); + this.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); + } + } + + private allocView(): void { + const total = this._cols * this._rows; + while (this.viewCells.length < total) this.viewCells.push(blankCell()); + this.viewCells.length = total; + this.viewGraphemes = new Array(this._rows).fill(null); + } + + // ========================================================================== + // Private: refresh + // ========================================================================== + + /** Cursor, scrollback rows and active screen, read once per generation. */ + private info(): void { + if (this.infoGen === this.generation) return; + const at = this.at; + const rc = this.ex.ghostty_terminal_get_multi(this.term, 4, at.tKeys, at.tVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt terminal_get_multi failed: ${rc}`); + const dv = this.dv(); + this.cursorX = dv.getUint16(at.curX, true); + this.cursorY = dv.getUint16(at.curY, true); + this.sbRows = dv.getUint32(at.sbRows, true); + this.altScreen = dv.getInt32(at.screen, true) === this.L.screenAlternate; + this.infoGen = this.generation; + } + + /** + * Brings the viewport copy up to date with the terminal, once per generation. Each + * refresh decodes only the rows Ghostty reports dirty (every row after a viewport + * move or resize), adds them to the dirty set, and ends with `render_state_clean`, + * which clears Ghostty's global and per-row dirty state. The JS dirty state + * accumulates until `markClean()`. + */ + private refresh(): void { + if (this.refreshedGen === this.generation) return; + this.info(); + const ex = this.ex; + const L = this.L; + const at = this.at; + + let rc = ex.ghostty_render_state_update(this.rs, this.term); + if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); + let dv = this.dv(); + dv.setUint32(at.cursor + L.cursor.sizeField, L.cursor.size, true); + dv.setUint32(at.colors + L.colors.sizeField, L.colors.size, true); + rc = ex.ghostty_render_state_get_multi(this.rs, 3, at.rsKeys, at.rsVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt render_state_get_multi failed: ${rc}`); + // Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no + // row dirty when only the defaults change (OSC 10/11/110/111, DECSCNM): its own + // renderer applies them globally. Redecode every row when any colour changed. + const colorsChanged = this.readCursorAndColors(); + dv = this.dv(); + const dirty = dv.getInt32(at.dirty, true); + rc = ex.ghostty_render_state_get(this.rs, L.rs.ROW_ITERATOR, at.iterH); + if (rc !== 0) throw new Error(`libghostty-vt ROW_ITERATOR failed: ${rc}`); + + const all = this.decodeAll || colorsChanged; + const cols = this._cols; + while ( + all + ? ex.ghostty_render_state_row_iterator_next(this.iter) + : ex.ghostty_render_state_row_iterator_next_dirty(this.iter, at.y) + ) { + const len = this.readRowRaw(); + const y = this.dv().getInt32(at.viewY, true); + if (y < 0 || y >= this._rows) continue; + this.viewGraphemes[y] = this.decodeRow(this.viewCells, y * cols, len); + this.dirtyRows.add(y); + } + ex.ghostty_render_state_clean(this.rs); + + const level = all + ? DirtyState.FULL + : dirty === L.dirty.FULL + ? DirtyState.FULL + : dirty === L.dirty.PARTIAL + ? DirtyState.PARTIAL + : DirtyState.NONE; + if (level > this.dirtyMax) this.dirtyMax = level; + this.decodeAll = false; + this.refreshedGen = this.generation; + } + + /** Returns whether the default colours or the palette changed since the last read. */ + private readCursorAndColors(): boolean { + const L = this.L; + const at = this.at; + const u8 = this.u8(); + const dv = this.dv(); + const C = L.cursor; + this.cursorVisible = u8[at.cursor + C.visible] !== 0; + this.cursorBlinking = u8[at.cursor + C.blinking] !== 0; + const style = dv.getInt32(at.cursor + C.visualStyle, true); + this.cursorStyle = + style === L.cursorStyle.BAR ? 'bar' : style === L.cursorStyle.UNDERLINE ? 'underline' : 'block'; + const K = L.colors; + const defBg = this.rgbAt(at.colors + K.background); + const defFg = this.rgbAt(at.colors + K.foreground); + let changed = defBg !== this.defBg || defFg !== this.defFg; + this.defBg = defBg; + this.defFg = defFg; + this.cursorColor = u8[at.colors + K.cursorHasValue] ? this.rgbAt(at.colors + K.cursor) : null; + const pal = at.colors + K.palette; + const out = this.palette; + for (let i = 0; i < 256; i++) { + const p = pal + i * K.rgbSize; + const r = u8[p + K.r]; + const g = u8[p + K.g]; + const b = u8[p + K.b]; + if (out[i * 3] !== r || out[i * 3 + 1] !== g || out[i * 3 + 2] !== b) { + changed = true; + out[i * 3] = r; + out[i * 3 + 1] = g; + out[i * 3 + 2] = b; + } + } + return changed; + } + + /** + * Reads the iterator's current row (CELLS_RAW, VIEWPORT_Y, and binds the row-cells + * handle) and copies the raw cells into the JS-owned `raw` array at once. + */ + private readRowRaw(): number { + const at = this.at; + const L = this.L; + const rc = this.ex.ghostty_render_state_row_get_multi(this.iter, 3, at.rowKeys, at.rowVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt render_state_row_get_multi failed: ${rc}`); + const dv = this.dv(); + const ptr = dv.getUint32(at.view + L.cellsView.ptr, true); + const len = dv.getUint32(at.view + L.cellsView.len, true); + const words = (len * L.cellSize) / 4; + if (this.raw.length < words) this.raw = new Uint32Array(words); + if (ptr && words) this.raw.set(new Uint32Array(this.ex.memory.buffer, ptr, words)); + return len; + } + + /** + * Decodes the copied raw row into `out[base..base+cols)`. Styles come from a + * per-row cache (`row_cells_get` STYLE only for an id not seen in the row), and + * grapheme codepoints are read only for cells tagged as graphemes. + */ + private decodeRow(out: GhosttyCell[], base: number, len: number): Map | null { + const L = this.L; + const B = L.cell; + // Hoisted: this loop runs once per cell of every decoded row. + const tagLsb = B.tag.lsb; + const tagMask = B.tag.mask; + const contentLsb = B.content.lsb; + const contentMask = B.content.mask; + const styleLsb = B.style.lsb; + const styleMask = B.style.mask; + const wideLsb = B.wide.lsb; + const wideMask = B.wide.mask; + const linkLsb = B.hyperlink.lsb; + const linkMask = B.hyperlink.mask; + const cpLsb = B.codepoint.lsb; + const cpMask = B.codepoint.mask; + const TAG_CP = L.tag.CODEPOINT; + const TAG_GRAPHEME = L.tag.CODEPOINT_GRAPHEME; + const TAG_BG_PALETTE = L.tag.BG_COLOR_PALETTE; + const TAG_BG_RGB = L.tag.BG_COLOR_RGB; + const WIDE = L.wide.WIDE; + const NARROW = L.wide.NARROW; + const defFg = this.defFg; + const defBg = this.defBg; + const raw = this.raw; + const pal = this.palette; + const words = L.cellSize / 4; + const styles = this.rowStyles; + styles.clear(); + let graphemes: Map | null = null; + const n = Math.min(len, this._cols); + for (let x = 0; x < n; x++) { + const lo = raw[x * words]; + const hi = raw[x * words + 1]; + const tag = readBits(lo, hi, tagLsb, tagMask); + const content = readBits(lo, hi, contentLsb, contentMask); + const styleId = readBits(lo, hi, styleLsb, styleMask); + const wide = readBits(lo, hi, wideLsb, wideMask); + const cell = out[base + x]; + let fg = defFg; + let bg = defBg; + let flags = 0; + let codepoint = 0; + let bgFromContent = false; + if (tag === TAG_CP || tag === TAG_GRAPHEME) { + codepoint = (content >>> cpLsb) & cpMask; + } else if (tag === TAG_BG_PALETTE) { + const i = readBits(content, 0, B.paletteIndex.lsb, B.paletteIndex.mask) * 3; + bg = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; + bgFromContent = true; + } else if (tag === TAG_BG_RGB) { + bg = + (readBits(content, 0, B.r.lsb, B.r.mask) << 16) | + (readBits(content, 0, B.g.lsb, B.g.mask) << 8) | + readBits(content, 0, B.b.lsb, B.b.mask); + bgFromContent = true; + } + if (styleId !== 0) { + let style = styles.get(styleId); + if (!style) { + style = this.readStyle(x); + styles.set(styleId, style); + } + if (style.fg >= 0) fg = style.fg; + if (style.bg >= 0 && !bgFromContent) bg = style.bg; + flags = style.flags; + } + cell.codepoint = codepoint; + cell.fg_r = (fg >>> 16) & 0xff; + cell.fg_g = (fg >>> 8) & 0xff; + cell.fg_b = fg & 0xff; + cell.bg_r = (bg >>> 16) & 0xff; + cell.bg_g = (bg >>> 8) & 0xff; + cell.bg_b = bg & 0xff; + cell.flags = flags; + cell.width = wide === WIDE ? 2 : wide === NARROW ? 1 : 0; + cell.hyperlink_id = readBits(lo, hi, linkLsb, linkMask); + cell.grapheme_len = 0; + if (tag === TAG_GRAPHEME) { + const cps = this.readGrapheme(x); + if (cps.length > 1) { + cell.grapheme_len = cps.length - 1; + (graphemes ??= new Map()).set(x, String.fromCodePoint(...cps)); + } + } + } + for (let x = n; x < this._cols; x++) { + const cell = out[base + x]; + cell.codepoint = 0; + setRgb(cell, defFg, defBg); + cell.flags = 0; + cell.width = 1; + cell.hyperlink_id = 0; + cell.grapheme_len = 0; + } + return graphemes; + } + + private readStyle(x: number): DecodedStyle { + const L = this.L; + const S = L.style; + const C = L.styleColor; + const p = this.at.style; + const ex = this.ex; + ex.ghostty_render_state_row_cells_select(this.rowCells, x); + new DataView(ex.memory.buffer).setUint32(p + S.sizeField, S.size, true); + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.STYLE, p) !== 0) { + return { fg: -1, bg: -1, flags: 0 }; + } + const dv = new DataView(ex.memory.buffer); + const pal = this.palette; + let fg = -1; + let bg = -1; + for (let side = 0; side < 2; side++) { + const at = p + (side === 0 ? S.fg : S.bg); + const tag = dv.getInt32(at + C.tag, true); + const v = at + C.value; + let rgb = -1; + if (tag === C.PALETTE) { + const i = dv.getUint8(v + C.palette) * 3; + rgb = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; + } else if (tag === C.RGB) { + const c = v + C.rgb; + const K = L.colors; + rgb = (dv.getUint8(c + K.r) << 16) | (dv.getUint8(c + K.g) << 8) | dv.getUint8(c + K.b); + } + if (side === 0) fg = rgb; + else bg = rgb; + } + const flags = + (dv.getUint8(p + S.bold) ? CellFlags.BOLD : 0) | + (dv.getUint8(p + S.italic) ? CellFlags.ITALIC : 0) | + (dv.getInt32(p + S.underline, true) ? CellFlags.UNDERLINE : 0) | + (dv.getUint8(p + S.strikethrough) ? CellFlags.STRIKETHROUGH : 0) | + (dv.getUint8(p + S.inverse) ? CellFlags.INVERSE : 0) | + (dv.getUint8(p + S.invisible) ? CellFlags.INVISIBLE : 0) | + (dv.getUint8(p + S.blink) ? CellFlags.BLINK : 0) | + (dv.getUint8(p + S.faint) ? CellFlags.FAINT : 0); + return { fg, bg, flags }; + } + + private readGrapheme(x: number): number[] { + const L = this.L; + const at = this.at; + const ex = this.ex; + ex.ghostty_render_state_row_cells_select(this.rowCells, x); + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_LEN, at.u32)) { + return []; + } + const n = this.dv().getUint32(at.u32, true); + if (n === 0) return []; + const big = n > GRAPHEME_SCRATCH; + const buf = big ? ex.ghostty_wasm_alloc(n * 4) : at.gBuf; + try { + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_BUF, buf)) { + return []; + } + const dv = this.dv(); + const cps: number[] = []; + for (let i = 0; i < n; i++) cps.push(dv.getUint32(buf + i * 4, true)); + return cps; + } finally { + if (big) ex.ghostty_wasm_free(buf, n * 4); + } + } + + // ========================================================================== + // Private: rows by absolute index (0 = oldest scrollback row) + // ========================================================================== + + /** A row by absolute index; call `refresh()` first. */ + private rowAt(abs: number): RowRef { + const top = this.sbRows - this.offset; + if (abs >= top && abs < top + this._rows) { + const y = abs - top; + return { cells: this.viewCells, base: y * this._cols, graphemes: this.viewGraphemes[y] }; + } + return this.readPage(abs); + } + + /** + * Reads the page of `rows` rows holding `abs` through a second render state: + * moves Ghostty's viewport there, reads every row with CELLS_RAW, and moves it + * back. The primary state has already been refreshed at this generation (rowAt's + * callers refresh first), so it has captured every pending dirty bit before the + * second state's update consumes the terminal's dirty state. The second state + * cannot trust dirty bits the primary has cleared, so it is recreated whenever the + * content has changed, and every miss rebuilds every row it reads. A viewport move + * alone changes no row, so the state and the cached pages are kept across one. + */ + private readPage(abs: number): RowRef { + const rows = this._rows; + const sb = this.sbRows; + // scroll_viewport ROW n with n >= S snaps to the active screen. + const start = Math.min(Math.floor(abs / rows) * rows, sb); + if (this.pageGen !== this.contentGen) { + if (this.pageRs) this.ex.ghostty_render_state_free(this.pageRs); + this.pageRs = this.vt.newHandle('render_state_new', (slot) => + this.ex.ghostty_render_state_new(0, slot) + ); + this.pages.clear(); + this.pageGen = this.contentGen; + } + let page = this.pages.get(start); + if (page) { + this.pages.delete(start); + this.pages.set(start, page); + return page[abs - start]; + } + + const ex = this.ex; + const L = this.L; + const at = this.at; + this.scrollViewport(L.scroll.ROW, start); + try { + const rc = ex.ghostty_render_state_update(this.pageRs, this.term); + if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); + if (ex.ghostty_render_state_get(this.pageRs, L.rs.ROW_ITERATOR, at.iterH) !== 0) { + throw new Error('libghostty-vt ROW_ITERATOR failed'); + } + page = []; + while (ex.ghostty_render_state_row_iterator_next(this.iter)) { + const len = this.readRowRaw(); + const y = this.dv().getInt32(at.viewY, true); + if (y < 0 || y >= rows) continue; + const cells: GhosttyCell[] = []; + for (let x = 0; x < this._cols; x++) cells.push(blankCell()); + page[y] = { cells, base: 0, graphemes: this.decodeRow(cells, 0, len) }; + } + } finally { + if (this.offset === 0) this.scrollViewport(L.scroll.BOTTOM, 0); + else this.scrollViewport(L.scroll.ROW, sb - this.offset); + } + for (let y = 0; y < rows; y++) { + if (!page[y]) { + const cells: GhosttyCell[] = []; + for (let x = 0; x < this._cols; x++) cells.push(blankCell()); + page[y] = { cells, base: 0, graphemes: null }; + } + } + this.pages.set(start, page); + if (this.pages.size > PAGE_CACHE_SIZE) { + const oldest = this.pages.keys().next().value; + if (oldest !== undefined) this.pages.delete(oldest); + } + return page[abs - start]; + } + + private copyRow(ref: RowRef): GhosttyCell[] { + const out: GhosttyCell[] = new Array(this._cols); + for (let x = 0; x < this._cols; x++) out[x] = { ...ref.cells[ref.base + x] }; + return out; + } + + private graphemeOf(ref: RowRef, col: number): string { + const g = ref.graphemes?.get(col); + if (g !== undefined) return g; + const cp = ref.cells[ref.base + col].codepoint; + return cp ? String.fromCodePoint(cp) : ' '; + } + + private codepointsOf(s: string): number[] { + const cps: number[] = []; + for (const ch of s) cps.push(ch.codePointAt(0) ?? 0); + return cps; + } + + // ========================================================================== + // Private: single-cell lookups (grid_ref) + // ========================================================================== + + /** Resolves a point to a grid reference at `at.gridRef`. */ + private gridRef(tag: number, x: number, y: number): boolean { + const L = this.L; + const at = this.at; + const P = L.point; + this.u8().fill(0, at.point, at.point + P.size); + const dv = this.dv(); + dv.setInt32(at.point + P.tag, tag, true); + dv.setUint16(at.point + P.value + P.x, x, true); + dv.setUint32(at.point + P.value + P.y, y, true); + this.u8().fill(0, at.gridRef, at.gridRef + L.gridRef.size); + dv.setUint32(at.gridRef + L.gridRef.sizeField, L.gridRef.size, true); + return this.ex.ghostty_terminal_grid_ref(this.term, at.point, at.gridRef) === 0; + } + + private hyperlinkAt(tag: number, x: number, y: number): string | null { + if (x < 0 || x >= this._cols || y < 0) return null; + const ex = this.ex; + const at = this.at; + if (!this.gridRef(tag, x, y)) return null; + // Most URIs fit; a longer one (Ghostty keeps OSC 8 URIs up to about 2 KiB) is + // read again with the size Ghostty reports. + let cap = 256; + for (let attempt = 0; attempt < 2; attempt++) { + const size = cap; + const buf = ex.ghostty_wasm_alloc(size); + try { + const rc = ex.ghostty_grid_ref_hyperlink_uri(at.gridRef, buf, size, at.u32); + const n = this.dv().getUint32(at.u32, true); + if (rc === this.L.result.OUT_OF_SPACE) { + cap = n; + continue; + } + if (rc !== 0 || n === 0) return null; + return new TextDecoder().decode(this.u8().slice(buf, buf + n)); + } finally { + ex.ghostty_wasm_free(buf, size); + } + } + return null; + } + + private readTitle(): void { + this.titleChanged = false; + const at = this.at; + const S = this.L.string; + if (this.ex.ghostty_terminal_get(this.term, this.L.tdata.TITLE, at.str) !== 0) return; + const dv = this.dv(); + const ptr = dv.getUint32(at.str + S.ptr, true); + const len = dv.getUint32(at.str + S.len, true); + this.pendingTitle = len ? new TextDecoder().decode(this.u8().slice(ptr, ptr + len)) : ''; + } +} + +/** + * Key encoder on the official wasm. ghostty-web's `Key`, `KeyAction` and + * `KeyEncoderOption` values are translated by name to the official enums. + */ +class OfficialKeyEncoder implements KeyEncoder { + private readonly vt: OfficialGhostty; + private readonly ex: OfficialWasmExports; + private encoder: number; + private readonly value: number; + private readonly written: number; + + constructor(vt: OfficialGhostty) { + this.vt = vt; + this.ex = vt.ex; + this.encoder = vt.newHandle('key_encoder_new', (slot) => this.ex.ghostty_key_encoder_new(0, slot)); + this.value = this.ex.ghostty_wasm_alloc(8); + this.written = this.ex.ghostty_wasm_alloc(8); + } + + setOption(option: KeyEncoderOption, value: boolean | number): void { + const official = this.vt.encoderOptions.get(option); + if (official === undefined) return; + // Every option ghostty-web sets is a bool or the u8 kitty flags. + new DataView(this.ex.memory.buffer).setUint8( + this.value, + typeof value === 'boolean' ? (value ? 1 : 0) : value + ); + this.ex.ghostty_key_encoder_setopt(this.encoder, official, this.value); + } + + setKittyFlags(flags: KittyKeyFlags): void { + this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags); + } + + encode(event: KeyEvent): Uint8Array { + const ex = this.ex; + const vt = this.vt; + const ev = vt.newHandle('key_event_new', (slot) => ex.ghostty_key_event_new(0, slot)); + let utf8Ptr = 0; + let utf8Len = 0; + let buf = 0; + let cap = 64; + try { + ex.ghostty_key_event_set_action(ev, vt.keyActions.get(event.action) ?? event.action); + ex.ghostty_key_event_set_key(ev, vt.keys.get(event.key) ?? vt.unidentifiedKey); + ex.ghostty_key_event_set_mods(ev, event.mods); + if (event.utf8) { + const bytes = new TextEncoder().encode(event.utf8); + utf8Len = bytes.length; + utf8Ptr = ex.ghostty_wasm_alloc(utf8Len); + new Uint8Array(ex.memory.buffer).set(bytes, utf8Ptr); + // The event does not copy the text; it stays allocated until after encode. + ex.ghostty_key_event_set_utf8(ev, utf8Ptr, utf8Len); + } + for (let attempt = 0; attempt < 2; attempt++) { + buf = ex.ghostty_wasm_alloc(cap); + const rc = ex.ghostty_key_encoder_encode(this.encoder, ev, buf, cap, this.written); + const n = new DataView(ex.memory.buffer).getUint32(this.written, true); + if (rc === vt.L.result.OUT_OF_SPACE && attempt === 0) { + ex.ghostty_wasm_free(buf, cap); + buf = 0; + cap = n; + continue; + } + if (rc !== 0) throw new Error(`Failed to encode key: ${rc}`); + return new Uint8Array(ex.memory.buffer, buf, n).slice(); + } + throw new Error('Failed to encode key: out of space'); + } finally { + if (buf) ex.ghostty_wasm_free(buf, cap); + if (utf8Ptr) ex.ghostty_wasm_free(utf8Ptr, utf8Len); + ex.ghostty_key_event_free(ev); + } + } + + dispose(): void { + if (this.encoder) { + this.ex.ghostty_key_encoder_free(this.encoder); + this.ex.ghostty_wasm_free(this.value, 8); + this.ex.ghostty_wasm_free(this.written, 8); + this.encoder = 0; + } + } +} diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index b46e05b..ae3f632 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -73,12 +73,14 @@ export type { // Ghostty WASM components (for advanced usage) export { Ghostty, - GhosttyTerminal, - KeyEncoder, + CoderTerminal, + OfficialGhostty, + OfficialTerminal, CellFlags, DirtyState, KeyEncoderOption, } from './ghostty'; +export type { EngineName, GhosttyTerminal, KeyEncoder, TerminalEffects } from './ghostty'; export { Key, KeyAction, Mods } from './types'; export type { KeyEvent, GhosttyCell, RGB, Cursor, TerminalHandle } from './types'; diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index 4d6eefa..e1a1e86 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -537,6 +537,17 @@ export interface GhosttyTerminalConfig { bgColor?: number; cursorColor?: number; palette?: number[]; + /** + * webtty (ADR 042): which colours above were given, so the official engine can + * set `#000000` (0) as black instead of reading it as "use default". Without it, + * a non-zero value counts as given. The Coder engine ignores it. + */ + colorsSet?: { + foreground?: boolean; + background?: boolean; + cursor?: boolean; + palette?: boolean[]; + }; } /** diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts new file mode 100644 index 0000000..1f67616 --- /dev/null +++ b/src/client/vendor/official-terminal.test.ts @@ -0,0 +1,571 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + DirtyState, + Ghostty, + type GhosttyTerminal, + OfficialGhostty, + type OfficialTerminal, +} from './ghostty-web/ghostty'; +import { + CellFlags, + Key, + KeyAction, + KeyEncoderOption, + KittyKeyFlags, + Mods, +} from './ghostty-web/types'; + +// The official libghostty-vt binding (ADR 042) on the vendored wasm, loaded from disk. + +const OFFICIAL_WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'ghostty-vt/ghostty-vt-small.wasm', +); + +const FG = 0xd4d4d4; +const BG = 0x1e1e1e; +const RED = 0xcd3131; +const BLUE = 0x2472c8; +const THEME = { + scrollbackLimit: 10000, + fgColor: FG, + bgColor: BG, + palette: [0x000000, RED, 0x0dbc79, 0xe5e510, BLUE], +}; + +let vt: Ghostty; + +beforeAll(async () => { + vt = await Ghostty.load(OFFICIAL_WASM, 'official'); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +/** The text of a row as an app would see it: graphemes joined, spacer cells skipped. */ +function rowText(t: GhosttyTerminal, y: number): string { + const line = t.getLine(y); + if (!line) return ''; + return line + .map((c, x) => { + if (c.width === 0) return ''; + if (c.grapheme_len > 0) return t.getGraphemeString(y, x); + return c.codepoint ? String.fromCodePoint(c.codepoint) : ' '; + }) + .join('') + .trimEnd(); +} + +function scrollbackText(t: GhosttyTerminal, offset: number): string { + const line = t.getScrollbackLine(offset); + if (!line) return ''; + return line + .map((c, x) => { + if (c.width === 0) return ''; + if (c.grapheme_len > 0) return t.getScrollbackGraphemeString(offset, x); + return c.codepoint ? String.fromCodePoint(c.codepoint) : ' '; + }) + .join('') + .trimEnd(); +} + +const rgb = (c: { fg_r: number; fg_g: number; fg_b: number }) => + (c.fg_r << 16) | (c.fg_g << 8) | c.fg_b; +const bgRgb = (c: { bg_r: number; bg_g: number; bg_b: number }) => + (c.bg_r << 16) | (c.bg_g << 8) | c.bg_b; + +describe('official engine: cells', () => { + test('reads 16-colour, 256-colour and truecolor foregrounds and backgrounds', () => { + const t = term(); + t.write( + '\x1b[31mA\x1b[0m\x1b[38;5;208mB\x1b[0m\x1b[38;2;10;20;30;48;2;200;100;50mC\x1b[0m' + + '\x1b[44mD\x1b[0mE\x1b[48;5;21m \x1b[0m', + ); + const line = t.getLine(0); + expect(line).not.toBeNull(); + const [a, b, c, d, e, f] = line ?? []; + expect(rgb(a)).toBe(RED); + expect(bgRgb(a)).toBe(BG); + expect(rgb(b)).toBe(0xff8700); + expect(rgb(c)).toBe(0x0a141e); + expect(bgRgb(c)).toBe(0xc86432); + expect(bgRgb(d)).toBe(BLUE); + expect(rgb(d)).toBe(FG); + expect(rgb(e)).toBe(FG); + expect(bgRgb(e)).toBe(BG); + // Spaces written with a 256-colour background. + expect(f.codepoint).toBe(0x20); + expect(bgRgb(f)).toBe(0x0000ff); + t.free(); + }); + + test('reads bold, italic, underline, strikethrough, inverse, faint, blink and invisible', () => { + const t = term(); + t.write( + '\x1b[1mA\x1b[0m\x1b[3mB\x1b[0m\x1b[4mC\x1b[0m\x1b[9mD\x1b[0m\x1b[7mE\x1b[0m' + + '\x1b[2mF\x1b[0m\x1b[5mG\x1b[0m\x1b[8mH\x1b[0m\x1b[1;3;4;7mI\x1b[0mJ', + ); + const flags = (t.getLine(0) ?? []).slice(0, 10).map((c) => c.flags); + expect(flags).toEqual([ + CellFlags.BOLD, + CellFlags.ITALIC, + CellFlags.UNDERLINE, + CellFlags.STRIKETHROUGH, + CellFlags.INVERSE, + CellFlags.FAINT, + CellFlags.BLINK, + CellFlags.INVISIBLE, + CellFlags.BOLD | CellFlags.ITALIC | CellFlags.UNDERLINE | CellFlags.INVERSE, + 0, + ]); + t.free(); + }); + + test('reads wide CJK, emoji, VS16 and combining marks as graphemes', () => { + const t = term(); + t.write('你好|😀|❤️|é|👨‍👩‍👧'); + const line = t.getLine(0) ?? []; + expect(line.slice(0, 5).map((c) => c.width)).toEqual([2, 0, 2, 0, 1]); + expect(String.fromCodePoint(line[0].codepoint)).toBe('你'); + expect(line[5].width).toBe(2); + expect(String.fromCodePoint(line[5].codepoint)).toBe('😀'); + // VS16 makes the heart wide (mode 2027 on by default). + expect(line[8].width).toBe(2); + expect(line[8].grapheme_len).toBe(1); + expect(t.getGraphemeString(0, 8)).toBe('❤️'); + expect(line[11].grapheme_len).toBe(1); + expect(t.getGraphemeString(0, 11)).toBe('é'); + expect(t.getGrapheme(0, 11)).toEqual([0x65, 0x301]); + expect(t.getGraphemeString(0, 13)).toBe('👨‍👩‍👧'); + expect(line[13].width).toBe(2); + expect(rowText(t, 0)).toBe('你好|😀|❤️|é|👨‍👩‍👧'); + t.free(); + }); + + test('marks OSC 8 hyperlinks and resolves their URIs on screen and in scrollback', () => { + const t = term(40, 5); + t.write('\x1b]8;;https://example.com/a\x1b\\link\x1b]8;;\x1b\\ plain'); + expect((t.getLine(0) ?? []).slice(0, 6).map((c) => c.hyperlink_id)).toEqual([1, 1, 1, 1, 0, 0]); + expect(t.getHyperlinkUri(0, 0)).toBe('https://example.com/a'); + expect(t.getHyperlinkUri(0, 5)).toBeNull(); + t.write('\r\n'.repeat(10)); + expect(t.getScrollbackLength()).toBeGreaterThan(0); + expect(t.getScrollbackHyperlinkUri(0, 3)).toBe('https://example.com/a'); + expect(t.getScrollbackHyperlinkUri(0, 6)).toBeNull(); + // Longer than the first buffer tried. + const long = `https://example.com/${'p'.repeat(1500)}`; + t.write(`\x1b]8;;${long}\x1b\\long\x1b]8;;\x1b\\`); + expect(t.getHyperlinkUri(t.getCursor().y, 0)).toBe(long); + t.free(); + }); + + test('uses an explicitly black theme background instead of the default', () => { + const t = term(40, 5, { + fgColor: FG, + bgColor: 0, + colorsSet: { foreground: true, background: true }, + }); + expect(t.getColors().background).toEqual({ r: 0, g: 0, b: 0 }); + t.write('\x1b]11;?\x07'); + expect(t.readResponse()).toBe('\x1b]11;rgb:0000/0000/0000\x07'); + t.free(); + }); + + // Ghostty's render state copies the default colours only when both are set, so the + // binding always sets both, falling back to the Coder engine's 0xcccccc on black. + const hex = (c: { r: number; g: number; b: number } | null) => + c ? (c.r << 16) | (c.g << 8) | c.b : null; + const defaults = (t: GhosttyTerminal) => { + t.write('\x1b[1;1HA'); + t.update(); + const cell = (t.getLine(0) ?? [])[0]; + const colors = t.getColors(); + t.readResponse(); + t.write('\x1b]10;?\x07'); + const osc10 = t.readResponse(); + t.write('\x1b]11;?\x07'); + const osc11 = t.readResponse(); + return { + fg: hex(colors.foreground), + bg: hex(colors.background), + cellFg: rgb(cell), + cellBg: bgRgb(cell), + osc10, + osc11, + }; + }; + + test('falls back to the default background when only the foreground is given', () => { + for (const config of [ + { fgColor: FG, bgColor: 0, colorsSet: { foreground: true } }, + { fgColor: FG, bgColor: 0 }, + ]) { + const t = term(10, 3, config); + expect(defaults(t)).toEqual({ + fg: FG, + bg: 0, + cellFg: FG, + cellBg: 0, + osc10: '\x1b]10;rgb:d4d4/d4d4/d4d4\x07', + osc11: '\x1b]11;rgb:0000/0000/0000\x07', + }); + t.free(); + } + }); + + test('falls back to the default foreground when only the background is given', () => { + const t = term(10, 3, { bgColor: 0x202020, colorsSet: { background: true } }); + expect(defaults(t)).toEqual({ + fg: 0xcccccc, + bg: 0x202020, + cellFg: 0xcccccc, + cellBg: 0x202020, + osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', + osc11: '\x1b]11;rgb:2020/2020/2020\x07', + }); + t.free(); + }); + + test('without theme colours still applies OSC 11 and DECSCNM', () => { + for (const config of [undefined, { scrollbackLimit: 1000 }]) { + const t = vt.createTerminal(10, 3, config) as OfficialTerminal; + expect(defaults(t)).toEqual({ + fg: 0xcccccc, + bg: 0, + cellFg: 0xcccccc, + cellBg: 0, + osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', + osc11: '\x1b]11;rgb:0000/0000/0000\x07', + }); + t.write('\x1b]11;rgb:20/40/60\x1b\\'); + t.update(); + expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0x204060); + expect(hex(t.getColors().background)).toBe(0x204060); + t.write('\x1b]111\x1b\\\x1b[?5h'); + t.update(); + expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0xcccccc); + expect(rgb((t.getLine(0) ?? [])[0])).toBe(0); + t.free(); + } + }); +}); + +describe('official engine: cursor and modes', () => { + test('reports the cursor in active-screen cells', () => { + const t = term(); + t.write('ab\r\ncde'); + expect(t.getCursor()).toMatchObject({ x: 3, y: 1, visible: true }); + t.write('\x1b[?25l'); + expect(t.getCursor().visible).toBe(false); + t.free(); + }); + + test('applies DECSCUSR styles', () => { + const t = term(); + t.write('\x1b[5 q'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[2 q'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.write('\x1b[4 q'); + expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: false }); + t.write('\x1b[0 q'); + expect(t.getCursor().style).toBe('block'); + t.free(); + }); + + test('switches to the alternate screen and back', () => { + const t = term(20, 4); + t.write('main'); + t.write('\x1b[?1049h'); + expect(t.isAlternateScreen()).toBe(true); + t.write('\x1b[Halt'); + expect(rowText(t, 0)).toBe('alt'); + expect(t.getScrollbackLength()).toBe(0); + t.write('\x1b[?1049l'); + expect(t.isAlternateScreen()).toBe(false); + expect(rowText(t, 0)).toBe('main'); + t.free(); + }); + + test('reports mouse tracking and the other DEC and ANSI modes', () => { + const t = term(); + expect(t.hasMouseTracking()).toBe(false); + t.write('\x1b[?1000h'); + expect(t.hasMouseTracking()).toBe(true); + expect(t.getMode(1000)).toBe(true); + t.write('\x1b[?1002h\x1b[?1006h'); + expect(t.getMode(1002)).toBe(true); + expect(t.getMode(1006)).toBe(true); + expect(t.getMode(1003)).toBe(false); + t.write('\x1b[?1003h'); + expect(t.getMode(1003)).toBe(true); + t.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l'); + expect(t.hasMouseTracking()).toBe(false); + t.write('\x1b[?2004h\x1b[?1004h\x1b[?1h\x1b[4h'); + expect(t.hasBracketedPaste()).toBe(true); + expect(t.hasFocusEvents()).toBe(true); + expect(t.getMode(1)).toBe(true); + expect(t.getMode(4, true)).toBe(true); + expect(t.getMode(4, false)).toBe(false); + t.free(); + }); +}); + +describe('official engine: scrollback, resize and the viewport', () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n'); + + test('keeps scrolled-off rows, oldest first', () => { + const t = term(20, 5, { scrollbackLimit: 0 }); + t.write(lines(50)); + expect(t.getScrollbackLength()).toBe(45); + expect(scrollbackText(t, 0)).toBe('line 1'); + expect(scrollbackText(t, 44)).toBe('line 45'); + expect(t.getScrollbackLine(45)).toBeNull(); + expect(rowText(t, 0)).toBe('line 46'); + expect(rowText(t, 4)).toBe('line 50'); + t.free(); + }); + + test('keeps active-screen coordinates while the view is scrolled up', () => { + const t = term(20, 5, { scrollbackLimit: 0 }); + t.write(lines(50)); + t.setViewportOffset(7); + expect(t.isRowDirty(0)).toBe(true); + expect(rowText(t, 0)).toBe('line 46'); + expect(rowText(t, 4)).toBe('line 50'); + expect(scrollbackText(t, 38)).toBe('line 39'); + expect(scrollbackText(t, 44)).toBe('line 45'); + const viewport = t.getViewport(); + expect(viewport.length).toBe(100); + expect(String.fromCodePoint(viewport[80 + 5].codepoint)).toBe('5'); + expect(t.getCursor()).toMatchObject({ x: 7, y: 4 }); + // Output snaps the view back to the bottom. + t.write('\r\nline 51'); + expect(rowText(t, 4)).toBe('line 51'); + expect(t.needsFullRedraw()).toBe(true); + t.free(); + }); + + test('reflows wrapped lines on resize', () => { + const t = term(20, 5); + t.write('abcdefghijklmnopqrstuvwxyz0123'); + expect(rowText(t, 0)).toBe('abcdefghijklmnopqrst'); + expect(rowText(t, 1)).toBe('uvwxyz0123'); + // Coder semantics: a row is wrapped when it continues the row above. + expect(t.isRowWrapped(0)).toBe(false); + expect(t.isRowWrapped(1)).toBe(true); + t.resize(30, 5); + expect(t.getDimensions()).toEqual({ cols: 30, rows: 5 }); + expect(rowText(t, 0)).toBe('abcdefghijklmnopqrstuvwxyz0123'); + expect(t.getLine(0)?.length).toBe(30); + t.resize(12, 6); + expect([rowText(t, 0), rowText(t, 1), rowText(t, 2)]).toEqual([ + 'abcdefghijkl', + 'mnopqrstuvwx', + 'yz0123', + ]); + expect(t.cols).toBe(12); + expect(t.rows).toBe(6); + t.free(); + }); + + test('keeps row dirt until markClean and only marks changed rows', () => { + const t = term(20, 5); + t.write('one\r\ntwo\r\nthree'); + t.update(); + t.markClean(); + expect(t.update()).toBe(DirtyState.NONE); + t.write('\x1b[2;1HTWO'); + expect(t.update()).toBe(DirtyState.PARTIAL); + expect(t.update()).toBe(DirtyState.PARTIAL); + expect(t.isRowDirty(1)).toBe(true); + expect(t.isRowDirty(0)).toBe(false); + expect(t.isRowDirty(3)).toBe(false); + expect(rowText(t, 1)).toBe('TWO'); + t.clearDirty(); + expect(t.isRowDirty(1)).toBe(false); + t.write('\x1b[?1049h'); + expect(t.needsFullRedraw()).toBe(true); + t.free(); + }); + + // Ghostty marks no row dirty when only the default colours change; the binding bakes + // them into every cell, so it must redecode every row itself. + const everyCell = (t: GhosttyTerminal, rows: number, f: (c: never) => number) => + Array.from({ length: rows }, (_, y) => (t.getLine(y) ?? []).map((c) => f(c as never))); + + test('recolours every row when OSC 10/11 change only the default colours', () => { + const t = term(10, 3); + t.write('a\r\nb\r\nc'); + t.update(); + t.markClean(); + t.write('\x1b]11;rgb:44/55/66\x1b\\'); + expect(t.update()).toBe(DirtyState.FULL); + for (let y = 0; y < 3; y++) expect(t.isRowDirty(y)).toBe(true); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(0x445566)); + t.markClean(); + t.write('\x1b]10;rgb:11/22/33\x1b\\'); + expect(t.update()).toBe(DirtyState.FULL); + expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(0x112233)); + t.write('\x1b[1;1HX'); + t.update(); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(0x445566)); + t.markClean(); + t.write('\x1b]111\x1b\\\x1b]110\x1b\\'); + t.update(); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(BG)); + expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(FG)); + t.free(); + }); + + test('recolours every row on DECSCNM (?5h / ?5l)', () => { + const t = term(10, 3); + t.write('a\r\nb\r\nc'); + t.update(); + t.markClean(); + t.write('\x1b[?5h'); + expect(t.update()).toBe(DirtyState.FULL); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(FG)); + t.markClean(); + t.write('\x1b[2;1HB'); + t.update(); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(FG)); + t.markClean(); + t.write('\x1b[?5l'); + expect(t.update()).toBe(DirtyState.FULL); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(BG)); + expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(FG)); + t.free(); + }); +}); + +describe('official engine: effects and replies', () => { + test('reports the title and the bell once', () => { + const t = term(); + t.write('\x1b]2;first\x07'); + expect(t.readEffects()).toEqual({ bell: false, title: 'first' }); + t.write('\x07\x1b]0;second\x1b\\'); + expect(t.readEffects()).toEqual({ bell: true, title: 'second' }); + expect(t.readEffects()).toEqual({ bell: false, title: null }); + t.free(); + }); + + test('answers DA1, DA2, DSR, XTWINOPS and DECRQM through WRITE_PTY', () => { + const t = term(80, 24); + t.setCellPixelSize(9, 18); + const ask = (q: string) => { + t.write(q); + return t.readResponse(); + }; + expect(t.hasResponse()).toBe(false); + expect(ask('\x1b[c')).toBe('\x1b[?62;22c'); + expect(ask('\x1b[>c')).toBe('\x1b[>1;10;0c'); + expect(ask('\x1b[5n')).toBe('\x1b[0n'); + expect(ask('\x1b[3;7H\x1b[6n')).toBe('\x1b[3;7R'); + expect(ask('\x1b[14t')).toBe('\x1b[4;432;720t'); + expect(ask('\x1b[16t')).toBe('\x1b[6;18;9t'); + expect(ask('\x1b[18t')).toBe('\x1b[8;24;80t'); + expect(ask('\x1b[?2027$p')).toBe('\x1b[?2027;1$y'); + expect(ask('\x1b[c\x1b[5n')).toBe('\x1b[?62;22c\x1b[0n'); + expect(t.readResponse()).toBeNull(); + t.free(); + }); + + test('sends one mode 2048 report per real size change', () => { + const t = term(80, 24); + t.setCellPixelSize(9, 18); + t.write('\x1b[?2048h'); + expect(t.readResponse()).toBe('\x1b[48;24;80;432;720t'); + t.resize(100, 30, 9, 18); + expect(t.readResponse()).toBe('\x1b[48;30;100;540;900t'); + t.resize(100, 30, 9, 18); + t.setCellPixelSize(9, 18); + expect(t.readResponse()).toBeNull(); + t.setCellPixelSize(10, 20); + expect(t.readResponse()).toBe('\x1b[48;30;100;600;1000t'); + t.free(); + }); + + test('makes one ghostty_terminal_resize call per grid or pixel change', async () => { + const wasm = await WebAssembly.instantiate(await Bun.file(OFFICIAL_WASM).arrayBuffer(), {}); + const real = wasm.instance.exports as Record; + let resizes = 0; + const resize = real.ghostty_terminal_resize as (...args: number[]) => number; + const spy = { + ...real, + ghostty_terminal_resize: (...args: number[]) => { + resizes++; + return resize(...args); + }, + }; + const engine = new OfficialGhostty( + spy as unknown as ConstructorParameters[0], + ); + const t = engine.createTerminal(80, 24); + // open(): the initial cell size. + t.setCellPixelSize(9, 18); + expect(resizes).toBe(1); + // Repeated fits with nothing changed. + t.setCellPixelSize(9, 18); + t.resize(80, 24, 9, 18); + expect(resizes).toBe(1); + // A zoom that changes grid and pixels: one call. + t.resize(90, 26, 10, 20); + t.setCellPixelSize(10, 20); + expect(resizes).toBe(2); + // A zoom or DPR change that keeps the grid: one call. + t.resize(90, 26, 11, 22); + t.setCellPixelSize(11, 22); + expect(resizes).toBe(3); + t.free(); + }); +}); + +describe('official engine: memory growth', () => { + test('reads correct cells and replies after the wasm memory has grown', () => { + const t = term(120, 40, { scrollbackLimit: 0 }); + const memory = (vt as OfficialGhostty).ex.memory; + const before = memory.buffer.byteLength; + let i = 0; + while (memory.buffer.byteLength === before && i < 200000) { + let chunk = ''; + for (let j = 0; j < 500; j++, i++) { + chunk += `\x1b[38;2;${i % 256};${(i >> 8) % 256};${(i * 7) % 256}mrow ${i}\x1b[0m\r\n`; + } + t.write(chunk); + } + expect(memory.buffer.byteLength).toBeGreaterThan(before); + t.write('\x1b[6n'); + expect(t.readResponse()).toBe('\x1b[40;1R'); + expect(rowText(t, 38)).toBe(`row ${i - 1}`); + const cell = t.getLine(38)?.[0]; + expect(cell && rgb(cell)).toBe( + (((i - 1) % 256) << 16) | ((((i - 1) >> 8) % 256) << 8) | (((i - 1) * 7) % 256), + ); + expect(scrollbackText(t, 0)).toBe('row 0'); + t.free(); + }); +}); + +describe('official engine: key encoder', () => { + test('encodes keys by their official names', () => { + const enc = vt.createKeyEncoder(); + const encode = (key: Key, mods = Mods.NONE, utf8?: string) => + new TextDecoder().decode(enc.encode({ action: KeyAction.PRESS, key, mods, utf8 })); + expect(encode(Key.UP)).toBe('\x1b[A'); + expect(encode(Key.ENTER)).toBe('\r'); + expect(encode(Key.C, Mods.CTRL, 'c')).toBe('\x03'); + expect(encode(Key.ONE, Mods.NONE, '1')).toBe('1'); + expect(encode(Key.GRAVE, Mods.NONE, '`')).toBe('`'); + expect(encode(Key.F1)).toBe('\x1bOP'); + expect(encode(Key.PASTE)).toBe(''); + enc.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, true); + expect(encode(Key.UP)).toBe('\x1bOA'); + enc.setKittyFlags(KittyKeyFlags.DISAMBIGUATE); + expect(encode(Key.ESCAPE)).toBe('\x1b[27u'); + enc.dispose(); + }); +}); From 4da654175848f1e69549e0ae37aeb51e34a25b9b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 02:33:52 -0400 Subject: [PATCH 014/295] feat(client): select the official libghostty-vt engine with ?engine=official engine.ts picks the engine: build-time DEFAULT_ENGINE (coder) or ?engine=coder|official; only the selected engine's wasm is fetched. terminal.ts wires the official engine's effects: replies drained after write, resize and render, cell pixels synced from fit(), theme colours masked so #000000 stays black, kitty CSI ? u reply dropped. client-browser.js grows ~37 KB minified (108 -> 145 KB); 531 tests pass. --- docs/adrs/042.client.official-vt-engine.md | 15 +- docs/specs/client.md | 18 +-- src/client/engine.test.ts | 28 ++++ src/client/engine.ts | 23 +++ src/client/graphics.test.ts | 18 ++- src/client/graphics.ts | 3 + src/client/index.ts | 12 +- src/client/vendor/ghostty-web/PROVENANCE.md | 3 +- src/client/vendor/ghostty-web/ghostty.ts | 21 ++- src/client/vendor/ghostty-web/renderer.ts | 9 ++ src/client/vendor/ghostty-web/terminal.ts | 146 +++++++++++++++----- src/client/vendor/official-terminal.test.ts | 13 ++ src/client/vendor/terminal.test.ts | 14 ++ 13 files changed, 262 insertions(+), 61 deletions(-) create mode 100644 src/client/engine.test.ts create mode 100644 src/client/engine.ts diff --git a/docs/adrs/042.client.official-vt-engine.md b/docs/adrs/042.client.official-vt-engine.md index f895a4e..b6a2923 100644 --- a/docs/adrs/042.client.official-vt-engine.md +++ b/docs/adrs/042.client.official-vt-engine.md @@ -32,6 +32,7 @@ The Coder wasm, and the official wasm (`05dcf887`, ReleaseSmall) with the callba | XTWINOPS `19 t`, mode 2048 | none | `ESC[9;rows;colst`, `ESC[48;rows;cols;h;wt` | | OSC 4/10/11 `?` | none | colour report, once the theme colours are set | | XTVERSION `CSI > q` | none | `DCS >\|libghostty ST` (no callback installed) | +| Kitty keyboard `CSI ? u` | none | `ESC[?flags u` from the wasm, dropped by the binding (see `WRITE_PTY` below) | | DSR `6n` | `ESC[r;cR` | same | | ENQ | none | none (no callback installed) | @@ -67,7 +68,7 @@ src/client/vendor/ ghostty-vt.test.ts ← offline sha256, size and signature check official-terminal.test.ts ← the official binding on its own (cells, modes, replies, keys) engine-parity.test.ts, fixtures/ ← both engines on the PTY captures and the #180 payload -src/client/engine.ts ← DEFAULT_ENGINE and the `?engine` override +src/client/engine.ts ← DEFAULT_ENGINE and the `?engine` override (engine.test.ts) scripts/update-ghostty-vt.ts ← fetch and verify a new pin (network; run by hand) ``` @@ -81,7 +82,7 @@ ADR 041's "one binding module" holds: every wasm export call and memory access f - `readEffects(): { bell: boolean; title: string | null }`: bell and title changes since the last write. Coder returns none, and `terminal.ts` keeps its stream scan for BEL and OSC 0/2 only on Coder. - `answersSizeQueries: boolean`: true on official. The graphics overlay (`graphics.ts`) then stops sending `kitty.ts`'s `sizeReply`, so apps get one reply per query. -Every existing read keeps the Coder engine's coordinates on both engines, so the renderer, selection and buffer code need no change: `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` use active-screen rows, and `getScrollbackLine(i)` counts from the oldest scrollback row. `terminal.ts` drains `readResponse` after `write`, after `wasmTerm.resize` and `setCellPixelSize`, and after each frame's render, not only after `write`. +Every existing read keeps the Coder engine's coordinates on both engines, so the renderer, selection and buffer code need no change: `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` use active-screen rows, and `getScrollbackLine(i)` counts from the oldest scrollback row. `terminal.ts` drains `readResponse` after `write`, after `wasmTerm.resize` and `setCellPixelSize`, and after each frame's render, not only after `write`. After a resize it drains once `onResize` has fired, so the PTY gets its new size before the mode 2048 report. **Binding (official).** @@ -91,17 +92,17 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the - Scrollback: `getScrollbackLength` reads `DATA_SCROLLBACK_ROWS` (S below). Writes never leave the view scrolled, but `terminal.ts` `writeInternal` calls `wasmTerm.write` first and `scrollToBottom()` after it (xterm behaviour), and `smooth-scroll.ts` `onOutput` then drops its sub-line offset. So `OfficialTerminal.write` itself moves Ghostty's viewport to `BOTTOM` before `vt_write` whenever it is scrolled, sets its offset to 0 and marks every row dirty; pruning and screen switches then happen with the viewport at the bottom, and any render before `scrollToBottom` (the `awaitingEcho` render) passes a stale `viewportY` that `setViewportOffset` moves back. A scrolled frame (k = `floor(viewportY)` > 0) follows user scrolling, and S changes under it only through resize and reflow. `setViewportOffset` moves Ghostty's viewport with `ghostty_terminal_scroll_viewport` `ROW` to row S − k (`ROW` counts from the top of scrollback, `viewportY` from the bottom) whenever k or S differs from the last move. Reflow moves Ghostty's viewport with the rows it shows even when S is unchanged, so a resize while scrolled moves it back to `ROW` S − k itself, before any read. The frame is read through the same render-state path, and copy row y maps to `getScrollbackLine(S − k + y)` for y < k and to `getLine(y − k)` for y ≥ k. Those reads are served from the copy. `isRowDirty` returns true for every row while scrolled, since the renderer repaints all rows then anyway. Back at the bottom (`viewportY` 0, including the snap after a write), the binding scrolls Ghostty's viewport with `BOTTOM` and marks every row dirty for the next frame. - Reads outside the viewport (`getLine(n)` for n ≥ rows − k, scrollback rows above the viewport, smooth scroll's row above the top, selection copy and the buffer API) are read a page at a time through the render state, not cell by cell. On a miss the binding moves Ghostty's viewport with `scroll_viewport` `ROW` to the page of `rows` rows that holds the row, reads the page with `CELLS_RAW` through a second render state, and moves the viewport back before returning: to `BOTTOM` when the offset is 0, to `ROW` S − k otherwise. `scroll_viewport` `ROW` n with n ≥ S snaps to the active screen (`PageList.zig` `scroll`), so a page starts at min(n, S) and rows are mapped from that start. Pages are cached until the content generation changes, so a viewport move keeps them: while scrolled by 0 < k < rows, the renderer's `getViewport` reads the k active rows below the view from the cache instead of decoding them again on every step. A render state's update consumes the terminal's dirty state (page and row dirty bits and the terminal-wide full-redraw flags; `render.zig` `beginUpdate` assumes it is the only consumer), so the second state would hide changes from the primary. Before a page read the binding therefore refreshes the primary when the generation has changed, so the primary has captured every pending dirty bit and flag (and the palette) before the second state runs; this ordering is what keeps the primary correct. The reverse also holds: the second state cannot trust dirty bits the primary has already cleared, and `beginUpdate` rebuilds a row only on a pin, screen or size change or a dirty bit (`render.zig`), so a page read at the same row after an in-place write, or after pruning recycled a page at the same address, would return stale cells. The page-read state is therefore freed and recreated whenever the content generation has changed, so every miss after a content change rebuilds every row it reads. A viewport move alone changes no row, pin or size of the rows already read, so the state is kept across it. Copying a selection over N rows costs about N / rows page reads plus one decode per row, and smooth scroll's row above the top is a cache hit on most frames. `ghostty_terminal_grid_ref` is kept for single-cell lookups (hyperlink hover, a grapheme outside the copy). Per-cell `grid_ref` costs one lookup plus cell, style and grapheme calls per cell (`grid_ref.h` notes the lookup's cost): a 3,000-row selection at 200 columns would be over 1.8 million export calls, and step 4 raises scrollback to 10,000 lines. - Callbacks through `fn-table.ts`, installed per terminal and released after `ghostty_terminal_free`. None re-enters `vt_write`; each copies what it needs and returns. - - `WRITE_PTY`: copies the bytes into the queue that `readResponse` drains, the path `terminal.ts` already uses for Coder replies. Replies also arise outside `write` (the mode 2048 report from `ghostty_terminal_resize`), so `terminal.ts` drains at the three points listed above. + - `WRITE_PTY`: copies the bytes into the queue that `readResponse` drains, the path `terminal.ts` already uses for Coder replies. Replies also arise outside `write` (the mode 2048 report from `ghostty_terminal_resize`), so `terminal.ts` drains at the three points listed above. The one exception is the kitty keyboard query reply `CSI ? flags u`, which the binding drops: `input-handler.ts` never passes the kitty flags to `KeyEncoder.setKittyFlags`, on either engine, so after `CSI > 31 u` keys still arrive in the legacy encoding, and a reply would make apps that detect the protocol from it (nvim, fish 4, helix) expect kitty keys. Dropping it keeps the Coder engine's behaviour (no reply). Ghostty sends each reply in its own `WRITE_PTY` call, and no other reply has that shape. - `BELL`, `TITLE_CHANGED`: set flags. The title is read with `DATA_TITLE` after `vt_write` returns, copied, and handed out by `readEffects`. - `SIZE`: fills `GhosttySizeReportSize` with rows, columns and the cell pixel size Ghostty last received. `ghostty_terminal_resize(cols, rows, cellWidthPx, cellHeightPx)` stores the pixel size, ends synchronized output and, with mode 2048 on, sends a report on every call, even with unchanged dimensions (`terminal.h`). So the binding makes exactly one call per real change, and `terminal.ts` supplies the pixels at three fixed points: - `open()`, after `createTerminal` and before the first write: `setCellPixelSize` with the initial metrics, so `SIZE` replies never use 0 px. Mode 2048 cannot be on yet, so this sends no report. - `Terminal.resize()`, which already returns early when columns and rows are unchanged: it computes the pixel size and calls `wasmTerm.resize(cols, rows, w, h)`, one `ghostty_terminal_resize` with the new grid and pixels. - - A new `Terminal.syncCellPixelSize()`, called at the end of `index.ts` `fit()`. Every metrics or DPR change reaches `fit()`: font zoom calls it directly after setting `fontSize`, and the `document.fonts.ready` remeasure and `watchDevicePixelRatio` go through `scheduleFit`. If the grid changed, `resize` has already sent the new pixels and this does nothing; if only the pixels changed, `setCellPixelSize` makes one call at the current grid. + - A new `Terminal.syncCellPixelSize()`, called in `index.ts` `fit()` right after its grid resize (before the padding layout, which can return early). Every metrics or DPR change reaches `fit()`: font zoom calls it directly after setting `fontSize`, and the `document.fonts.ready` remeasure and `watchDevicePixelRatio` go through `scheduleFit`. If the grid changed, `resize` has already sent the new pixels and this does nothing; if only the pixels changed, `setCellPixelSize` makes one call at the current grid. `setCellPixelSize` returns at once when the size equals the one Ghostty last received. Nothing is flushed from the render loop, so the order of the fit's and the render loop's `requestAnimationFrame` callbacks does not matter. A unit test drives `OfficialTerminal` over the real wasm exports with `ghostty_terminal_resize` counted, through the calls `terminal.ts` and `fit()` make, and asserts one `ghostty_terminal_resize` for a zoom that changes grid and pixels, one for a zoom or DPR change that keeps the grid, and none for repeated fits. The refresh, scrolled-view and page-read logic is tested on the real wasm in `engine-parity.test.ts`, against the Coder engine: scrolled reads (`getLine`, `getScrollbackLine`, rendered rows) at several k, a write while scrolled, a resize while scrolled, a write that dirties active rows followed by a page read and then a render, a scrolled page read, an in-place write, a render with the stale `viewportY`, then the same page read again, and the offset stepped through 0 < k < rows, k = rows and beyond and back, before and after an in-place write. - `DEVICE_ATTRIBUTES`: primary conformance 62 with feature 22, secondary type 1, firmware 10, ROM 0, tertiary unit id 0. This gives `ESC[?62;22c` and `ESC[>1;10;0c`. - `ENQUIRY`, `XTVERSION`, clipboard, notification and the other effects are not installed (ADR 041 future work). -- Setup: `MODE_DEFAULT` with mode 2027 on, so RIS keeps grapheme clustering. The theme's foreground, background, cursor and palette are set through `COLOR_FOREGROUND`, `COLOR_BACKGROUND`, `COLOR_CURSOR` and `COLOR_PALETTE`. The colour values in `GhosttyTerminalConfig` cannot be used as they are: `terminal.ts` `parseColorToHex` encodes an unset colour as 0 ("use default" in `types.ts`), and the default theme's background `#000000` (`src/config.ts`) also becomes 0. `buildWasmConfig` therefore adds a mask of the theme keys that parsed (foreground, background, cursor and the 16 palette entries), which Coder ignores. A key in the mask is set as given, so `#000000` is black. Foreground and background are always both set: libghostty-vt has no default for them, and its render state copies the default colours only when both are set (`render.zig`, the `bg_fg` block), otherwise keeping white on black and ignoring the given colour, OSC 10/11 and DECSCNM. An unset one falls back to the Coder engine's effective default (foreground `0xcccccc`, background `0x000000`). An unset cursor stays unset, and unset palette entries and entries 16–255 come from `COLOR_PALETTE_DEFAULT`. +- Setup: `MODE_DEFAULT` with mode 2027 on, so RIS keeps grapheme clustering. The theme's foreground, background, cursor and palette are set through `COLOR_FOREGROUND`, `COLOR_BACKGROUND`, `COLOR_CURSOR` and `COLOR_PALETTE`. The colour values in `GhosttyTerminalConfig` cannot be used as they are: `terminal.ts` `parseColorToHex` encodes an unset colour as 0 ("use default" in `types.ts`), and the default theme's background `#000000` (`src/config.ts`) also becomes 0. `terminal.ts` therefore replaces `parseColorToHex` with `parseColor`, which returns undefined for an unset or invalid colour, and `buildWasmConfig` adds a mask of the theme keys that parsed (foreground, background, cursor and the 16 palette entries), which Coder ignores. A key in the mask is set as given, so `#000000` is black. Foreground and background are always both set: libghostty-vt has no default for them, and its render state copies the default colours only when both are set (`render.zig`, the `bg_fg` block), otherwise keeping white on black and ignoring the given colour, OSC 10/11 and DECSCNM. An unset one falls back to the Coder engine's effective default (foreground `0xcccccc`, background `0x000000`). An unset cursor stays unset, and unset palette entries and entries 16–255 come from `COLOR_PALETTE_DEFAULT`. - Scrollback budget unchanged: the same `Math.ceil(config.scrollback / 80)` goes to `SCROLLBACK_MAX_BYTES`. `scrollback: 0`, unlimited on the Coder wasm, becomes 32 MiB, because 0 bytes disables scrollback and the binding never passes NULL for both limits. `SCROLLBACK_MAX_LINES` stays unset until step 4. - Keys: the official `KeyEncoder` translates ghostty-web's `Key` and `KeyEncoderOption` values to the official `GhosttyKey` and `GhosttyKeyEncoderOption` values from `ghostty_type_json`. It matches by name, with a fixed rename table for the 44 keys whose names differ (e.g. `GRAVE` → `BACKQUOTE`, `ZERO` → `DIGIT_0`, `UP` → `ARROW_UP`, `KP_*` → `NUMPAD_*`) and for `KITTY_KEYBOARD_FLAGS` → `KITTY_FLAGS`. Twenty-nine keys have different numbers in the two builds, so no value is passed through untranslated. It allocates with `ghostty_wasm_alloc` and `ghostty_wasm_free`. @@ -119,7 +120,7 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the |---|---|---| | Browsers | vs Coder | Load, type, render, select and copy, paste, IME, wheel and mouse apps in Chrome, Firefox and Safari | | Resize/reflow | vs Coder | Normal-screen reflow of wrapped lines when shrinking and growing; alternate screen; font zoom and DPR change re-report cell pixels. After growing, the Coder wasm's `getScrollbackLine` returns stale cells past the old width, so there the official engine is checked against the written text | -| Round trips | official | DA1, DA2, DA3; DSR `5n`/`6n`; XTWINOPS `14/16/18/19 t` answered once, in device pixels; a mode 2048 report delivered on resize, font zoom and DPR change, exactly once each and without waiting for PTY output; OSC 0/2 title; OSC 4/10/11 against the theme, including OSC 11 replying `rgb:0000/0000/0000` on the default config; DECRQM 2027 reports set | +| Round trips | official | DA1, DA2, DA3; DSR `5n`/`6n`; XTWINOPS `14/16/18/19 t` answered once, in device pixels; a mode 2048 report delivered on resize, font zoom and DPR change, exactly once each and without waiting for PTY output; OSC 0/2 title; OSC 4/10/11 against the theme, including OSC 11 replying `rgb:0000/0000/0000` on the default config; DECRQM 2027 reports set; kitty keyboard `CSI ? u` gets no reply, before and after `CSI > 31 u`, unless the same change makes key encoding follow the kitty flags | | #180 | official | The ADR 041 stress payload: 0 stale rows over 300 repetitions on the official engine, in Bun (`engine-parity.test.ts`) and in Chrome | | Parity | vs Coder | ls, vim, nvim, less, top, SGR/256/truecolor, Unicode and `seq 5000` captures match cell for cell (text, fg, bg, flags, width, cursor) with 512-byte and 1-byte writes; the Hindi conjunct in the Unicode capture is the one listed difference | | Scrollback | vs Coder | Rows kept at the default config are at least 90% of the Coder engine's, on the same plain and mixed payloads at 120×40 and 200×60 (more rows pass) | @@ -144,7 +145,7 @@ The captures are re-recorded from a clean `HOME` before they are committed as fi ## Consequences - `?engine=official` runs the official wasm with real effects: bell and title from Ghostty, query replies through `WRITE_PTY`, and size replies from Ghostty with device-pixel cells. -- Apps on the official engine see new replies: DA3, XTWINOPS `19 t`, mode 2048 reports, OSC 4/10/11 colour reports, DECRQM and XTVERSION `libghostty`. DA1 and DA2 are unchanged. ENQ stays silent: it needs the `ENQUIRY` callback, which this step does not install, although ADR 041's Consequences list ENQ among the new replies. +- Apps on the official engine see new replies: DA3, XTWINOPS `19 t`, mode 2048 reports, OSC 4/10/11 colour reports, DECRQM and XTVERSION `libghostty`. DA1 and DA2 are unchanged. ENQ stays silent: it needs the `ENQUIRY` callback, which this step does not install, although ADR 041's Consequences list ENQ among the new replies. The kitty keyboard query `CSI ? u` also stays silent: the wasm answers it, but the binding drops the reply because webtty does not encode keys with the kitty flags. Kitty keyboard support (input-handler passing the engine's flags to `setKittyFlags`, encoding printable keys and key releases) is future work; the change that adds it stops dropping the reply. - Scrollback under the default config holds −7% to +25% of the Coder engine's rows, depending on size and payload (see Context), until step 4. - The repo carries 780,049 more bytes of wasm, plus the signature, the sidecar, `fn-table.ts` and the fixtures. Pages using the Coder engine download no new wasm; `client-browser.js` grows by about 37 KB minified (108 → 145 KB, uncompressed) for the official binding, which is bundled whichever engine runs. - `ghostty.ts` holds two engines until step 5 deletes the Coder one. diff --git a/docs/specs/client.md b/docs/specs/client.md index 46f6fdd..b19629b 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -30,15 +30,16 @@ dist/ `client.html` loads only `client-browser.js`. The npm `ghostty-web.js` build inlined the wasm as a base64 `data:` URL, so the browser never fetched `/ghostty-vt.wasm`. The vendored TS is bundled without inlining, so the client now fetches it on every page load through an explicit `Ghostty.load('/ghostty-vt.wasm')`, and the page depends on the server serving that route as `application/wasm`. The server resolves the wasm from `dist/` (npm install) or the vendored path (local dev); nothing comes from `node_modules` ([ADR 041](../adrs/041.client.libghostty-vt.md)). -The server resolves `ghostty-vt-small.wasm`, the official libghostty-vt wasm ([ADR 042](../adrs/042.client.official-vt-engine.md)), the same way and serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`. Nothing loads it yet. +The server resolves `ghostty-vt-small.wasm`, the official libghostty-vt wasm ([ADR 042](../adrs/042.client.official-vt-engine.md)), the same way and serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`. -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): the client fetches only the selected engine's wasm: the build-time default (`coder` until the acceptance matrix is recorded) or `?engine=coder|official` in the page URL. The Coder wasm goes away when the Coder engine is removed. +The client fetches only the selected engine's wasm ([ADR 042](../adrs/042.client.official-vt-engine.md)): `/ghostty-vt.wasm` for the Coder engine, `/ghostty-vt-small.wasm` for the official one. The engine is the build-time default (`DEFAULT_ENGINE` in `engine.ts`, `coder` until the acceptance matrix is recorded) or `?engine=coder|official` in the page URL; any other value uses the default, and nothing is stored. The Coder wasm goes away when the Coder engine is removed. ## Source layout ``` src/client/ index.ts ← browser TypeScript entry point + engine.ts ← DEFAULT_ENGINE and the ?engine=coder|official override (ADR 042) index.html ← HTML shell (no inline script) index.css ← terminal styles vendor/ @@ -56,14 +57,7 @@ scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networ The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. `vendor/ghostty-vt/` is excluded from Biome too, since the update script writes its sidecar; nothing in it is edited. -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md)): - -``` -src/client/ - engine.ts ← DEFAULT_ENGINE and the ?engine=coder|official override -``` - -The official engine (`OfficialTerminal`) is built and tested but not yet selectable; the Coder wasm and sidecar stay until the Coder engine is removed. +The Coder wasm and sidecar stay until the Coder engine is removed ([ADR 041](../adrs/041.client.libghostty-vt.md) step 5). ## Page @@ -72,7 +66,7 @@ The official engine (`OfficialTerminal`) is built and tested but not yet selecta 1. Reads `sessionId` from `window.location.pathname` (`/s/main` → `main`) 2. Fetches `GET /api/config` to get terminal config 3. Sets `document.title = sessionId + ' | webtty'` -4. Loads `/ghostty-vt.wasm` and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) +4. Picks the engine (`?engine=coder|official`, else the build-time default), loads its wasm and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) 5. Connects to `ws:///ws/:id?cols=&rows=` over WebSocket 6. Fits the terminal to the viewport from the full container size (no scrollbar reserve, see ADR 033) and observes resize events 7. Sends a `{ type: 'resize', cols, rows }` JSON message on open and on every terminal resize @@ -200,7 +194,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | -| Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | +| Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | | Official engine by default | The default flips to `official` once ADR 042's acceptance matrix (Chrome, Firefox, Safari; resize/reflow; query round trips; #180; parity) is recorded; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | diff --git a/src/client/engine.test.ts b/src/client/engine.test.ts new file mode 100644 index 0000000..3104f13 --- /dev/null +++ b/src/client/engine.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_ENGINE, engineWasmPath, selectEngine } from './engine'; + +describe('selectEngine', () => { + test('defaults to the Coder engine until ADR 042 flips it', () => { + expect(DEFAULT_ENGINE).toBe('coder'); + expect(selectEngine('')).toBe(DEFAULT_ENGINE); + }); + + test('?engine=official and ?engine=coder override the default', () => { + expect(selectEngine('?engine=official')).toBe('official'); + expect(selectEngine('?engine=coder')).toBe('coder'); + expect(selectEngine('?foo=1&engine=official')).toBe('official'); + }); + + test('any other value uses the default', () => { + expect(selectEngine('?engine=')).toBe(DEFAULT_ENGINE); + expect(selectEngine('?engine=Official')).toBe(DEFAULT_ENGINE); + expect(selectEngine('?engine=ghostty')).toBe(DEFAULT_ENGINE); + }); +}); + +describe('engineWasmPath', () => { + test('each engine loads the wasm the server serves for it', () => { + expect(engineWasmPath('coder')).toBe('/ghostty-vt.wasm'); + expect(engineWasmPath('official')).toBe('/ghostty-vt-small.wasm'); + }); +}); diff --git a/src/client/engine.ts b/src/client/engine.ts new file mode 100644 index 0000000..4172c96 --- /dev/null +++ b/src/client/engine.ts @@ -0,0 +1,23 @@ +/** + * Which terminal engine a tab runs (ADR 042). + * + * The default is compiled into `client-browser.js`; `?engine=coder` or + * `?engine=official` in the page URL overrides it for that tab. Any other value + * uses the default. Nothing is stored, so a reload keeps whatever the URL says. + */ + +import type { EngineName } from './vendor/ghostty-web'; + +/** The engine a tab runs without `?engine`. Flips to `official` after ADR 042's matrix. */ +export const DEFAULT_ENGINE: EngineName = 'coder'; + +/** The engine for a page whose URL query string is `search` (e.g. `?engine=official`). */ +export function selectEngine(search: string): EngineName { + const engine = new URLSearchParams(search).get('engine'); + return engine === 'coder' || engine === 'official' ? engine : DEFAULT_ENGINE; +} + +/** Where the server serves each engine's wasm; the client fetches only the selected one. */ +export function engineWasmPath(engine: EngineName): string { + return engine === 'official' ? '/ghostty-vt-small.wasm' : '/ghostty-vt.wasm'; +} diff --git a/src/client/graphics.test.ts b/src/client/graphics.test.ts index 8f4ddab..51d98c0 100644 --- a/src/client/graphics.test.ts +++ b/src/client/graphics.test.ts @@ -125,6 +125,7 @@ interface Harness { function harness( limits: Partial = {}, cell: { width: number; height: number } = { width: 10, height: 20 }, + answersSizeQueries = false, ): Harness { const written: string[] = []; const sent: string[] = []; @@ -144,7 +145,7 @@ function harness( }, }, }, - wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0 }, + wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0, answersSizeQueries }, renderer: { getMetrics: () => cell }, }; const container = { appendChild: () => {}, querySelector: () => null }; @@ -480,6 +481,21 @@ describe('sequences split across chunks', () => { }); }); +describe('size queries', () => { + test('are answered from the cell size on the Coder engine', () => { + const h = harness(); + h.g.feed(`${ESC}[16t${ESC}[18t`); + expect(h.sent).toEqual([`${ESC}[6;20;10t`, `${ESC}[8;24;80t`]); + }); + + test('are left to an engine that answers them itself (ADR 042)', () => { + const h = harness({}, undefined, true); + h.g.feed(`${ESC}[14t${ESC}[16t${ESC}[18t`); + expect(h.sent).toEqual([]); + expect(h.written.join('')).toBe(`${ESC}[14t${ESC}[16t${ESC}[18t`); + }); +}); + describe('cursor movement at scaled display', () => { // An image exactly cols x rows cells of the size the program was told must // leave the cursor exactly that many columns and rows on. Converting device diff --git a/src/client/graphics.ts b/src/client/graphics.ts index 392bee0..9b0018a 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -251,6 +251,9 @@ export class KittyGraphics { this.term.write(text); const { clears, queries } = this.scanner.scan(text); if (clears) this.clear(); + // The official engine answers size queries itself (OPT_SIZE), so apps get one + // reply per query. See ADR 042. + if (this.term.wasmTerm?.answersSizeQueries) return; for (const q of queries) this.send(sizeReply(q, this.terminalSize())); } diff --git a/src/client/index.ts b/src/client/index.ts index e6b3deb..93eb0fd 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,6 @@ import { Composer } from './compose'; import { EdgeExtender } from './edge'; +import { engineWasmPath, selectEngine } from './engine'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { encodePaste } from './paste'; @@ -63,8 +64,11 @@ document.title = `${sessionId} | webtty`; // The vendored ghostty-web is bundled into /dist/client-browser.js, so its default // wasm lookup (relative to its own module URL) would depend on where the bundle sits. -// Load the wasm from the path the server serves it at instead (ADR 041). -const ghostty = await Ghostty.load('/ghostty-vt.wasm'); +// Load the wasm from the path the server serves it at instead (ADR 041). The engine +// is the build-time default or ?engine=coder|official, and only its wasm is fetched +// (ADR 042). +const engine = selectEngine(window.location.search); +const ghostty = await Ghostty.load(engineWasmPath(engine), engine); // Translate webtty's theme keys to ghostty-web's names and keep `padding`, a webtty // setting and not a terminal colour, out of the theme it receives (ADR 036). @@ -115,6 +119,10 @@ function fit(): void { if (grid.cols !== term.cols || grid.rows !== term.rows) { term.resize(grid.cols, grid.rows); } + // Every font zoom, font load and DPR change reaches fit(). A grid change has + // already sent the new cell pixels with resize; this sends them when only the + // pixels changed, for the official engine's size reports (ADR 042). + term.syncCellPixelSize(); const canvas = container.querySelector('canvas') as HTMLElement | null; if (!canvas) return; const insets = computeInsets( diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index c598945..b8954f4 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -20,5 +20,6 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. -- `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. +- `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. The official engine drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. +- `terminal.ts`, `renderer.ts`: the client runs either engine (ADR 042). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; on the official engine bell and title come from `readEffects` instead of the stream scan; `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours, and `buildWasmConfig` fills `colorsSet` from it; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index c564bc9..a8cc75c 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -1614,6 +1614,21 @@ function rgbObject(rgb: number): RGB { return { r: (rgb >>> 16) & 0xff, g: (rgb >>> 8) & 0xff, b: rgb & 0xff }; } +/** + * Whether one `WRITE_PTY` reply is the kitty keyboard query reply `ESC [ ? u` + * (`queryKittyKeyboard` in Ghostty's `stream_terminal.zig`). No other reply has + * this shape: DECRQM ends in `$y`, DA in `c`. + */ +function isKittyKeyboardReply(reply: Uint8Array): boolean { + const n = reply.length; + if (n < 5 || reply[0] !== 0x1b || reply[1] !== 0x5b || reply[2] !== 0x3f) return false; + if (reply[n - 1] !== 0x75) return false; + for (let i = 3; i < n - 1; i++) { + if (reply[i] < 0x30 || reply[i] > 0x39) return false; + } + return true; +} + /** * A terminal on the official libghostty-vt wasm (ADR 042). * @@ -2179,11 +2194,15 @@ export class OfficialTerminal implements GhosttyTerminal { }; // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. + // Each call carries one whole reply. The kitty keyboard query reply is dropped: + // webtty's input handler never encodes keys with the kitty flags, so answering + // `CSI ? u` would tell apps the protocol is on when it is not (ADR 042). install( L.opt.WRITE_PTY, 'WRITE_PTY', (_t, _u, ptr, len) => { - this.responses.push(this.u8().slice(ptr, ptr + len)); + const reply = this.u8().slice(ptr, ptr + len); + if (!isKittyKeyboardReply(reply)) this.responses.push(reply); return undefined; }, 4, diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 2d893fb..43eb35c 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -23,6 +23,12 @@ export interface IRenderable { * the next call, so rows taken from it are only valid until then. */ getViewport?(): GhosttyCell[]; + /** + * The view's `viewportY` (lines scrolled up from the bottom). `render` calls it + * first, so every render path syncs the engine's viewport in one place. Only the + * official engine implements it. (webtty, ADR 042) + */ + setViewportOffset?(linesFromBottom: number): void; getCursor(): { x: number; y: number; visible: boolean }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; @@ -352,6 +358,9 @@ export class CanvasRenderer { scrollbackProvider?: IScrollbackProvider, scrollbarOpacity: number = 1 ): void { + // Move the engine's viewport to the view before any read (webtty, ADR 042) + buffer.setViewportOffset?.(viewportY); + // Store buffer reference for grapheme lookups in renderCell this.currentBuffer = buffer; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 9f901df..f94d264 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -93,6 +93,16 @@ export function decscusrShape(ps: number): CursorShape | undefined { }; } +/** + * A cell's size in whole device pixels, for the official engine's size reports + * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as kitty.ts's `deviceCellSize` + * does for its own replies: the CSS cell times `devicePixelRatio`, rounded, at + * least 1. (webtty, ADR 042) + */ +export function deviceCellPixels(cssCell: number, dpr: number): number { + return Math.max(1, Math.round(cssCell * dpr)); +} + /** Wheel travel not yet sent to a mouse-tracking app, and its direction. */ export interface WheelAccumulator { accum: number; @@ -384,11 +394,12 @@ export class Terminal implements ITerminalCore { } /** - * Parse a CSS color string to 0xRRGGBB format. - * Returns 0 if the color is undefined or invalid. + * Parse a CSS color string to 0xRRGGBB format, or undefined if the color is + * undefined or invalid. (webtty: replaces `parseColorToHex`, which returned 0 + * for both; tells `#000000` apart from "not given", ADR 042) */ - private parseColorToHex(color?: string): number { - if (!color) return 0; + private parseColor(color?: string): number | undefined { + if (!color) return undefined; // Handle hex colors (#RGB, #RRGGBB) if (color.startsWith('#')) { @@ -397,7 +408,7 @@ export class Terminal implements ITerminalCore { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? 0 : value; + return Number.isNaN(value) ? undefined : value; } // Handle rgb(r, g, b) format @@ -409,7 +420,7 @@ export class Terminal implements ITerminalCore { return (r << 16) | (g << 8) | b; } - return 0; + return undefined; } /** @@ -427,31 +438,42 @@ export class Terminal implements ITerminalCore { // Build palette array from theme colors // Order: black, red, green, yellow, blue, magenta, cyan, white, // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite - const palette: number[] = [ - this.parseColorToHex(theme?.black), - this.parseColorToHex(theme?.red), - this.parseColorToHex(theme?.green), - this.parseColorToHex(theme?.yellow), - this.parseColorToHex(theme?.blue), - this.parseColorToHex(theme?.magenta), - this.parseColorToHex(theme?.cyan), - this.parseColorToHex(theme?.white), - this.parseColorToHex(theme?.brightBlack), - this.parseColorToHex(theme?.brightRed), - this.parseColorToHex(theme?.brightGreen), - this.parseColorToHex(theme?.brightYellow), - this.parseColorToHex(theme?.brightBlue), - this.parseColorToHex(theme?.brightMagenta), - this.parseColorToHex(theme?.brightCyan), - this.parseColorToHex(theme?.brightWhite), - ]; + const paletteColors = [ + theme?.black, + theme?.red, + theme?.green, + theme?.yellow, + theme?.blue, + theme?.magenta, + theme?.cyan, + theme?.white, + theme?.brightBlack, + theme?.brightRed, + theme?.brightGreen, + theme?.brightYellow, + theme?.brightBlue, + theme?.brightMagenta, + theme?.brightCyan, + theme?.brightWhite, + ].map((color) => this.parseColor(color)); + const fg = this.parseColor(theme?.foreground); + const bg = this.parseColor(theme?.background); + const cursor = this.parseColor(theme?.cursor); return { scrollbackLimit: scrollback, - fgColor: this.parseColorToHex(theme?.foreground), - bgColor: this.parseColorToHex(theme?.background), - cursorColor: this.parseColorToHex(theme?.cursor), - palette, + fgColor: fg ?? 0, + bgColor: bg ?? 0, + cursorColor: cursor ?? 0, + palette: paletteColors.map((color) => color ?? 0), + // Which colours parsed, so the official engine sets #000000 as black instead + // of reading 0 as "use default". The Coder engine ignores it. (webtty, ADR 042) + colorsSet: { + foreground: fg !== undefined, + background: bg !== undefined, + cursor: cursor !== undefined, + palette: paletteColors.map((color) => color !== undefined), + }, }; } @@ -564,6 +586,10 @@ export class Terminal implements ITerminalCore { // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); + // Cell pixels for the official engine's size reports, before the first write, + // so a reply never says 0 px. Mode 2048 cannot be on yet. (webtty, ADR 042) + this.syncCellPixelSize(); + // Create mouse tracking configuration const canvas = this.canvas; const renderer = this.renderer; @@ -702,8 +728,14 @@ export class Terminal implements ITerminalCore { this.processTerminalResponses(); // Check for bell character (BEL, \x07) - // WASM doesn't expose bell events, so we detect it in the data stream - if (typeof data === 'string' && data.includes('\x07')) { + // The Coder wasm doesn't expose bell events, so we detect it in the data stream. + // The official engine reports bell and title itself. (webtty, ADR 042) + const scanStream = this.ghostty?.engine !== 'official'; + if (!scanStream) { + const effects = this.wasmTerm!.readEffects(); + if (effects.bell) this.bellEmitter.fire(); + if (effects.title !== null) this.setTitle(effects.title); + } else if (typeof data === 'string' && data.includes('\x07')) { this.bellEmitter.fire(); } else if (data instanceof Uint8Array && data.includes(0x07)) { this.bellEmitter.fire(); @@ -730,7 +762,7 @@ export class Terminal implements ITerminalCore { // Check for title changes (OSC 0, 1, 2 sequences) // This is a simplified implementation - Ghostty WASM may provide this - if (typeof data === 'string' && data.includes('\x1b]')) { + if (scanStream && typeof data === 'string' && data.includes('\x1b]')) { this.checkForTitleChange(data); } @@ -834,8 +866,11 @@ export class Terminal implements ITerminalCore { this.cols = cols; this.rows = rows; - // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views) - this.wasmTerm!.resize(cols, rows); + // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views). + // The official engine takes the device-pixel cell size in the same call, so a + // change of grid and pixels is one size report. (webtty, ADR 042) + const px = this.cellPixelSize(); + this.wasmTerm!.resize(cols, rows, px?.width, px?.height); // Resize renderer this.renderer!.resize(cols, rows); @@ -850,6 +885,10 @@ export class Terminal implements ITerminalCore { // Fire resize event this.resizeEmitter.fire({ cols, rows }); + // A mode 2048 size report from the resize, sent after the PTY resize message + // (webtty, ADR 042) + this.processTerminalResponses(); + // Force full render this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); } catch (e) { @@ -861,6 +900,31 @@ export class Terminal implements ITerminalCore { this.startRenderLoop(); } + /** + * Sends the current device-pixel cell size to the engine for its size reports. + * Called at the end of every fit: if the grid changed, resize() has already sent + * it and this does nothing; if only the pixels changed (font zoom or load, DPR), + * the official engine makes one size update. A no-op on the Coder engine. + * (webtty, ADR 042) + */ + syncCellPixelSize(): void { + const px = this.cellPixelSize(); + if (!this.wasmTerm || !px) return; + this.wasmTerm.setCellPixelSize(px.width, px.height); + this.processTerminalResponses(); + } + + /** The renderer's cell in whole device pixels, or null before open(). (webtty) */ + private cellPixelSize(): { width: number; height: number } | null { + if (!this.renderer) return null; + const metrics = this.renderer.getMetrics(); + const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; + return { + width: deviceCellPixels(metrics.width, dpr), + height: deviceCellPixels(metrics.height, dpr), + }; + } + /** * Clear terminal screen */ @@ -882,6 +946,7 @@ export class Terminal implements ITerminalCore { } const config = this.buildWasmConfig(); this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + this.syncCellPixelSize(); // Clear renderer this.renderer!.clear(); @@ -1383,6 +1448,9 @@ export class Terminal implements ITerminalCore { // 3. Always calls clearDirty() at the end this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); + // Replies that arose outside write() reach the PTY within a frame (webtty, ADR 042) + if (this.wasmTerm!.hasResponse()) this.processTerminalResponses(); + // Check for cursor movement (Phase 2: onCursorMove event) // Note: getCursor() reads from already-updated render state (from render() above) const cursor = this.wasmTerm!.getCursor(); @@ -2098,14 +2166,18 @@ export class Terminal implements ITerminalCore { // OSC 0 and OSC 2 set the title if (ps === '0' || ps === '2') { - if (pt !== this.currentTitle) { - this.currentTitle = pt; - this.titleChangeEmitter.fire(pt); - } + this.setTitle(pt); } } } + /** Fires onTitleChange when the title differs from the current one. */ + private setTitle(title: string): void { + if (title === this.currentTitle) return; + this.currentTitle = title; + this.titleChangeEmitter.fire(title); + } + // ============================================================================ // Terminal Modes // ============================================================================ diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 1f67616..dd3054a 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -474,6 +474,19 @@ describe('official engine: effects and replies', () => { t.free(); }); + test('does not answer the kitty keyboard query', () => { + // Keys are never encoded with the kitty flags, so the protocol must not look on. + const t = term(); + t.write('\x1b[?u'); + expect(t.readResponse()).toBeNull(); + t.write('\x1b[>31u\x1b[?u'); + expect(t.readResponse()).toBeNull(); + // Replies around it still arrive, in order. + t.write('\x1b[c\x1b[?u\x1b[5n'); + expect(t.readResponse()).toBe('\x1b[?62;22c\x1b[0n'); + t.free(); + }); + test('sends one mode 2048 report per real size change', () => { const t = term(80, 24); t.setCellPixelSize(9, 18); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 8b824c1..0c41566 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { cursorCellOffset, decscusrShape, + deviceCellPixels, mouseCursorFor, scanDecscusr, type WheelAccumulator, @@ -191,3 +192,16 @@ describe('cursorCellOffset', () => { expect(cursorCellOffset({ x: 80, y: 0 }, 0, grid, cell, canvas).left).toBe(79 * 9); }); }); + +describe('deviceCellPixels', () => { + test('is the CSS cell at the device pixel ratio, in whole pixels', () => { + expect(deviceCellPixels(9, 2)).toBe(18); + expect(deviceCellPixels(8.4, 1.25)).toBe(11); + expect(deviceCellPixels(19.2, 1.5)).toBe(29); + }); + + test('never reports less than one pixel', () => { + expect(deviceCellPixels(0, 2)).toBe(1); + expect(deviceCellPixels(0.2, 1)).toBe(1); + }); +}); From 2d14e364f7b078163cef5bae09d0cb1ea5872535 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 03:22:36 -0400 Subject: [PATCH 015/295] fix(client): draw scrolled graphemes from their own row renderCellText looked multi-codepoint graphemes up by canvas row, which reads a screen row, so while scrolled a scrollback grapheme (emoji with a variation selector, combining marks) drew whatever sat at that column of the screen: a stray letter, or U+0000 as a missing-glyph box. Pressing a key snapped back to the bottom, where the rows line up. Both engines had it; the official engine's mode 2027 clustering makes it show more often. renderLine now takes a lookup for the line's own source, and the smooth scroll strip passes its scrollback row. --- src/client/smooth-scroll.ts | 11 ++-- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/renderer.ts | 44 ++++++++++--- src/client/vendor/ghostty-web/terminal.ts | 9 +++ src/client/vendor/renderer.test.ts | 69 ++++++++++++++++++++- 5 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 5841989..66cf9f8 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -48,7 +48,7 @@ type Cell = NonNullable>[number]; interface RendererInternals { ctx: CanvasRenderingContext2D; - renderLine(line: Cell[], row: number, cols: number): void; + renderLine(line: Cell[], row: number, cols: number, grapheme: (col: number) => string): void; } export class SmoothScroll { @@ -138,7 +138,8 @@ export class SmoothScroll { cellHeight: number, ): void { const ctx = this.stripCtx; - const cells = this.term.getScrollbackLine(this.term.getScrollbackLength() - line - 1); + const sbOffset = this.term.getScrollbackLength() - line - 1; + const cells = this.term.getScrollbackLine(sbOffset); if (!ctx || !cells) { this.strip.style.display = 'none'; return; @@ -158,14 +159,16 @@ export class SmoothScroll { // renderLine draws row `r` at y = r x cellHeight into renderer.ctx. Borrow it with the // strip's context, shifted down a row so row -1 lands at y = 0. Row -1 is never in a - // selection, so the line draws unselected. + // selection, so the line draws unselected. Graphemes come from the same scrollback row. ctx.setTransform(dpr, 0, 0, dpr, 0, cellHeight * dpr); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; const own = renderer.ctx; renderer.ctx = ctx; try { - renderer.renderLine(cells, -1, this.term.cols); + renderer.renderLine(cells, -1, this.term.cols, (col) => + this.term.getScrollbackGraphemeString(sbOffset, col), + ); } finally { renderer.ctx = own; } diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index b8954f4..50c27db 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -22,4 +22,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. - `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. The official engine drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. - `terminal.ts`, `renderer.ts`: the client runs either engine (ADR 042). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; on the official engine bell and title come from `readEffects` instead of the stream scan; `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours, and `buildWasmConfig` fills `colorsSet` from it; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. +- `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder engine). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 43eb35c..7256106 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -46,8 +46,13 @@ export interface IRenderable { export interface IScrollbackProvider { getScrollbackLine(offset: number): GhosttyCell[] | null; getScrollbackLength(): number; + /** Full grapheme string for a scrollback cell, as getGraphemeString for screen rows. */ + getScrollbackGraphemeString?(offset: number, col: number): string; } +/** Looks up the full grapheme string for a column of the line being drawn. */ +export type GraphemeLookup = (col: number) => string; + // ============================================================================ // Type Definitions // ============================================================================ @@ -543,8 +548,10 @@ export class CanvasRenderer { anyLinesRendered = true; - // Fetch line from scrollback or visible screen + // Fetch line from scrollback or visible screen. Graphemes are looked up in the + // same source: canvas row y is not screen row y while scrolled. (webtty) let line: GhosttyCell[] | null = null; + let grapheme: GraphemeLookup | undefined; if (viewportY > 0) { // Scrolled up - need to fetch from scrollback + visible screen // When scrolled up N lines, we want to show: @@ -557,10 +564,12 @@ export class CanvasRenderer { // Floor viewportY for array access (handles fractional values during smooth scroll) const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; line = scrollbackProvider.getScrollbackLine(scrollbackOffset); + grapheme = this.scrollbackGraphemes(scrollbackProvider, scrollbackOffset); } else { // This row is from visible screen (lower part of viewport) const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y; line = getLine(screenRow); + grapheme = this.screenGraphemes(buffer, screenRow); } } else { // At bottom - fetch from visible screen @@ -568,7 +577,7 @@ export class CanvasRenderer { } if (line) { - this.renderLine(line, y, dims.cols); + this.renderLine(line, y, dims.cols, grapheme); } } @@ -607,7 +616,12 @@ export class CanvasRenderer { * and text in a single pass (cell by cell), the background of cell N would * cover any left-extending portions of graphemes from cell N-1. */ - private renderLine(line: GhosttyCell[], y: number, cols: number): void { + private renderLine( + line: GhosttyCell[], + y: number, + cols: number, + grapheme: GraphemeLookup = this.screenGraphemes(this.currentBuffer, y) + ): void { const lineY = y * this.metrics.height; const lineWidth = cols * this.metrics.width; @@ -634,10 +648,20 @@ export class CanvasRenderer { for (let x = 0; x < line.length; x++) { const cell = line[x]; if (cell.width === 0) continue; // Skip spacer cells for wide characters - this.renderCellText(cell, x, y); + this.renderCellText(cell, x, y, undefined, grapheme); } } + /** Grapheme lookup for a screen row. */ + private screenGraphemes(buffer: IRenderable | null, row: number): GraphemeLookup { + return (col) => buffer?.getGraphemeString?.(row, col) ?? ' '; + } + + /** Grapheme lookup for a scrollback row. */ + private scrollbackGraphemes(provider: IScrollbackProvider, offset: number): GraphemeLookup { + return (col) => provider.getScrollbackGraphemeString?.(offset, col) ?? ' '; + } + /** * Render a cell's background only (Pass 1 of two-pass rendering) * Selection highlighting is integrated here to avoid z-order issues with @@ -683,7 +707,13 @@ export class CanvasRenderer { * Render a cell's text and decorations (Pass 2 of two-pass rendering) * Selection foreground color is applied here to match the selection background. */ - private renderCellText(cell: GhosttyCell, x: number, y: number, colorOverride?: string): void { + private renderCellText( + cell: GhosttyCell, + x: number, + y: number, + colorOverride?: string, + grapheme: GraphemeLookup = this.screenGraphemes(this.currentBuffer, y) + ): void { const cellX = x * this.metrics.width; const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; @@ -734,9 +764,9 @@ export class CanvasRenderer { // Get the character to render - use grapheme lookup for complex scripts let char: string; - if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) { + if (cell.grapheme_len > 0) { // Cell has additional codepoints - get full grapheme cluster - char = this.currentBuffer.getGraphemeString(y, x); + char = grapheme(x); } else { // Simple cell - single codepoint char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index f94d264..0479d7a 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -1490,6 +1490,15 @@ export class Terminal implements ITerminalCore { return this.wasmTerm.getScrollbackLength(); } + /** + * Get the full grapheme string for a scrollback cell + * Implements IScrollbackProvider + */ + public getScrollbackGraphemeString(offset: number, col: number): string { + if (!this.wasmTerm) return ' '; + return this.wasmTerm.getScrollbackGraphemeString(offset, col); + } + /** * Clean up components (called on dispose or error) */ diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 1e0be4e..b32a9ff 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -2,10 +2,19 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Ghostty } from './ghostty-web/ghostty'; -import { frameLines, watchDevicePixelRatio, wholeDevicePixelRatio } from './ghostty-web/renderer'; +import { + CanvasRenderer, + frameLines, + watchDevicePixelRatio, + wholeDevicePixelRatio, +} from './ghostty-web/renderer'; import type { GhosttyCell } from './ghostty-web/types'; const WASM = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-web/ghostty-vt.wasm'); +const OFFICIAL_WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'ghostty-vt/ghostty-vt-small.wasm', +); describe('wholeDevicePixelRatio', () => { test('keeps a whole ratio', () => { @@ -163,3 +172,61 @@ describe('frameLines', () => { term.free(); }); }); + +/** A 2D context that records the text drawn on each canvas row. */ +function recordingCanvas(cellHeight: number) { + const drawn = new Map(); + const ctx = new Proxy( + { + fillText: (text: string, _x: number, y: number) => { + const row = Math.floor(y / cellHeight); + drawn.set(row, [...(drawn.get(row) ?? []), text]); + }, + measureText: () => ({ width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }), + } as Record, + { get: (t, k) => (k in t ? t[k] : () => undefined), set: () => true }, + ); + const canvas = { getContext: () => ctx, style: {}, width: 0, height: 0 }; + return { canvas: canvas as unknown as HTMLCanvasElement, drawn }; +} + +describe('graphemes while scrolled', () => { + for (const [engine, wasm] of [ + ['coder', WASM], + ['official', OFFICIAL_WASM], + ] as const) { + test(`draws scrollback graphemes from their own row (${engine})`, async () => { + const doc = globalThis.document; + const { canvas, drawn } = recordingCanvas(15); + globalThis.document = { createElement: () => canvas } as unknown as Document; + try { + const ghostty = await Ghostty.load(wasm, engine); + const term = ghostty.createTerminal(30, 6, { scrollbackLimit: 10000 }); + // A grapheme at a different column on every line, so a lookup on the wrong + // row finds something else. + for (let i = 0; i < 20; i++) term.write(`${'x'.repeat(i % 7)}e\u0301 ${i}\r\n`); + term.write('$ abcdefghijklmnop'); + const sb = term.getScrollbackLength(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => sb, + getScrollbackGraphemeString: (o: number, c: number) => + term.getScrollbackGraphemeString(o, c), + }; + const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1 }); + renderer.resize(30, 6); + renderer.render(term, true, 3, provider); + // Canvas rows 0-2 show the last three scrollback lines (line i is the i-th + // written); rows 3-5 the top of the screen. + for (let row = 0; row < 3; row++) { + expect(drawn.get(row)).toContain('e\u0301'); + expect(drawn.get(row)?.join('')).toContain(` ${sb - 3 + row} `); + } + expect(drawn.get(3)).toContain('e\u0301'); + term.free(); + } finally { + globalThis.document = doc; + } + }); + } +}); From 2a11a6a35608b88c4b53433df7bc146090a6d572 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 03:33:13 -0400 Subject: [PATCH 016/295] fix(server): decode PTY output as a stream so split characters survive Bun's PTY path decoded every read on its own with Buffer.toString('utf8'). A read that ends inside a multi-byte character (e.g. the 3-byte box line Claude Code draws) turned both halves into U+FFFD, which the terminal then showed until the app redrew the line. Keep one streaming TextDecoder per PTY so partial bytes wait for the rest. node-pty already decodes with a StringDecoder. --- src/pty/bun.ts | 7 ++++++- src/pty/index.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/pty/bun.ts b/src/pty/bun.ts index d9f644a..8a722e3 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -22,13 +22,18 @@ export function spawn( ): PtyProcess { let onDataCb: ((data: string) => void) | undefined; let onExitCb: ((e: { exitCode: number }) => void) | undefined; + // One streaming decoder per PTY: a read can end mid-character, and decoding each + // chunk on its own turns both halves of a split multi-byte character (e.g. `─`) + // into U+FFFD. The decoder holds the partial bytes until the rest arrives. + const decoder = new TextDecoder(); const proc = Bun.spawn([shell], { terminal: { cols, rows, data(_term: unknown, data: Uint8Array) { - onDataCb?.(Buffer.from(data).toString('utf8')); + const text = decoder.decode(data, { stream: true }); + if (text) onDataCb?.(text); }, }, cwd, diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 2f1c899..6f975d3 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { spawnForSession } from './index'; function waitForData(received: string[], content: string, timeout = 3000): Promise { @@ -74,4 +77,36 @@ describe('spawnForSession', () => { await new Promise((resolve) => pty.onExit(() => resolve())); expect(received.join('')).toContain('hello-pty'); }); + + test.skipIf(process.platform === 'win32')( + 'multi-byte characters split across reads arrive intact', + async () => { + // `cat` writes in fixed-size blocks, so 3-byte `─` characters after a 1-byte + // prefix straddle block (and read) boundaries. + const file = path.join(os.tmpdir(), `webtty-pty-mb-${process.pid}.txt`); + fs.writeFileSync(file, `x${'─'.repeat(100_000)}\n`); + const pty = spawnForSession( + 80, + 24, + TEST_SHELL, + 'xterm-256color', + 'truecolor', + process.cwd(), + {}, + ); + const received: string[] = []; + pty.onData((d) => received.push(d)); + pty.write(`cat '${file}'; echo DONE-$((40+2))\n`); + try { + await waitForData(received, 'DONE-42', 15000); + } finally { + pty.kill(); + fs.rmSync(file, { force: true }); + } + const out = received.join(''); + expect(out.split('\uFFFD').length - 1).toBe(0); + expect(out.split('─').length - 1).toBe(100_000); + }, + 20000, + ); }); From f02c0acd50aaf90bc306be665de6ce90b0a0cf55 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 04:40:35 -0400 Subject: [PATCH 017/295] perf(client): serve scrolled official-engine rows from cached pages Keep Ghostty's viewport at the bottom and let a scroll step only record the offset. Scrollback rows come from the page cache, so a cached step makes no WASM calls and nothing is re-decoded. Scrolled frames drop from about 1.46 ms to 0.98 ms (Coder: 1.30 ms) at 200x60. --- docs/adrs/042.client.official-vt-engine.md | 11 +- src/client/vendor/engine-parity.test.ts | 63 ++++++++++ src/client/vendor/ghostty-web/ghostty.ts | 123 +++++++++----------- src/client/vendor/official-terminal.test.ts | 89 ++++++++++++++ 4 files changed, 212 insertions(+), 74 deletions(-) diff --git a/docs/adrs/042.client.official-vt-engine.md b/docs/adrs/042.client.official-vt-engine.md index b6a2923..d5f6d58 100644 --- a/docs/adrs/042.client.official-vt-engine.md +++ b/docs/adrs/042.client.official-vt-engine.md @@ -86,11 +86,11 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the **Binding (official).** -- Rows: a refresh runs `ghostty_render_state_update`, then `ghostty_render_state_row_iterator_next_dirty` and one `ghostty_render_state_row_get_multi` per dirty row for `ROW_DATA_CELLS_RAW` and `ROW_DATA_VIEWPORT_Y`. Each row is decoded straight into JS-owned `GhosttyCell` objects, with bit positions from the `GhosttyCell` descriptor in `ghostty_type_json` and colours and flags from a per-row style cache (`ghostty_render_state_row_cells_get` `STYLE`, only for a style id not yet seen). Graphemes are read only for cells tagged as graphemes. The JS copy is indexed by viewport row. At the bottom (`viewportY` 0) the viewport is the active screen, so `getViewport`, `getLine(n)` and `isRowDirty(n)` read copy row `n`. -- Refresh and dirty rules. The renderer and the Coder-style reads call `update()` many times per frame (`renderer.ts` `render` through `getCursor`, `needsFullRedraw` and `getLine`; selection through `getLine` once per row) and rely on dirty state persisting until `clearDirty()`/`markClean()`. The binding keeps a generation counter, bumped by `write`, `resize`, a `setCellPixelSize` that calls `ghostty_terminal_resize`, a viewport move in `setViewportOffset`, and `reset`. The viewport moves a page read makes, and its move back, do not bump it. A second counter, the content generation, is bumped by all of these except the viewport move; it keys the page cache below. `update()` refreshes only when the generation has changed since the last refresh; otherwise it returns the stored dirty state without an export call. So `getLine`, `getViewport` and `getCursor` always reflect the latest write, as they do on Coder, and a fully dirty frame decodes each row once however often `update()` is called. Each refresh ends with `ghostty_render_state_clean`, which clears both the global `DIRTY` and every per-row `ROW_OPTION_DIRTY` (`render.h`: `update` does not clear either layer), so the next refresh decodes only rows changed since. Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no row dirty when only the defaults change (OSC 10/11/110/111 and DECSCNM leave `DIRTY` false, because its renderer applies them globally; a palette change is `FULL`). So each refresh compares the default colours and the 256-entry palette with the previous refresh and, if any changed, decodes every row and reports full. The rows it decoded go into a JS dirty set, and the global state it returned is kept as the highest seen (none, partial, full). Both accumulate until `markClean()` empties them. Renders outside the render loop (`writeInternal`'s `awaitingEcho` render, `index.ts`'s `visibilitychange` repaint, ADR 040's `renderLine`) go through the same `update()`. +- Rows: a refresh runs `ghostty_render_state_update`, then `ghostty_render_state_row_iterator_next_dirty` and one `ghostty_render_state_row_get_multi` per dirty row for `ROW_DATA_CELLS_RAW` and `ROW_DATA_VIEWPORT_Y`. Each row is decoded straight into JS-owned `GhosttyCell` objects, with bit positions from the `GhosttyCell` descriptor in `ghostty_type_json` and colours and flags from a per-row style cache (`ghostty_render_state_row_cells_get` `STYLE`, only for a style id not yet seen). Graphemes are read only for cells tagged as graphemes. Ghostty's viewport stays at `BOTTOM` except inside a page read (below), so the primary render state always holds the active screen. The JS copy is indexed by active row, and `getViewport`, `getLine(n)` and `isRowDirty(n)` read copy row `n` at any offset. +- Refresh and dirty rules. The renderer and the Coder-style reads call `update()` many times per frame (`renderer.ts` `render` through `getCursor`, `needsFullRedraw` and `getLine`; selection through `getLine` once per row) and rely on dirty state persisting until `clearDirty()`/`markClean()`. The binding keeps a generation counter, bumped by `write`, `resize`, a `setCellPixelSize` that calls `ghostty_terminal_resize`, and `reset`. `setViewportOffset` and the viewport moves a page read makes do not bump it. The same counter keys the page cache below. `update()` refreshes only when the generation has changed since the last refresh; otherwise it returns the stored dirty state without an export call. So `getLine`, `getViewport` and `getCursor` always reflect the latest write, as they do on Coder, and a fully dirty frame decodes each row once however often `update()` is called. Each refresh ends with `ghostty_render_state_clean`, which clears both the global `DIRTY` and every per-row `ROW_OPTION_DIRTY` (`render.h`: `update` does not clear either layer), so the next refresh decodes only rows changed since. Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no row dirty when only the defaults change (OSC 10/11/110/111 and DECSCNM leave `DIRTY` false, because its renderer applies them globally; a palette change is `FULL`). So each refresh compares the default colours and the 256-entry palette with the previous refresh and, if any changed, decodes every row and reports full. The rows it decoded go into a JS dirty set, and the global state it returned is kept as the highest seen (none, partial, full). Both accumulate until `markClean()` empties them. Renders outside the render loop (`writeInternal`'s `awaitingEcho` render, `index.ts`'s `visibilitychange` repaint, ADR 040's `renderLine`) go through the same `update()`. - Cursor: `getCursor` reads `DATA_CURSOR_X`/`DATA_CURSOR_Y` (active-screen coordinates), not the render state's viewport cursor. -- Scrollback: `getScrollbackLength` reads `DATA_SCROLLBACK_ROWS` (S below). Writes never leave the view scrolled, but `terminal.ts` `writeInternal` calls `wasmTerm.write` first and `scrollToBottom()` after it (xterm behaviour), and `smooth-scroll.ts` `onOutput` then drops its sub-line offset. So `OfficialTerminal.write` itself moves Ghostty's viewport to `BOTTOM` before `vt_write` whenever it is scrolled, sets its offset to 0 and marks every row dirty; pruning and screen switches then happen with the viewport at the bottom, and any render before `scrollToBottom` (the `awaitingEcho` render) passes a stale `viewportY` that `setViewportOffset` moves back. A scrolled frame (k = `floor(viewportY)` > 0) follows user scrolling, and S changes under it only through resize and reflow. `setViewportOffset` moves Ghostty's viewport with `ghostty_terminal_scroll_viewport` `ROW` to row S − k (`ROW` counts from the top of scrollback, `viewportY` from the bottom) whenever k or S differs from the last move. Reflow moves Ghostty's viewport with the rows it shows even when S is unchanged, so a resize while scrolled moves it back to `ROW` S − k itself, before any read. The frame is read through the same render-state path, and copy row y maps to `getScrollbackLine(S − k + y)` for y < k and to `getLine(y − k)` for y ≥ k. Those reads are served from the copy. `isRowDirty` returns true for every row while scrolled, since the renderer repaints all rows then anyway. Back at the bottom (`viewportY` 0, including the snap after a write), the binding scrolls Ghostty's viewport with `BOTTOM` and marks every row dirty for the next frame. -- Reads outside the viewport (`getLine(n)` for n ≥ rows − k, scrollback rows above the viewport, smooth scroll's row above the top, selection copy and the buffer API) are read a page at a time through the render state, not cell by cell. On a miss the binding moves Ghostty's viewport with `scroll_viewport` `ROW` to the page of `rows` rows that holds the row, reads the page with `CELLS_RAW` through a second render state, and moves the viewport back before returning: to `BOTTOM` when the offset is 0, to `ROW` S − k otherwise. `scroll_viewport` `ROW` n with n ≥ S snaps to the active screen (`PageList.zig` `scroll`), so a page starts at min(n, S) and rows are mapped from that start. Pages are cached until the content generation changes, so a viewport move keeps them: while scrolled by 0 < k < rows, the renderer's `getViewport` reads the k active rows below the view from the cache instead of decoding them again on every step. A render state's update consumes the terminal's dirty state (page and row dirty bits and the terminal-wide full-redraw flags; `render.zig` `beginUpdate` assumes it is the only consumer), so the second state would hide changes from the primary. Before a page read the binding therefore refreshes the primary when the generation has changed, so the primary has captured every pending dirty bit and flag (and the palette) before the second state runs; this ordering is what keeps the primary correct. The reverse also holds: the second state cannot trust dirty bits the primary has already cleared, and `beginUpdate` rebuilds a row only on a pin, screen or size change or a dirty bit (`render.zig`), so a page read at the same row after an in-place write, or after pruning recycled a page at the same address, would return stale cells. The page-read state is therefore freed and recreated whenever the content generation has changed, so every miss after a content change rebuilds every row it reads. A viewport move alone changes no row, pin or size of the rows already read, so the state is kept across it. Copying a selection over N rows costs about N / rows page reads plus one decode per row, and smooth scroll's row above the top is a cache hit on most frames. `ghostty_terminal_grid_ref` is kept for single-cell lookups (hyperlink hover, a grapheme outside the copy). Per-cell `grid_ref` costs one lookup plus cell, style and grapheme calls per cell (`grid_ref.h` notes the lookup's cost): a 3,000-row selection at 200 columns would be over 1.8 million export calls, and step 4 raises scrollback to 10,000 lines. +- Scrollback: `getScrollbackLength` reads `DATA_SCROLLBACK_ROWS` (S below). Writes never leave the view scrolled, but `terminal.ts` `writeInternal` calls `wasmTerm.write` first and `scrollToBottom()` after it (xterm behaviour), and `smooth-scroll.ts` `onOutput` then drops its sub-line offset. So `OfficialTerminal.write` sets its offset to 0 when it is scrolled and reports every row dirty for the next frame; Ghostty's viewport is already at the bottom, so pruning and screen switches happen there. Any render before `scrollToBottom` (the `awaitingEcho` render) passes a stale `viewportY`, which `setViewportOffset` records again. A scrolled frame (k = `floor(viewportY)` > 0) follows user scrolling. `setViewportOffset` only records k, clamped to [0, S] (0 on the alternate screen): it does not move Ghostty's viewport, bump the generation or decode a row. The renderer shows row y as `getScrollbackLine(S − k + y)` for y < k, served from the page cache, and as `getLine(y − k)` (its `getViewport` slice) for y ≥ k, served from the copy. So a scroll step whose rows are cached makes no export call, and a step onto an uncached page costs one page read (`rows` rows). `getScrollbackLine` returns a new array of the page's cell objects, which are never written after the page is read, instead of copying every cell. A resize while scrolled reflows with the viewport at the bottom, bumps the generation (dropping every page) and clamps k to the new S. `isRowDirty` returns true for every row while scrolled, since the renderer repaints all rows then anyway. Back at the bottom (`viewportY` 0, including the snap after a write), every row is reported dirty for the next frame without decoding any row again. On a fake 2D canvas (Bun 1.3.11, Apple M3 Pro, 200×60, 3,000 lines of box rules, truecolor, VS16 emoji, skin tones and combining marks, 600 frames scrolled one line each, best of three per run, three runs), a scrolled frame took 1.46–1.49 ms on the official engine against 1.31–1.36 ms on Coder when `setViewportOffset` moved Ghostty's viewport and redecoded all `rows` rows per step; with the viewport kept at the bottom it takes 0.98–0.99 ms against Coder's 1.30–1.34 ms. +- Reads outside the viewport (`getLine(n)` for n ≥ rows − k, scrollback rows above the viewport, smooth scroll's row above the top, selection copy and the buffer API) are read a page at a time through the render state, not cell by cell. Active rows always come from the copy; only scrollback rows (absolute row < S) are read this way. On a miss the binding moves Ghostty's viewport with `scroll_viewport` `ROW` to the page of `rows` rows that holds the row, reads the page with `CELLS_RAW` through a second render state, and moves the viewport back to `BOTTOM` before returning. Pages start at multiples of `rows` from the oldest row, so a page holding a scrollback row starts below S; `scroll_viewport` `ROW` n with n ≥ S would snap to the active screen (`PageList.zig` `scroll`). A page that runs on into active rows is cached whole, but those rows are served from the copy. Pages are cached until the generation changes, so scrolling keeps them. A render state's update consumes the terminal's dirty state (page and row dirty bits and the terminal-wide full-redraw flags; `render.zig` `beginUpdate` assumes it is the only consumer), so the second state would hide changes from the primary. Before a page read the binding therefore refreshes the primary when the generation has changed, so the primary has captured every pending dirty bit and flag (and the palette) before the second state runs; this ordering is what keeps the primary correct. The reverse also holds: the second state cannot trust dirty bits the primary has already cleared, and `beginUpdate` rebuilds a row only on a pin, screen or size change or a dirty bit (`render.zig`), so a page read at the same row after an in-place write, or after pruning recycled a page at the same address, would return stale cells. The page-read state is therefore freed and recreated whenever the generation has changed, so every miss after a content change rebuilds every row it reads. Scrolling changes no row, pin or size, so the state is kept across it. Copying a selection over N rows costs about N / rows page reads plus one decode per row, and smooth scroll's row above the top is a cache hit on most frames. `ghostty_terminal_grid_ref` is kept for single-cell lookups (hyperlink hover, a grapheme outside the copy). Per-cell `grid_ref` costs one lookup plus cell, style and grapheme calls per cell (`grid_ref.h` notes the lookup's cost): a 3,000-row selection at 200 columns would be over 1.8 million export calls, and step 4 raises scrollback to 10,000 lines. - Callbacks through `fn-table.ts`, installed per terminal and released after `ghostty_terminal_free`. None re-enters `vt_write`; each copies what it needs and returns. - `WRITE_PTY`: copies the bytes into the queue that `readResponse` drains, the path `terminal.ts` already uses for Coder replies. Replies also arise outside `write` (the mode 2048 report from `ghostty_terminal_resize`), so `terminal.ts` drains at the three points listed above. The one exception is the kitty keyboard query reply `CSI ? flags u`, which the binding drops: `input-handler.ts` never passes the kitty flags to `KeyEncoder.setKittyFlags`, on either engine, so after `CSI > 31 u` keys still arrive in the legacy encoding, and a reply would make apps that detect the protocol from it (nvim, fish 4, helix) expect kitty keys. Dropping it keeps the Coder engine's behaviour (no reply). Ghostty sends each reply in its own `WRITE_PTY` call, and no other reply has that shape. - `BELL`, `TITLE_CHANGED`: set flags. The title is read with `DATA_TITLE` after `vt_write` returns, copied, and handed out by `readEffects`. @@ -99,7 +99,7 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the - `Terminal.resize()`, which already returns early when columns and rows are unchanged: it computes the pixel size and calls `wasmTerm.resize(cols, rows, w, h)`, one `ghostty_terminal_resize` with the new grid and pixels. - A new `Terminal.syncCellPixelSize()`, called in `index.ts` `fit()` right after its grid resize (before the padding layout, which can return early). Every metrics or DPR change reaches `fit()`: font zoom calls it directly after setting `fontSize`, and the `document.fonts.ready` remeasure and `watchDevicePixelRatio` go through `scheduleFit`. If the grid changed, `resize` has already sent the new pixels and this does nothing; if only the pixels changed, `setCellPixelSize` makes one call at the current grid. - `setCellPixelSize` returns at once when the size equals the one Ghostty last received. Nothing is flushed from the render loop, so the order of the fit's and the render loop's `requestAnimationFrame` callbacks does not matter. A unit test drives `OfficialTerminal` over the real wasm exports with `ghostty_terminal_resize` counted, through the calls `terminal.ts` and `fit()` make, and asserts one `ghostty_terminal_resize` for a zoom that changes grid and pixels, one for a zoom or DPR change that keeps the grid, and none for repeated fits. The refresh, scrolled-view and page-read logic is tested on the real wasm in `engine-parity.test.ts`, against the Coder engine: scrolled reads (`getLine`, `getScrollbackLine`, rendered rows) at several k, a write while scrolled, a resize while scrolled, a write that dirties active rows followed by a page read and then a render, a scrolled page read, an in-place write, a render with the stale `viewportY`, then the same page read again, and the offset stepped through 0 < k < rows, k = rows and beyond and back, before and after an in-place write. + `setCellPixelSize` returns at once when the size equals the one Ghostty last received. Nothing is flushed from the render loop, so the order of the fit's and the render loop's `requestAnimationFrame` callbacks does not matter. A unit test drives `OfficialTerminal` over the real wasm exports with `ghostty_terminal_resize` counted, through the calls `terminal.ts` and `fit()` make, and asserts one `ghostty_terminal_resize` for a zoom that changes grid and pixels, one for a zoom or DPR change that keeps the grid, and none for repeated fits. The refresh, scrolled-view and page-read logic is tested on the real wasm in `engine-parity.test.ts`, against the Coder engine: scrolled reads (`getLine`, `getScrollbackLine`, rendered rows) at several k, a write while scrolled, a resize while scrolled, a write that dirties active rows followed by a page read and then a render, a scrolled page read, an in-place write, a render with the stale `viewportY`, then the same page read again, the offset stepped through 0 < k < rows, k = rows and beyond and back, before and after an in-place write, and a walk over rows with graphemes compared on rows, graphemes and cursor at every step, with output and a resize while scrolled. `official-terminal.test.ts` counts `ghostty_render_state_update`, `ghostty_render_state_row_get_multi` and `ghostty_terminal_scroll_viewport` over renderer-shaped frames: the first walk through scrollback reads each page once, a walk over cached pages makes none of these calls, and output or a resize while scrolled moves no viewport. - `DEVICE_ATTRIBUTES`: primary conformance 62 with feature 22, secondary type 1, firmware 10, ROM 0, tertiary unit id 0. This gives `ESC[?62;22c` and `ESC[>1;10;0c`. - `ENQUIRY`, `XTVERSION`, clipboard, notification and the other effects are not installed (ADR 041 future work). - Setup: `MODE_DEFAULT` with mode 2027 on, so RIS keeps grapheme clustering. The theme's foreground, background, cursor and palette are set through `COLOR_FOREGROUND`, `COLOR_BACKGROUND`, `COLOR_CURSOR` and `COLOR_PALETTE`. The colour values in `GhosttyTerminalConfig` cannot be used as they are: `terminal.ts` `parseColorToHex` encodes an unset colour as 0 ("use default" in `types.ts`), and the default theme's background `#000000` (`src/config.ts`) also becomes 0. `terminal.ts` therefore replaces `parseColorToHex` with `parseColor`, which returns undefined for an unset or invalid colour, and `buildWasmConfig` adds a mask of the theme keys that parsed (foreground, background, cursor and the 16 palette entries), which Coder ignores. A key in the mask is set as given, so `#000000` is black. Foreground and background are always both set: libghostty-vt has no default for them, and its render state copies the default colours only when both are set (`render.zig`, the `bg_fg` block), otherwise keeping white on black and ignoring the given colour, OSC 10/11 and DECSCNM. An unset one falls back to the Coder engine's effective default (foreground `0xcccccc`, background `0x000000`). An unset cursor stays unset, and unset palette entries and entries 16–255 come from `COLOR_PALETTE_DEFAULT`. @@ -139,6 +139,7 @@ The captures are re-recorded from a clean `HOME` before they are committed as fi - **Official binding in its own module** (e.g. `ghostty-vt.ts`) — smaller files, but splits wasm access across two modules, against ADR 041's one-binding rule. - **Engine chosen in config** (`GET /api/config`) — needs a config key that step 5 deletes, and a server change to try an engine. A URL parameter is per tab and needs neither. - **Answer size queries in `kitty.ts` on both engines** and leave `OPT_SIZE` unset — then mode 2048 and `19 t` stay unanswered, and step 5 could not delete the `kitty.ts` replies. +- **Move Ghostty's viewport to the view on every scroll step** (`scroll_viewport` `ROW` S − k, the first binding) — the primary state then redecodes all `rows` rows per step although one is new, and writes and resizes while scrolled must move the viewport back first. Scrolled frames were about 10% slower than Coder's, against about 26% faster now (see Scrollback above). - **Read rows through `grid_ref`**, every row or only those outside the viewport — the header marks `ghostty_terminal_grid_ref` as not for performance-sensitive use, and it works one cell at a time. The render state's dirty rows and `CELLS_RAW` are the bulk path that the spike's 0.17 ms full frame and 0.005 ms typing frame were measured on, and page reads reuse it for selection copy and the buffer API. - **Selection text from Ghostty's formatter** (`ghostty_formatter_format` with a selection) — one call per copy, but selection and buffer code would change to use it, and they read cells, not text. Page reads keep them as they are. diff --git a/src/client/vendor/engine-parity.test.ts b/src/client/vendor/engine-parity.test.ts index f8d36b3..0f577ca 100644 --- a/src/client/vendor/engine-parity.test.ts +++ b/src/client/vendor/engine-parity.test.ts @@ -330,6 +330,69 @@ describe('engine parity on scrolled views', () => { official.free(); }); + // Graphemes in scrollback and on screen: VS16 emoji, a skin-tone modifier and a + // combining mark, so the scrolled rows' grapheme lookups are compared too. + const graphemeLines = (from: number, to: number) => { + let s = ''; + for (let i = from; i <= to; i++) { + s += + i % 3 === 0 + ? `\x1b[38;2;200;120;80m⏺️\x1b[0m ${i} café 👍🏽 ✔︎\r\n` + : `\x1b[48;5;${i % 256}mrow ${i}\x1b[0m ${'─'.repeat(i % 40)}\r\n`; + } + return s; + }; + + test('a scroll walk past the screen and back matches rows, graphemes and cursor', async () => { + const { coder, official } = await engines(80, 24, config); + for (const t of [coder, official]) t.write(`${graphemeLines(1, 200)}\x1b[3;7H`); + const sb = coder.getScrollbackLength(); + expect(official.getScrollbackLength()).toBe(sb); + const ks = [ + ...Array.from({ length: 60 }, (_, k) => k), + sb - 1, + sb, + sb + 5, + ...Array.from({ length: 60 }, (_, k) => 59 - k), + ]; + for (const k of ks) { + expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); + expect(official.getCursor()).toMatchObject({ + x: coder.getCursor().x, + y: coder.getCursor().y, + }); + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + } + official.free(); + }); + + test('output while scrolled shows the new rows at the bottom and in later scrolls', async () => { + const { coder, official } = await engines(80, 24, config); + for (const t of [coder, official]) t.write(graphemeLines(1, 120)); + for (const k of [5, 30]) shownRows(official, k); + for (const t of [coder, official]) t.write(graphemeLines(121, 130)); + expect(official.needsFullRedraw()).toBe(true); + expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); + for (const k of [0, 3, 24, 30, 0]) { + expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); + } + official.free(); + }); + + test('a resize while scrolled keeps the walk matching', async () => { + const { coder, official } = await engines(80, 24, config); + for (const t of [coder, official]) t.write(graphemeLines(1, 150)); + for (const k of [10, 40]) shownRows(official, k); + coder.resize(70, 20); + official.resize(70, 20); + expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); + for (const k of [40, 39, 20, 19, 1, 0, 25]) { + expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); + expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); + } + official.free(); + }); + test('a page read after an in-place write does not return stale cells', async () => { const { coder, official } = await engines(80, 24, config); coder.write(lines(1, 100)); diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index a8cc75c..71b1392 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -1573,7 +1573,7 @@ interface DecodedStyle { } const I32 = WasmValType.I32; -/** At most this many pages of rows outside the viewport stay cached per content generation. */ +/** At most this many pages of scrollback rows stay cached per generation. */ const PAGE_CACHE_SIZE = 16; /** `scrollback: 0` (unlimited on the Coder wasm) becomes this byte ceiling. */ const UNLIMITED_SCROLLBACK_BYTES = 32 * 1024 * 1024; @@ -1634,12 +1634,13 @@ function isKittyKeyboardReply(reply: Uint8Array): boolean { * * Rows are read through the render state: a refresh runs `render_state_update`, * visits the dirty rows with `row_iterator_next_dirty`, and decodes each row's - * `CELLS_RAW` into JS-owned cells indexed by viewport row. Refreshes happen only - * when the generation (bumped by writes, resizes, pixel-size changes and viewport - * moves) has changed, so the many `update()` calls of one frame decode each row - * once. Rows outside the viewport are read a page at a time through a second - * render state and cached until the content changes (a write, resize or - * pixel-size change), so scrolling reuses them. + * `CELLS_RAW` into JS-owned cells indexed by active row. Refreshes happen only + * when the generation (bumped by writes, resizes and pixel-size changes) has + * changed, so the many `update()` calls of one frame decode each row once. + * Ghostty's viewport stays at the bottom, so the primary copy is the active + * screen. Scrollback rows are read a page at a time through a second render + * state and cached until the content changes, so scrolling only records the + * offset and serves rows from the copy and the cache. */ export class OfficialTerminal implements GhosttyTerminal { readonly answersSizeQueries = true; @@ -1691,17 +1692,16 @@ export class OfficialTerminal implements GhosttyTerminal { private cellWidthPx = 0; private cellHeightPx = 0; - // Generation and refresh state. `contentGen` is bumped with `generation` by - // everything except a viewport move, which changes what is shown, not the rows. + // Generation and refresh state. The generation is bumped by every content change + // (scrolling is not one) and also keys the page cache. private generation = 1; - private contentGen = 1; private refreshedGen = 0; private infoGen = 0; private decodeAll = true; private readonly dirtyRows = new Set(); private dirtyMax: DirtyState = DirtyState.NONE; - // The JS copy of the viewport, indexed by viewport row. + // The JS copy of the active screen, indexed by active row. private viewCells: GhosttyCell[] = []; private viewGraphemes: (Map | null)[] = []; private raw = new Uint32Array(0); @@ -1722,11 +1722,11 @@ export class OfficialTerminal implements GhosttyTerminal { private cursorColor: number | null = null; private readonly palette = new Uint8Array(256 * 3); - // Viewport position: `offset` lines scrolled up, last moved at `movedSb` scrollback rows. + // The view's offset: lines scrolled up from the bottom. Ghostty's viewport stays at + // the bottom; only page reads move it, and they move it back. private offset = 0; - private movedSb = 0; - // Rows outside the viewport, keyed by the absolute row their page starts at. + // Scrollback rows, keyed by the absolute row their page starts at. private readonly pages = new Map(); private pageGen = 0; @@ -1824,11 +1824,10 @@ export class OfficialTerminal implements GhosttyTerminal { write(data: string | Uint8Array): void { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; // Writes never leave the view scrolled (terminal.ts scrolls to the bottom after - // writing), so prune and screen switches happen with the viewport at the bottom. + // writing); the first frame at the bottom repaints every row. if (this.offset !== 0) { - this.scrollViewport(this.L.scroll.BOTTOM, 0); this.offset = 0; - this.decodeAll = true; + this.dirtyMax = DirtyState.FULL; } if (bytes.length > 0) { const ex = this.ex; @@ -1839,7 +1838,6 @@ export class OfficialTerminal implements GhosttyTerminal { ex.ghostty_wasm_free(ptr, bytes.length); } this.generation++; - this.contentGen++; if (this.titleChanged) this.readTitle(); } @@ -1866,19 +1864,10 @@ export class OfficialTerminal implements GhosttyTerminal { ); if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); this.generation++; - this.contentGen++; this.decodeAll = true; this.allocView(); - if (this.offset > 0) { - // Reflow moves Ghostty's viewport with the rows it shows; put it back k rows - // above the bottom so the copy's top stays at S - k. - this.info(); - this.offset = this.altScreen ? 0 : Math.min(this.offset, this.sbRows); - this.movedSb = this.sbRows; - if (this.offset === 0) this.scrollViewport(this.L.scroll.BOTTOM, 0); - else this.scrollViewport(this.L.scroll.ROW, this.sbRows - this.offset); - this.generation++; - } + // Reflow keeps Ghostty's viewport at the bottom; only the offset is clamped. + if (this.offset > 0) this.setViewportOffset(this.offset); } setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { @@ -1890,21 +1879,22 @@ export class OfficialTerminal implements GhosttyTerminal { const rc = this.ex.ghostty_terminal_resize(this.term, this._cols, this._rows, w, h); if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); this.generation++; - this.contentGen++; } + /** + * Records the view's offset. Ghostty's viewport is not moved and nothing is + * decoded: rows above the active screen come from the page cache, and the active + * rows from the primary copy, so a scroll step costs no export call on a page hit. + */ setViewportOffset(linesFromBottom: number): void { this.info(); - const sb = this.sbRows; - const k = this.altScreen ? 0 : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, sb)); - if (k === this.offset && (k === 0 || sb === this.movedSb)) return; - if (k === 0) this.scrollViewport(this.L.scroll.BOTTOM, 0); - // ROW counts from the top of scrollback, viewportY from the bottom. - else this.scrollViewport(this.L.scroll.ROW, sb - k); + const k = this.altScreen + ? 0 + : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, this.sbRows)); + if (k === this.offset) return; + // Back at the bottom, the next frame repaints every row (no row is redecoded). + if (k === 0) this.dirtyMax = DirtyState.FULL; this.offset = k; - this.movedSb = sb; - this.decodeAll = true; - this.generation++; } free(): void { @@ -1968,14 +1958,8 @@ export class OfficialTerminal implements GhosttyTerminal { getViewport(): GhosttyCell[] { this.refresh(); - if (this.offset === 0) return this.viewCells; - // Scrolled: the active screen is partly below the viewport. - const out: GhosttyCell[] = []; - for (let y = 0; y < this._rows; y++) { - const ref = this.rowAt(this.sbRows + y); - for (let x = 0; x < this._cols; x++) out.push(ref.cells[ref.base + x]); - } - return out; + // The active screen, scrolled or not. + return this.viewCells; } getLine(y: number): GhosttyCell[] | null { @@ -2046,7 +2030,9 @@ export class OfficialTerminal implements GhosttyTerminal { getScrollbackLine(offset: number): GhosttyCell[] | null { this.refresh(); if (offset < 0 || offset >= this.sbRows) return null; - return this.copyRow(this.rowAt(offset)); + // A page's cells are never written after the page is read, so the row's cell + // objects are shared, not copied; only the array is new. + return this.readPage(offset).cells.slice(); } isRowWrapped(row: number): boolean { @@ -2352,9 +2338,9 @@ export class OfficialTerminal implements GhosttyTerminal { } /** - * Brings the viewport copy up to date with the terminal, once per generation. Each - * refresh decodes only the rows Ghostty reports dirty (every row after a viewport - * move or resize), adds them to the dirty set, and ends with `render_state_clean`, + * Brings the active-screen copy up to date with the terminal, once per generation. Each + * refresh decodes only the rows Ghostty reports dirty (every row after a resize or + * colour change), adds them to the dirty set, and ends with `render_state_clean`, * which clears Ghostty's global and per-row dirty state. The JS dirty state * accumulates until `markClean()`. */ @@ -2639,36 +2625,36 @@ export class OfficialTerminal implements GhosttyTerminal { /** A row by absolute index; call `refresh()` first. */ private rowAt(abs: number): RowRef { - const top = this.sbRows - this.offset; - if (abs >= top && abs < top + this._rows) { - const y = abs - top; + if (abs >= this.sbRows) { + const y = abs - this.sbRows; return { cells: this.viewCells, base: y * this._cols, graphemes: this.viewGraphemes[y] }; } return this.readPage(abs); } /** - * Reads the page of `rows` rows holding `abs` through a second render state: - * moves Ghostty's viewport there, reads every row with CELLS_RAW, and moves it - * back. The primary state has already been refreshed at this generation (rowAt's - * callers refresh first), so it has captured every pending dirty bit before the - * second state's update consumes the terminal's dirty state. The second state - * cannot trust dirty bits the primary has cleared, so it is recreated whenever the - * content has changed, and every miss rebuilds every row it reads. A viewport move - * alone changes no row, so the state and the cached pages are kept across one. + * Reads the page of `rows` rows holding scrollback row `abs` through a second + * render state: moves Ghostty's viewport there, reads every row with CELLS_RAW, + * and moves it back to the bottom. The primary state has already been refreshed + * at this generation (callers refresh first), so it has captured every pending + * dirty bit before the second state's update consumes the terminal's dirty state. + * The second state cannot trust dirty bits the primary has cleared, so it is + * recreated whenever the content has changed, and every miss rebuilds every row + * it reads. Scrolling changes no row, so the state and the pages are kept. */ private readPage(abs: number): RowRef { const rows = this._rows; - const sb = this.sbRows; - // scroll_viewport ROW n with n >= S snaps to the active screen. - const start = Math.min(Math.floor(abs / rows) * rows, sb); - if (this.pageGen !== this.contentGen) { + // abs < S, so the page starts in scrollback: scroll_viewport ROW n snaps to the + // active screen only for n >= S. A page may run on into active rows; rowAt + // serves those from the primary copy. + const start = Math.floor(abs / rows) * rows; + if (this.pageGen !== this.generation) { if (this.pageRs) this.ex.ghostty_render_state_free(this.pageRs); this.pageRs = this.vt.newHandle('render_state_new', (slot) => this.ex.ghostty_render_state_new(0, slot) ); this.pages.clear(); - this.pageGen = this.contentGen; + this.pageGen = this.generation; } let page = this.pages.get(start); if (page) { @@ -2697,8 +2683,7 @@ export class OfficialTerminal implements GhosttyTerminal { page[y] = { cells, base: 0, graphemes: this.decodeRow(cells, 0, len) }; } } finally { - if (this.offset === 0) this.scrollViewport(L.scroll.BOTTOM, 0); - else this.scrollViewport(L.scroll.ROW, sb - this.offset); + this.scrollViewport(L.scroll.BOTTOM, 0); } for (let y = 0; y < rows; y++) { if (!page[y]) { diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index dd3054a..49889d7 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -349,6 +349,95 @@ describe('official engine: scrollback, resize and the viewport', () => { t.free(); }); + test('a scroll step decodes nothing and moves no viewport once its page is cached', async () => { + const wasm = await WebAssembly.instantiate(await Bun.file(OFFICIAL_WASM).arrayBuffer(), {}); + const real = wasm.instance.exports as Record number>; + const calls = { update: 0, rows: 0, scroll: 0 }; + const counted = + (name: string, key: keyof typeof calls) => + (...args: number[]) => { + calls[key]++; + return real[name](...args); + }; + const spy = { + ...real, + ghostty_render_state_update: counted('ghostty_render_state_update', 'update'), + ghostty_render_state_row_get_multi: counted('ghostty_render_state_row_get_multi', 'rows'), + ghostty_terminal_scroll_viewport: counted('ghostty_terminal_scroll_viewport', 'scroll'), + }; + const engine = new OfficialGhostty( + spy as unknown as ConstructorParameters[0], + ); + const t = engine.createTerminal(20, 5, { scrollbackLimit: 0 }); + t.write(lines(50)); + const sb = t.getScrollbackLength(); + expect(sb).toBe(45); + // One frame as the renderer reads it at viewportY = k. + const frame = (k: number) => { + t.setViewportOffset(k); + t.getCursor(); + t.needsFullRedraw(); + const view = t.getViewport(); + const shown: string[] = []; + for (let y = 0; y < t.rows; y++) { + if (y < k) { + const line = t.getScrollbackLine(sb - k + y) ?? []; + for (let x = 0; x < line.length; x++) t.getScrollbackGraphemeString(sb - k + y, x); + shown.push( + line + .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) + .join('') + .trimEnd(), + ); + } else { + const row = view.slice((y - k) * t.cols, (y - k + 1) * t.cols); + shown.push( + row + .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) + .join('') + .trimEnd(), + ); + } + t.isRowDirty(y); + } + t.markClean(); + return shown; + }; + const expected = (k: number) => + Array.from({ length: 5 }, (_, y) => `line ${50 - 5 - k + y + 1}`); + frame(0); + // The first walk reads each scrollback page once: 45 rows in pages of 5. + const first = { ...calls }; + for (let k = 1; k <= sb; k++) expect(frame(k)).toEqual(expected(k)); + expect(calls.update - first.update).toBe(9); + expect(calls.rows - first.rows).toBe(9 * 5); + // Each page read moves the viewport there and back; nothing else moves it. + expect(calls.scroll - first.scroll).toBe(9 * 2); + // Every page is cached now: a walk back down and up again costs no export call. + const warm = { ...calls }; + for (let k = sb; k >= 0; k--) expect(frame(k)).toEqual(k ? expected(k) : expected(0)); + for (let k = 0; k <= sb + 10; k++) frame(k); + expect(calls).toEqual(warm); + // Output while scrolled: one refresh of the active rows, no viewport move, and + // the first frame at the bottom repaints every row. + t.setViewportOffset(12); + t.write('\r\nline 51'); + expect(t.needsFullRedraw()).toBe(true); + expect(calls.update - warm.update).toBe(1); + expect(calls.scroll).toBe(warm.scroll); + expect(rowText(t, 4)).toBe('line 51'); + t.markClean(); + // Resize while scrolled: no viewport move, and the reflowed rows are read anew. + t.setViewportOffset(12); + const beforeResize = calls.scroll; + t.resize(30, 4); + expect(calls.scroll).toBe(beforeResize); + expect(t.getScrollbackLength()).toBe(47); + expect(scrollbackText(t, 46)).toBe('line 47'); + expect(rowText(t, 3)).toBe('line 51'); + t.free(); + }); + test('reflows wrapped lines on resize', () => { const t = term(20, 5); t.write('abcdefghijklmnopqrstuvwxyz0123'); From f95fb58443156bbdaf7f111114e108657601cba0 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 05:31:49 -0400 Subject: [PATCH 018/295] docs(client): ADR 043, copy rows on scroll, then overscan The renderer redrew every row on each viewport move and on every frame while scrolled up. Step 1 copies the rows still in view with one drawImage of the canvas onto itself and draws only the rows that came into view, leaving the canvas exactly as a full redraw would. Step 2 (later) overscans so pixel-smooth scroll can translate the canvas across several lines, replacing ADR 040's strip. --- .../043.client.scroll-copy-and-overscan.md | 92 +++++++++++++++++++ docs/specs/client.md | 3 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/adrs/043.client.scroll-copy-and-overscan.md diff --git a/docs/adrs/043.client.scroll-copy-and-overscan.md b/docs/adrs/043.client.scroll-copy-and-overscan.md new file mode 100644 index 0000000..ced5e65 --- /dev/null +++ b/docs/adrs/043.client.scroll-copy-and-overscan.md @@ -0,0 +1,92 @@ +# ADR 043: client — Copy rows on scroll, then overscan + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-13 + +--- + +## Context + +The renderer (`src/client/vendor/ghostty-web/renderer.ts`) drew every row on any frame where `viewportY` changed, and on every frame while scrolled up, even when nothing changed. A one-line scroll step on a 175×64 grid draws about 11,000 glyphs, and a terminal left scrolled up in its history draws them 60 times a second. Both engines (ADR 042) go through the same `render()`, so both pay this. + +Pixel-smooth scroll (ADR 040) moves the canvas by a CSS offset between lines, so only whole-line moves reach the renderer. Each one still costs a full redraw, and at most one line can be offset before the canvas has to be redrawn. + +## Decision + +Two steps, both in the renderer and for both engines. + +**Step 1: copy the rows still in view.** When the viewport moves by whole lines, by less than a screen, and nothing else changed, `render()` moves the canvas content with one `drawImage` of the canvas onto itself, in device pixels, with identity transform and the `copy` operator. Then it draws only the rows that came into view. `copy` replaces pixels instead of blending them, so a translucent background does not darken. It also clears everything outside the copied area, which is exactly the rows that get redrawn. A frame with nothing changed draws nothing, including while scrolled up. + +A partial frame must leave the canvas exactly as a full redraw would. A full redraw draws the rows in order, and each row clears its own cell area first. So a row's pixels are its own cells plus whatever the next row draws over it (tall glyphs, combining marks). Anything the row above drew into it is cleared. To match this, `repaintRows` repaints a run of rows by drawing them in order, plus the row below the run, clipped to the run. Repainting follows three rules: + +- When a row's content or highlight changes, that row and the row above it are repainted. This covers engine-dirty rows, selection, link hover, and rows that came into view. +- When rows come into view at the top, the last row is repainted too, because the row below it, whose glyphs overflowed into it, scrolled out. +- When only an overlay changes, just its own row is repainted. The cursor is the only such overlay: the row the last frame drew it in, after the copy moved it, unless the same cursor is drawn there again. + +This replaces the old rules: every dirty row plus both neighbours unclipped, and a separate cursor-line redraw on every frame while the cursor blinks. + +Copying is skipped, and every row is drawn, whenever any of the following holds: + +- `forceAll` is set, the canvas was resized, or the engine reports a full redraw. +- The theme, font, metrics or device pixel ratio changed since the last frame (`canvasStale`). +- The viewport moved by a fraction of a line, or by a screen or more. +- The scrollback length changed, or the engine reports any dirty row. While scrolled, any dirty row also forces a full redraw on a frame with no move. +- Selection rows were marked dirty. They are viewport rows from when they were marked. +- The last frame drew the scrollbar and this one does not, so no row keeps the old thumb. This also fixes text under the scrollbar staying hidden after the fade-out. +- The last frame was drawn without a scrollback provider while scrolled, which leaves its top rows blank. The visibility repaint in `index.ts` does this. + +Things that move with the copied rows need no extra handling: the selection (anchored to buffer lines), OSC 8 hover underlines (keyed by hyperlink id) and the edge strips (ADR 035), which copy the canvas' edge pixels. The regex link underline is drawn at fixed canvas rows, so on a copied frame it is cleared where the copy moved it and drawn where it is. + +The official engine reported every row dirty while scrolled, and a full redraw on returning to the bottom, so the old renderer would repaint. It now reports the real dirty state. Output still marks a full redraw, because it snaps the view to the bottom. + +Screen scrolling caused by output is not copied. The engines report which rows changed, not that the screen scrolled, and detecting a scroll by comparing rows (including graphemes and hyperlinks) is not cheap or safe enough for this step. + +**Step 2: overscan (later).** Draw extra rows above and below the viewport into a taller canvas. Pixel-smooth scroll can then translate the canvas by CSS across several lines, and redraw (by step 1's copy) only when the overscan runs out. This replaces ADR 040's one-row strip canvas and its `renderLine` context swap. It is ADR 041 step 6: on the official engine the extra rows come from the render state's `OVERSCAN`, on the Coder engine from the scrollback provider. Step 2 must keep step 1's rules: a moved frame equals a full redraw of the taller canvas, and the bottom edge strip and the cursor stay on the visible rows. When it ships, ADR 040 gets a "Superseded in part by" note. + +## Considered Options + +- **Offscreen double buffer.** Copy to a second canvas and back. That is two copies and twice the backing store. Drawing the canvas onto itself works in Chrome: the browser check below found no difference from a full redraw. +- **Unclipped neighbour rows (the old dirty-row rule).** Drawing a row again over an unchanged neighbour draws its overflow twice, which differs from a full redraw on anti-aliased edges. + +## Measurements + +Apple M3 Pro. Bun 1.3.11 with a recording fake canvas (`scrollbench.ts`: 200×60, DPR 2, 3,000 lines, one-line steps, 600 frames, best of 3): + +| Per scrolled frame | Before | After | +|---|---|---| +| Official engine | 0.698 ms | 0.039 ms | +| Coder engine | 0.928 ms | 0.059 ms | +| Three-line steps, official / Coder | 0.75 / 0.71 ms | 0.08 / 0.06 ms | +| Parked while scrolled, official / Coder | 0.71 / 0.99 ms | 0.001 / 0.001 ms | + +Headless Chrome 155 (ANGLE Metal), 1400×900 at DPR 2 (175×64 cells), 3,000 lines of mixed output (colours, emoji with VS16, combining marks, OSC 8 links, faint box lines). Times are the script time of all `requestAnimationFrame` callbacks in one frame, including the edge strips' copy of the canvas: + +| | Before (official / Coder) | After (official / Coder) | +|---|---|---| +| One-line wheel step per frame, frame mean | 8.29 / 8.76 ms | 0.11 / 0.13 ms | +| Frames that moved the viewport, mean | 8.29 / 8.76 ms | 0.50 / 0.63 ms | +| Trackpad-style pixel deltas, frame mean | 8.20 / 8.69 ms | 0.11 / 0.12 ms | +| Trackpad, p95 / max | 9.0 / 9.5 ms (official) | 0.5 / 0.8 ms (official) | +| Parked while scrolled, per frame | 8.26 / 8.64 ms (about 11,000 `fillText`) | 0.01 / 0.03 ms (none) | +| At the bottom, idle with a blinking cursor | 0.17 / 0.31 ms (175 `fillText`) | 0.04 / 0.04 ms (none) | + +In the browser, the canvas after whole-line steps (1 to 5 lines, up and down, back to the bottom) was compared with a full redraw of the same position by `getImageData`. It was identical for both engines at DPR 1, 1.5 and 2. Reading a canvas back makes Chrome draw it on the CPU from then on, and pixels drawn before and after that switch differ by up to 2 levels. So each check first reads the canvas back, then redraws it in full, then compares. + +Unit tests (`renderer.test.ts`) draw into a rasterising fake canvas where glyphs overflow into the rows above and below. They check that each step draws at most the new rows plus four, and that the result equals a fresh full redraw at DPR 1 and 1.5. A third test does the same at DPR 2 with a selection, a hovered link range and the cursor. + +## Consequences + +- Scrolling through scrollback draws only the rows that came into view. A terminal parked in scrollback draws nothing. +- The canvas is the same as a full redraw after every partial frame, including glyph overflow from the row below. An exception is a glyph that reaches more than one row up. +- A blinking cursor no longer redraws its row on every frame, only when it toggles. +- A frame that drops the scrollbar repaints every row. That is once per scrollbar fade-out, and once after a resize or font change, which now pass the current scrollbar opacity instead of drawing it at full opacity. +- Output that scrolls the screen still redraws every dirty row, which is every row. +- Self-copy `drawImage` and the `copy` operator were checked in Chrome only. Firefox and Safari need the same check before step 2 ships. + +## Related Decisions + +- [ADR 035 — Edge extension](035.client.edge-extend.md) +- [ADR 040 — Pixel-smooth scrollback scrolling](040.client.pixel-smooth-scroll.md) +- [ADR 041 — libghostty-vt](041.client.libghostty-vt.md) +- [ADR 042 — Official libghostty-vt engine](042.client.official-vt-engine.md) diff --git a/docs/specs/client.md b/docs/specs/client.md index b19629b..5380c27 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -197,7 +197,8 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | | Official engine by default | The default flips to `official` once ADR 042's acceptance matrix (Chrome, Firefox, Safari; resize/reflow; query round trips; #180; parity) is recorded; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | -| Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws the rows Ghostty's render state provides above and below the viewport, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | +| Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws extra rows above and below the viewport (Ghostty's render state `OVERSCAN` on the official engine) and translates the canvas across several lines before redrawing, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ⬜ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | From a2369bb67a6836cc94057bc6eea7c4d6f475a0aa Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 05:31:49 -0400 Subject: [PATCH 019/295] perf(client): copy the rows still in view when the viewport moves by whole lines A whole-line viewport move with nothing else changed now moves the canvas content with one drawImage of the canvas onto itself and draws only the rows that came into view. A partial repaint draws its rows, with the row below them, on a spare canvas and copies them in, because Chrome skips glyphs outside a clip even when their edge reaches inside; so a partial frame equals a full redraw even with glyphs that overflow into the row above. A frame with nothing changed draws nothing, including one parked in scrollback, and a blinking cursor repaints its row only when it toggles. The official engine reports its real dirty state while scrolled. Headless Chrome at 175x64, DPR 2: a frame that scrolls drops from about 7.5 ms to 0.5 ms (ADR 043). --- .../043.client.scroll-copy-and-overscan.md | 13 +- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 7 +- src/client/vendor/ghostty-web/renderer.ts | 380 ++++++++++++++---- src/client/vendor/ghostty-web/terminal.ts | 9 +- src/client/vendor/renderer.test.ts | 353 +++++++++++++++- 6 files changed, 661 insertions(+), 102 deletions(-) diff --git a/docs/adrs/043.client.scroll-copy-and-overscan.md b/docs/adrs/043.client.scroll-copy-and-overscan.md index ced5e65..e990a38 100644 --- a/docs/adrs/043.client.scroll-copy-and-overscan.md +++ b/docs/adrs/043.client.scroll-copy-and-overscan.md @@ -18,11 +18,11 @@ Two steps, both in the renderer and for both engines. **Step 1: copy the rows still in view.** When the viewport moves by whole lines, by less than a screen, and nothing else changed, `render()` moves the canvas content with one `drawImage` of the canvas onto itself, in device pixels, with identity transform and the `copy` operator. Then it draws only the rows that came into view. `copy` replaces pixels instead of blending them, so a translucent background does not darken. It also clears everything outside the copied area, which is exactly the rows that get redrawn. A frame with nothing changed draws nothing, including while scrolled up. -A partial frame must leave the canvas exactly as a full redraw would. A full redraw draws the rows in order, and each row clears its own cell area first. So a row's pixels are its own cells plus whatever the next row draws over it (tall glyphs, combining marks). Anything the row above drew into it is cleared. To match this, `repaintRows` repaints a run of rows by drawing them in order, plus the row below the run, clipped to the run. Repainting follows three rules: +A partial frame must leave the canvas exactly as a full redraw would. A full redraw draws the rows in order, and each row clears its own cell area first. So a row's pixels are its own cells plus whatever the next row draws over it (tall glyphs, combining marks). Anything the row above drew into it is cleared. To match this, `repaintRows` repaints a run of rows by drawing them in order, plus the row below the run, on a scratch canvas the size of the canvas, unclipped. It then copies the run's rows into the canvas in device pixels, clearing them first so a translucent background is replaced, not blended. A run of every row is drawn on the canvas directly. Repainting follows three rules: - When a row's content or highlight changes, that row and the row above it are repainted. This covers engine-dirty rows, selection, link hover, and rows that came into view. - When rows come into view at the top, the last row is repainted too, because the row below it, whose glyphs overflowed into it, scrolled out. -- When only an overlay changes, just its own row is repainted. The cursor is the only such overlay: the row the last frame drew it in, after the copy moved it, unless the same cursor is drawn there again. +- When only an overlay changes, just its own row is repainted. The cursor is the only such overlay: the row the last frame drew it in, after the copy moved it, unless the same cursor is drawn there again. The same cursor over a row that was not repainted is not drawn again either: it is still there, and a translucent cursor colour drawn over itself every frame builds up to opaque. This replaces the old rules: every dirty row plus both neighbours unclipped, and a separate cursor-line redraw on every frame while the cursor blinks. @@ -35,6 +35,7 @@ Copying is skipped, and every row is drawn, whenever any of the following holds: - Selection rows were marked dirty. They are viewport rows from when they were marked. - The last frame drew the scrollbar and this one does not, so no row keeps the old thumb. This also fixes text under the scrollbar staying hidden after the fade-out. - The last frame was drawn without a scrollback provider while scrolled, which leaves its top rows blank. The visibility repaint in `index.ts` does this. +- The browser restored a lost context (`contextrestored` on the canvas). The backing store is blank and the context state reset, so the canvas is resized too. A frame that draws nothing would otherwise leave it blank, and a copy would copy the blank rows. Things that move with the copied rows need no extra handling: the selection (anchored to buffer lines), OSC 8 hover underlines (keyed by hyperlink id) and the edge strips (ADR 035), which copy the canvas' edge pixels. The regex link underline is drawn at fixed canvas rows, so on a copied frame it is cleared where the copy moved it and drawn where it is. @@ -46,7 +47,8 @@ Screen scrolling caused by output is not copied. The engines report which rows c ## Considered Options -- **Offscreen double buffer.** Copy to a second canvas and back. That is two copies and twice the backing store. Drawing the canvas onto itself works in Chrome: the browser check below found no difference from a full redraw. +- **Offscreen double buffer.** Copy to a second canvas and back. That is two copies of the whole canvas per scroll; the scratch canvas only ever copies the repainted rows. Drawing the canvas onto itself works in Chrome: the browser check below found no difference from a full redraw. +- **Drawing the run on the canvas, clipped to it.** Chrome skips a glyph whose box lies outside the clip, even where its antialiased edge reaches inside. A combining mark of the row below whose top sits just under the run (the diaeresis of `ạ̈`, the stack of `ặ`) then loses the faint edge it draws on the run's last device-pixel line: up to 21 levels on about 10 pixels per row. Copied frames carry those pixels along until the next full redraw. Clip edges through the middle of a row avoid it in practice, but not for every glyph height. - **Unclipped neighbour rows (the old dirty-row rule).** Drawing a row again over an unchanged neighbour draws its overflow twice, which differs from a full redraw on anti-aliased edges. ## Measurements @@ -73,12 +75,13 @@ Headless Chrome 155 (ANGLE Metal), 1400×900 at DPR 2 (175×64 cells), 3,000 lin In the browser, the canvas after whole-line steps (1 to 5 lines, up and down, back to the bottom) was compared with a full redraw of the same position by `getImageData`. It was identical for both engines at DPR 1, 1.5 and 2. Reading a canvas back makes Chrome draw it on the CPU from then on, and pixels drawn before and after that switch differ by up to 2 levels. So each check first reads the canvas back, then redraws it in full, then compares. -Unit tests (`renderer.test.ts`) draw into a rasterising fake canvas where glyphs overflow into the rows above and below. They check that each step draws at most the new rows plus four, and that the result equals a fresh full redraw at DPR 1 and 1.5. A third test does the same at DPR 2 with a selection, a hovered link range and the cursor. +Unit tests (`renderer.test.ts`) draw into a rasterising fake canvas where glyphs overflow into the rows above and below, and, like Chrome, a glyph whose cell is outside the clip is not drawn. They check that each step draws at most the new rows plus four, and that the result equals a fresh full redraw at DPR 1 and 1.5. A third test does the same at DPR 2 with a selection, a hovered link range and the cursor. ## Consequences - Scrolling through scrollback draws only the rows that came into view. A terminal parked in scrollback draws nothing. -- The canvas is the same as a full redraw after every partial frame, including glyph overflow from the row below. An exception is a glyph that reaches more than one row up. +- The canvas is the same as a full redraw after every partial frame, including glyph overflow from the row below. An exception is a glyph that reaches more than one row up. A later browser check never read the canvas back: after each of 39 states on the Coder engine and 45 on the official one (whole-line and trackpad scrolling, selection, cursor shapes, typing, output while scrolled, clear, alt screen, resize), with emoji, ZWJ sequences, flags, stacked combining marks, Devanagari and wide characters, it compared a screenshot with a screenshot after a forced full redraw. All were identical. The first version drew runs clipped on the canvas and failed this check (see Considered Options). +- The scratch canvas is as large as the canvas: about 20 MB more at 175×64 cells, DPR 2. Each partial repaint adds one copy of the repainted rows. - A blinking cursor no longer redraws its row on every frame, only when it toggles. - A frame that drops the scrollbar repaints every row. That is once per scrollbar fade-out, and once after a resize or font change, which now pass the current scrollbar opacity instead of drawing it at full opacity. - Output that scrolls the screen still redraws every dirty row, which is every row. diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 50c27db..2eaeeaf 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -23,4 +23,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. The official engine drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. - `terminal.ts`, `renderer.ts`: the client runs either engine (ADR 042). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; on the official engine bell and title come from `readEffects` instead of the stream scan; `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours, and `buildWasmConfig` fills `colorsSet` from it; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder engine). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. +- `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The official engine reports its real dirty state while scrolled. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 71b1392..e0dd468 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -1892,8 +1892,7 @@ export class OfficialTerminal implements GhosttyTerminal { ? 0 : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, this.sbRows)); if (k === this.offset) return; - // Back at the bottom, the next frame repaints every row (no row is redecoded). - if (k === 0) this.dirtyMax = DirtyState.FULL; + // Scrolling changes no row: the renderer repaints what the move needs (ADR 043). this.offset = k; } @@ -1919,7 +1918,7 @@ export class OfficialTerminal implements GhosttyTerminal { update(): DirtyState { this.refresh(); - return this.offset > 0 ? DirtyState.FULL : this.dirtyMax; + return this.dirtyMax; } getCursor(): RenderStateCursor { @@ -1946,8 +1945,6 @@ export class OfficialTerminal implements GhosttyTerminal { isRowDirty(y: number): boolean { this.refresh(); - // The renderer repaints every row while scrolled anyway. - if (this.offset > 0) return true; return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); } diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 7256106..3aef10d 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -179,13 +179,31 @@ export class CanvasRenderer { // Cursor blinking state private cursorVisible: boolean = true; private cursorBlinkInterval?: number; - private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 }; + // Where the last frame drew the cursor: its canvas row (-1: not drawn) and column (webtty) + private cursorRow: number = -1; + private cursorCol: number = 0; // Cursor style or blink changed since the last frame: repaint the cursor cell so // the old shape does not stay under the new one (webtty) private cursorShapeChanged: boolean = false; // Viewport tracking (for scrolling) private lastViewportY: number = 0; + private lastScrollbackLength: number = 0; + + // The canvas no longer matches its rows (theme, font or ratio change); the next frame + // repaints every row (webtty, ADR 043) + private canvasStale: boolean = false; + // The last frame painted the scrollbar over the right edge of the rows (webtty, ADR 043) + private scrollbarPainted: boolean = false; + // The browser restored a lost context: the backing store is blank and the context + // state reset, so the next frame resizes and repaints every row (webtty, ADR 043) + private contextRestored: boolean = false; + private readonly onContextRestored = () => { + this.contextRestored = true; + }; + // A partial repaint draws its rows here, unclipped, then copies them into the canvas + // (webtty, ADR 043; see repaintRows) + private readonly scratch: HTMLCanvasElement; // Current buffer being rendered (for grapheme lookups) private currentBuffer: IRenderable | null = null; @@ -223,6 +241,12 @@ export class CanvasRenderer { throw new Error('Failed to get 2D rendering context'); } this.ctx = ctx; + // Frames only draw what changed, so a canvas the browser blanked would stay blank + // (webtty, ADR 043) + canvas.addEventListener('contextrestored', this.onContextRestored); + this.scratch = document.createElement('canvas'); + this.scratch.width = 0; + this.scratch.height = 0; // Apply options this.fontSize = options.fontSize ?? 15; @@ -238,6 +262,7 @@ export class CanvasRenderer { if (options.devicePixelRatio === undefined) { this.stopWatchingDevicePixelRatio = watchDevicePixelRatio(window, (ratio) => { this._devicePixelRatio = wholeDevicePixelRatio(ratio); + this.canvasStale = true; }); } @@ -308,6 +333,7 @@ export class CanvasRenderer { */ public remeasureFont(): void { this.metrics = this.measureFont(); + this.canvasStale = true; } // ========================================================================== @@ -347,6 +373,7 @@ export class CanvasRenderer { // Fill background after resize this.ctx.fillStyle = this.theme.background; this.ctx.fillRect(0, 0, cssWidth, cssHeight); + this.canvasStale = true; } // ========================================================================== @@ -355,6 +382,12 @@ export class CanvasRenderer { /** * Render the terminal buffer to canvas + * + * Only the canvas rows that changed are repainted. When the viewport moves by whole + * lines and nothing else changed, the rows still in view are copied to their new place + * with one drawImage of the canvas onto itself and only the rows that came into view + * are drawn. A partial frame leaves the canvas exactly as a full redraw would (webtty, + * ADR 043; see repaintRows). */ public render( buffer: IRenderable, @@ -384,43 +417,27 @@ export class CanvasRenderer { // Resize canvas if dimensions changed const needsResize = + this.contextRestored || this.canvas.width !== Math.round(dims.cols * this.metrics.width * this.devicePixelRatio) || this.canvas.height !== Math.round(dims.rows * this.metrics.height * this.devicePixelRatio); if (needsResize) { this.resize(dims.cols, dims.rows); + this.contextRestored = false; forceAll = true; // Force full render after resize } - // Force re-render when viewport changes (scrolling) - if (viewportY !== this.lastViewportY) { + // A theme, font or ratio change since the last frame changes every row (webtty, ADR 043) + if (this.canvasStale) { forceAll = true; - this.lastViewportY = viewportY; + this.canvasStale = false; } - // Check if cursor position changed, blinking or changed shape (need to redraw cursor line) - const cursorMoved = - cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y; - const cursorShapeChanged = this.cursorShapeChanged && viewportY === 0; - this.cursorShapeChanged = false; - if (cursorMoved || this.cursorBlink || cursorShapeChanged) { - // Mark cursor lines as needing redraw - if (!forceAll && !buffer.isRowDirty(cursor.y)) { - // Need to redraw cursor line - const line = getLine(cursor.y); - if (line) { - this.renderLine(line, cursor.y, dims.cols); - } - } - if (cursorMoved && this.lastCursorPosition.y !== cursor.y) { - // Also redraw old cursor line if cursor moved to different line - if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) { - const line = getLine(this.lastCursorPosition.y); - if (line) { - this.renderLine(line, this.lastCursorPosition.y, dims.cols); - } - } - } + // The scrollbar is painted over the right edge of every row, so a frame that does not + // paint it repaints every row: none keeps the old thumb (webtty, ADR 043) + const paintsScrollbar = !!scrollbackProvider && scrollbarOpacity > 0; + if (this.scrollbarPainted && !paintsScrollbar) { + forceAll = true; } // Check if we need to redraw selection-related lines @@ -439,10 +456,14 @@ export class CanvasRenderer { } } - // Always mark dirty selection rows for redraw (to clear old overlay) + // Always mark dirty selection rows for redraw (to clear old overlay). They were + // marked at the viewport of their time, so a viewport move with any of them + // repaints every row (webtty, ADR 043) + let selectionDirty = false; if (this.selectionManager) { const dirtyRows = this.selectionManager.getDirtySelectionRows(); if (dirtyRows.size > 0) { + selectionDirty = true; for (const row of dirtyRows) { selectionRows.add(row); } @@ -456,6 +477,8 @@ export class CanvasRenderer { const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; const linkRangeChanged = JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange); + // The regex link underline the last frame drew (webtty, ADR 043) + const drawnLinkRange = this.previousHoveredLinkRange; if (hyperlinkChanged) { // Find rows containing the old or new hovered hyperlink @@ -517,68 +540,108 @@ export class CanvasRenderer { this.previousHoveredLinkRange = this.hoveredLinkRange; } - // Track if anything was actually rendered - let anyLinesRendered = false; - - // Determine which rows need rendering. - // We also include adjacent rows (above and below) for each dirty row to handle - // glyph overflow - tall glyphs like Devanagari vowel signs can extend into - // adjacent rows' visual space. - const rowsToRender = new Set(); - for (let y = 0; y < dims.rows; y++) { - // When scrolled, always force render all lines since we're showing scrollback - const needsRender = - viewportY > 0 - ? true - : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); - - if (needsRender) { - rowsToRender.add(y); - // Include adjacent rows to handle glyph overflow - if (y > 0) rowsToRender.add(y - 1); - if (y < dims.rows - 1) rowsToRender.add(y + 1); + // Rows the engine changed. They are screen rows, which are canvas rows only at the + // bottom; while scrolled, any change repaints every row. (webtty, ADR 043) + const dirtyRows: number[] = []; + if (!forceAll) { + for (let y = 0; y < dims.rows; y++) { + if (buffer.isRowDirty(y)) dirtyRows.push(y); } } - // Render each line - for (let y = 0; y < dims.rows; y++) { - if (!rowsToRender.has(y)) { - continue; + // Canvas rows the kept rows moved down by (up when negative) this frame + let shift = 0; + if (viewportY !== this.lastViewportY) { + // The viewport moved. When only the viewport moved, by whole lines and by less than + // a screen, copy the rows still in view to their new place; otherwise repaint + // every row as before. (webtty, ADR 043) + const moved = viewportY - this.lastViewportY; + const onlyViewportMoved = + !forceAll && + Number.isInteger(viewportY) && + Number.isInteger(this.lastViewportY) && + Math.abs(moved) < dims.rows && + scrollbackLength === this.lastScrollbackLength && + dirtyRows.length === 0 && + !selectionDirty; + if (onlyViewportMoved) { + this.shiftRows(moved); + shift = moved; + } else { + forceAll = true; } - - anyLinesRendered = true; - - // Fetch line from scrollback or visible screen. Graphemes are looked up in the - // same source: canvas row y is not screen row y while scrolled. (webtty) - let line: GhosttyCell[] | null = null; - let grapheme: GraphemeLookup | undefined; - if (viewportY > 0) { - // Scrolled up - need to fetch from scrollback + visible screen - // When scrolled up N lines, we want to show: - // - Scrollback lines (from the end) + visible screen lines - - // Check if this row should come from scrollback or visible screen - if (y < viewportY && scrollbackProvider) { - // This row is from scrollback (upper part of viewport) - // Get from end of scrollback buffer - // Floor viewportY for array access (handles fractional values during smooth scroll) - const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; - line = scrollbackProvider.getScrollbackLine(scrollbackOffset); - grapheme = this.scrollbackGraphemes(scrollbackProvider, scrollbackOffset); - } else { - // This row is from visible screen (lower part of viewport) - const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y; - line = getLine(screenRow); - grapheme = this.screenGraphemes(buffer, screenRow); + this.lastViewportY = viewportY; + } else if ( + viewportY > 0 && + (dirtyRows.length > 0 || scrollbackLength !== this.lastScrollbackLength) + ) { + forceAll = true; + } + this.lastScrollbackLength = scrollbackLength; + + // Canvas rows whose pixels are repainted. A row whose content or highlight changed + // also repaints the row above, which its glyphs may overflow into. + const rows = dims.rows; + const repaint = new Uint8Array(rows); + const region = (y: number) => { + if (y >= 0 && y < rows) repaint[y] = 1; + }; + const changed = (y: number) => { + region(y); + region(y - 1); + }; + if (forceAll) { + repaint.fill(1); + } else { + for (const y of dirtyRows) changed(y); + for (const y of selectionRows) changed(y); + for (const y of hyperlinkRows) changed(y); + if (shift > 0) { + // Rows came into view at the top. The last row lost the row below it, whose + // glyphs overflowed into it. + for (let y = 0; y < shift; y++) changed(y); + region(rows - 1); + } else if (shift < 0) { + // Rows came into view at the bottom + for (let y = rows + shift; y < rows; y++) changed(y); + } + if (shift !== 0) { + // A regex link underline is drawn at fixed canvas rows, so it did not move with + // the rows: clear it where the copy moved it and draw it where it is. + for (const range of [drawnLinkRange, this.hoveredLinkRange]) { + if (!range) continue; + for (let y = range.startY; y <= range.endY; y++) { + changed(y); + changed(y + shift); + } } - } else { - // At bottom - fetch from visible screen - line = getLine(y); } + } - if (line) { - this.renderLine(line, y, dims.cols, grapheme); - } + // The cursor is drawn over its cell after the rows. Repaint the cell the last frame's + // cursor is in now unless the same cursor is drawn there again. + const drawsCursor = viewportY === 0 && cursor.visible && this.cursorVisible; + let sameCursor = false; + if (this.cursorRow >= 0) { + const row = this.cursorRow + shift; + sameCursor = + drawsCursor && row === cursor.y && cursor.x === this.cursorCol && !this.cursorShapeChanged; + if (!sameCursor) region(row); + } + this.cursorShapeChanged = false; + // The same cursor over an unrepainted row is still on the canvas. Drawing it again + // would blend a translucent cursor colour over itself until it is opaque. + const cursorKept = sameCursor && !repaint[cursor.y]; + + // Repaint each run of rows + for (let y = 0; y < rows; y++) { + if (!repaint[y]) continue; + let end = y; + while (end + 1 < rows && repaint[end + 1]) end++; + this.repaintRows(y, end, dims.cols, rows, (row) => + this.rowAt(row, viewportY, getLine, buffer, scrollbackProvider, scrollbackLength) + ); + y = end; } // Selection highlighting is now integrated into renderCellBackground/renderCellText @@ -587,17 +650,20 @@ export class CanvasRenderer { // Link underlines are drawn during cell rendering (see renderCell) // Render cursor (only if we're at the bottom, not scrolled) - if (viewportY === 0 && cursor.visible && this.cursorVisible) { + if (drawsCursor && !cursorKept) { this.renderCursor(cursor.x, cursor.y); } + this.cursorRow = drawsCursor ? cursor.y : -1; + this.cursorCol = cursor.x; // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) - if (scrollbackProvider && scrollbarOpacity > 0) { + if (paintsScrollbar) { this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity); } + this.scrollbarPainted = paintsScrollbar; - // Update last cursor position - this.lastCursorPosition = { x: cursor.x, y: cursor.y }; + // Without scrollback rows a scrolled frame left its top rows unpainted + if (viewportY > 0 && !scrollbackProvider) this.canvasStale = true; // ALWAYS clear dirty flags after rendering, regardless of forceAll. // This is critical - if we don't clear after a full redraw, the dirty @@ -605,6 +671,138 @@ export class CanvasRenderer { buffer.clearDirty(); } + /** + * The line shown at canvas row `y`, from scrollback or the screen, with a grapheme + * lookup on the same source: canvas row y is not screen row y while scrolled. (webtty) + */ + private rowAt( + y: number, + viewportY: number, + getLine: (y: number) => GhosttyCell[] | null, + buffer: IRenderable, + scrollbackProvider: IScrollbackProvider | undefined, + scrollbackLength: number + ): { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } { + if (viewportY > 0) { + // Scrolled up - need to fetch from scrollback + visible screen + // When scrolled up N lines, we want to show: + // - Scrollback lines (from the end) + visible screen lines + + // Check if this row should come from scrollback or visible screen + if (y < viewportY && scrollbackProvider) { + // This row is from scrollback (upper part of viewport) + // Get from end of scrollback buffer + // Floor viewportY for array access (handles fractional values during smooth scroll) + const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; + return { + line: scrollbackProvider.getScrollbackLine(scrollbackOffset), + grapheme: this.scrollbackGraphemes(scrollbackProvider, scrollbackOffset), + }; + } + // This row is from visible screen (lower part of viewport) + const screenRow = y - Math.floor(viewportY); + return { line: getLine(screenRow), grapheme: this.screenGraphemes(buffer, screenRow) }; + } + // At bottom - fetch from visible screen + return { line: getLine(y) }; + } + + /** + * Repaints canvas rows `first` to `last` exactly as a full redraw leaves them. A full + * redraw draws every row in order, each clearing its own cell area first, so a row's + * pixels are its own cells plus whatever the row below draws over it (tall glyphs, + * combining marks); what the row above overflows into it is cleared. So the rows are + * drawn in order, plus the row below the last. + * + * A run short of the whole canvas is drawn on the scratch canvas and its rows copied + * into the canvas. Drawing it clipped to the run is not the same: Chrome skips a glyph + * whose box is outside the clip, so a combining mark of the row below loses the + * antialiased edge it draws on the run's last pixel line. (webtty, ADR 043) + */ + private repaintRows( + first: number, + last: number, + cols: number, + rows: number, + rowAt: (y: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + ): void { + const whole = first === 0 && last === rows - 1; + const own = this.ctx; + const scratch = whole ? null : this.scratchContext(); + if (scratch) this.ctx = scratch; + try { + for (let y = first; y <= Math.min(last + 1, rows - 1); y++) { + const { line, grapheme } = rowAt(y); + if (line) { + this.renderLine(line, y, cols, grapheme); + } + } + } finally { + this.ctx = own; + } + if (!scratch) return; + // Replace the rows' pixels in device pixels: cleared first, the copy is not blended + // over them, so a translucent background does not darken + const width = this.canvas.width; + const rowHeight = this.metrics.height * this.devicePixelRatio; + const top = first * rowHeight; + const height = (last - first + 1) * rowHeight; + own.save(); + own.setTransform(1, 0, 0, 1, 0, 0); + own.globalAlpha = 1; + own.globalCompositeOperation = 'source-over'; + own.imageSmoothingEnabled = false; + own.clearRect(0, top, width, height); + own.drawImage(this.scratch, 0, top, width, height, 0, top, width, height); + own.restore(); + } + + /** The scratch canvas' context, sized and scaled like the canvas. (webtty, ADR 043) */ + private scratchContext(): CanvasRenderingContext2D { + const { width, height } = this.canvas; + if (this.scratch.width !== width) this.scratch.width = width; + if (this.scratch.height !== height) this.scratch.height = height; + const ctx = this.scratch.getContext('2d', { alpha: true }); + if (!ctx) { + throw new Error('Failed to get 2D rendering context'); + } + // Set every time: resizing, or a lost context, resets the context state + ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, 0); + ctx.textBaseline = 'alphabetic'; + ctx.textAlign = 'left'; + return ctx; + } + + /** + * Moves the canvas content down by `rows` rows (up when negative) with one drawImage of + * the canvas onto itself, in device pixels. The 'copy' operator replaces pixels instead + * of blending onto them (a translucent background would darken) and clears the rest of + * the canvas: the rows that came into view, which are all repainted. (webtty, ADR 043) + */ + private shiftRows(rows: number): void { + const { width, height } = this.canvas; + const dy = rows * this.metrics.height * this.devicePixelRatio; + const kept = height - Math.abs(dy); + const ctx = this.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'copy'; + ctx.imageSmoothingEnabled = false; + ctx.drawImage( + this.canvas, + 0, + Math.max(0, -dy), + width, + kept, + 0, + Math.max(0, dy), + width, + kept + ); + ctx.restore(); + } + /** * Render a single line using two-pass approach: * 1. First pass: Draw all cell backgrounds @@ -912,6 +1110,7 @@ export class CanvasRenderer { */ public setTheme(theme: ITheme): void { this.theme = { ...DEFAULT_THEME, ...theme }; + this.canvasStale = true; // Rebuild palette this.palette = [ @@ -940,6 +1139,7 @@ export class CanvasRenderer { public setFontSize(size: number): void { this.fontSize = size; this.metrics = this.measureFont(); + this.canvasStale = true; } /** @@ -948,6 +1148,7 @@ export class CanvasRenderer { public setFontFamily(family: string): void { this.fontFamily = family; this.metrics = this.measureFont(); + this.canvasStale = true; } /** @@ -1116,6 +1317,7 @@ export class CanvasRenderer { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.fillStyle = this.theme.background; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.canvasStale = true; } /** @@ -1124,5 +1326,9 @@ export class CanvasRenderer { public dispose(): void { this.stopCursorBlink(); this.stopWatchingDevicePixelRatio?.(); + this.canvas.removeEventListener('contextrestored', this.onContextRestored); + // Free the scratch canvas' backing store + this.scratch.width = 0; + this.scratch.height = 0; } } diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 0479d7a..325e853 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -389,8 +389,9 @@ export class Terminal implements ITerminalCore { this.canvas.style.width = `${metrics.width * this.cols}px`; this.canvas.style.height = `${metrics.height * this.rows}px`; - // Force full re-render with new font - this.renderer.render(this.wasmTerm, true, this.viewportY, this); + // Force full re-render with new font, the scrollbar at its current opacity: a frame + // that drops it repaints every row (webtty, ADR 043) + this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); } /** @@ -889,8 +890,8 @@ export class Terminal implements ITerminalCore { // (webtty, ADR 042) this.processTerminalResponses(); - // Force full render - this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); + // Force full render, the scrollbar at its current opacity (webtty, ADR 043) + this.renderer!.render(this.wasmTerm!, true, this.viewportY, this, this.scrollbarOpacity); } catch (e) { console.error('Terminal resize failed:', e); } diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index b32a9ff..45a912f 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -186,7 +186,14 @@ function recordingCanvas(cellHeight: number) { } as Record, { get: (t, k) => (k in t ? t[k] : () => undefined), set: () => true }, ); - const canvas = { getContext: () => ctx, style: {}, width: 0, height: 0 }; + const canvas = { + getContext: () => ctx, + style: {}, + width: 0, + height: 0, + addEventListener: () => {}, + removeEventListener: () => {}, + }; return { canvas: canvas as unknown as HTMLCanvasElement, drawn }; } @@ -230,3 +237,347 @@ describe('graphemes while scrolled', () => { }); } }); + +/** + * A 2D context that rasterises into one string per device pixel: enough of canvas to + * compare a partial frame with a full redraw. Glyphs fill their cell; a combining acute + * also covers the bottom of the row above and a `g` the top of the row below, like + * glyphs that overflow their cell. Like Chrome, a glyph whose cell lies outside the clip + * is not drawn at all, even where its overflow reaches into the clip. A translucent + * `rgba()` fill blends over what is there, so drawing it twice differs from drawing it + * once. `lose()` blanks the canvas and resets the context, as a lost and restored + * context does. + */ +function rasterCanvas() { + type Rect = [number, number, number, number]; + interface State { + m: [number, number, number, number]; + clip: Rect | null; + fillStyle: string; + strokeStyle: string; + font: string; + globalAlpha: number; + globalCompositeOperation: string; + } + let width = 0; + let height = 0; + let pixels: string[] = []; + const fresh = (): State => ({ + m: [1, 1, 0, 0], + clip: null, + fillStyle: '#000', + strokeStyle: '#000', + font: '', + globalAlpha: 1, + globalCompositeOperation: 'source-over', + }); + let st = fresh(); + let stack: State[] = []; + let path: Rect | null = null; + let line: number[] = []; + const reset = () => { + pixels = new Array(width * height).fill(''); + st = fresh(); + stack = []; + }; + const device = (x: number, y: number, w: number, h: number): Rect => { + const [sx, sy, tx, ty] = st.m; + return [x * sx + tx, y * sy + ty, (x + w) * sx + tx, (y + h) * sy + ty].map(Math.round) as Rect; + }; + const paint = ([x0, y0, x1, y1]: Rect, value: string) => { + const c = st.clip ?? [0, 0, width, height]; + const blends = value.startsWith('rgba('); + for (let y = Math.max(y0, c[1], 0); y < Math.min(y1, c[3], height); y++) { + for (let x = Math.max(x0, c[0], 0); x < Math.min(x1, c[2], width); x++) { + const i = y * width + x; + pixels[i] = blends ? `${pixels[i]} + ${value}` : value; + } + } + }; + const ink = (style: string) => (st.globalAlpha === 1 ? style : `${style}@${st.globalAlpha}`); + const ctx = new Proxy( + { + measureText: () => ({ width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }), + save: () => stack.push({ ...st, m: [...st.m] }), + restore: () => { + st = stack.pop() ?? st; + }, + scale: (x: number, y: number) => { + st.m = [st.m[0] * x, st.m[1] * y, st.m[2], st.m[3]]; + }, + setTransform: (a: number, _b: number, _c: number, d: number, e: number, f: number) => { + st.m = [a, d, e, f]; + }, + clearRect: (x: number, y: number, w: number, h: number) => paint(device(x, y, w, h), ''), + fillRect: (x: number, y: number, w: number, h: number) => + paint(device(x, y, w, h), ink(st.fillStyle)), + fillText: (text: string, x: number, y: number) => { + const cell = device(x, y - 11, 8, 15); + if (st.clip && (cell[3] <= st.clip[1] || cell[1] >= st.clip[3])) return; + const up = text.includes('́') ? 4 : 0; + const down = text === 'g' ? 4 : 0; + paint(device(x, y - 11 - up, 8, 15 + up + down), ink(`${st.fillStyle} ${st.font} ${text}`)); + }, + beginPath: () => { + path = null; + line = []; + }, + rect: (x: number, y: number, w: number, h: number) => { + path = device(x, y, w, h); + }, + clip: () => { + const c = st.clip ?? [0, 0, width, height]; + const p = path ?? c; + st.clip = [ + Math.max(c[0], p[0]), + Math.max(c[1], p[1]), + Math.min(c[2], p[2]), + Math.min(c[3], p[3]), + ]; + }, + moveTo: (x: number, y: number) => { + line = [x, y]; + }, + lineTo: (x: number) => { + line = [line[0], line[1], x]; + }, + stroke: () => + paint(device(line[0], line[1] - 0.5, line[2] - line[0], 1), `line ${st.strokeStyle}`), + drawImage: ( + src: { pixels: string[]; width: number }, + sx: number, + sy: number, + sw: number, + sh: number, + dx: number, + dy: number, + ) => { + expect(st.m).toEqual([1, 1, 0, 0]); + const from = [...src.pixels]; + if (st.globalCompositeOperation === 'copy') paint([0, 0, width, height], ''); + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + pixels[(dy + y) * width + dx + x] = from[(sy + y) * src.width + sx + x]; + } + } + }, + } as Record, + { + get: (t, k) => (k in t ? t[k] : k in st ? st[k as keyof State] : () => undefined), + set: (_t, k, v) => { + if (k in st) (st as unknown as Record)[k] = v; + return true; + }, + }, + ); + const restored = new Set<() => void>(); + const canvas = { + getContext: () => ctx, + style: {}, + addEventListener: (type: string, listener: () => void) => { + if (type === 'contextrestored') restored.add(listener); + }, + removeEventListener: (type: string, listener: () => void) => { + if (type === 'contextrestored') restored.delete(listener); + }, + get pixels() { + return pixels; + }, + get width() { + return width; + }, + set width(w: number) { + width = w; + reset(); + }, + get height() { + return height; + }, + set height(h: number) { + height = h; + reset(); + }, + }; + const lose = () => { + reset(); + for (const listener of restored) listener(); + }; + return { canvas: canvas as unknown as HTMLCanvasElement, pixels: () => pixels, lose }; +} + +describe('whole-line scroll copies rows (ADR 043)', () => { + const cols = 30; + const rows = 8; + + /** A fresh renderer on its own raster canvas, counting the rows it draws. */ + function renderer(dpr: number, options: ConstructorParameters[1] = {}) { + const raster = rasterCanvas(); + const doc = globalThis.document; + // Other canvases (font measuring, the scratch canvas) rasterise on their own + globalThis.document = { + createElement: () => rasterCanvas().canvas, + } as unknown as Document; + try { + const r = new CanvasRenderer(raster.canvas, { ...options, devicePixelRatio: dpr }); + r.resize(cols, rows); + const drawn: number[] = []; + const internals = r as unknown as { renderLine: (...args: unknown[]) => void }; + const renderLine = internals.renderLine.bind(r); + internals.renderLine = (...args: unknown[]) => { + drawn.push(args[1] as number); + renderLine(...args); + }; + return { r, pixels: raster.pixels, lose: raster.lose, drawn }; + } finally { + globalThis.document = doc; + } + } + + for (const [engine, wasm] of [ + ['coder', WASM], + ['official', OFFICIAL_WASM], + ] as const) { + async function terminal() { + const ghostty = await Ghostty.load(wasm, engine); + const term = ghostty.createTerminal(cols, rows, { scrollbackLimit: 10000 }); + // Colours, backgrounds, a wide character, combining marks and descenders that + // overflow into the rows above and below. + for (let i = 0; i < 40; i++) { + term.write( + i % 3 === 0 + ? `\x1b[3${i % 8}mline ${i} é g\x1b[0m\r\n` + : i % 3 === 1 + ? `\x1b[4${i % 8}m bg ${i} \x1b[0m 中 g\r\n` + : `plain ${i} á\r\n`, + ); + } + // The cursor mid-screen, so a scroll moves it onto another row in view. + term.write('$ prompt\x1b[5;3H'); + const sb = term.getScrollbackLength(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => sb, + getScrollbackGraphemeString: (o: number, c: number) => + term.getScrollbackGraphemeString(o, c), + }; + return { term, provider }; + } + + test(`each step draws only the rows that came into view and equals a full redraw (${engine})`, async () => { + const { term, provider } = await terminal(); + for (const dpr of [1, 1.5]) { + const live = renderer(dpr); + live.r.render(term, true, 0, provider, 1); + for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { + const before = live.drawn.length; + const from = (live.r as unknown as { lastViewportY: number }).lastViewportY; + live.r.render(term, false, y, provider, 1); + const steps = Math.abs(y - from); + // The rows that came into view and the one their glyphs overflow into, the last + // row (scrolling up) and the row the old cursor moved to with the row below it. + expect(live.drawn.length - before).toBeLessThanOrEqual(steps + 4); + const full = renderer(dpr); + full.r.render(term, true, y, provider, 1); + expect(live.pixels()).toEqual(full.pixels()); + } + } + term.free(); + }); + + test(`a scrolled frame with nothing changed draws no row (${engine})`, async () => { + const { term, provider } = await terminal(); + const live = renderer(2); + live.r.render(term, true, 0, provider, 1); + live.r.render(term, false, 6, provider, 1); + const before = live.drawn.length; + for (let i = 0; i < 3; i++) live.r.render(term, false, 6, provider, 1); + expect(live.drawn.length).toBe(before); + // Dropping the scrollbar repaints every row, so none keeps the thumb. + live.r.render(term, false, 6, provider, 0); + expect(live.drawn.length - before).toBe(rows); + const full = renderer(2); + full.r.render(term, true, 6, provider, 0); + expect(live.pixels()).toEqual(full.pixels()); + term.free(); + }); + + test(`selection, link underline and cursor follow a copied scroll (${engine})`, async () => { + const { term, provider } = await terminal(); + const sb = provider.getScrollbackLength(); + let view = 0; + // A selection anchored to buffer lines, as SelectionManager keeps it. + const selection = { + hasSelection: () => true, + getSelectionCoords: () => ({ + startCol: 3, + startRow: sb - 4 - sb + view, + endCol: 12, + endRow: sb - 2 - sb + view, + }), + getDirtySelectionRows: () => new Set(), + clearDirtySelectionRows: () => {}, + } as unknown as Parameters[0]; + const range = { startX: 2, startY: 3, endX: 9, endY: 3 }; + const live = renderer(2); + live.r.setSelectionManager(selection); + live.r.setHoveredLinkRange(range); + live.r.render(term, true, 0, provider, 1); + for (const y of [1, 3, 2, 0, 2, 7, 6]) { + view = y; + live.r.render(term, false, y, provider, 1); + const full = renderer(2); + full.r.setSelectionManager(selection); + full.r.setHoveredLinkRange(range); + full.r.render(term, true, y, provider, 1); + expect(live.pixels()).toEqual(full.pixels()); + } + term.free(); + }); + + test(`a translucent cursor drawn again is not blended over itself (${engine})`, async () => { + const { term, provider } = await terminal(); + const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; + for (const cursorStyle of ['block', 'bar', 'underline'] as const) { + const live = renderer(2, { theme, cursorStyle }); + live.r.render(term, true, 0, provider, 1); + // Idle frames, a scroll away and back, and a frame that repaints the cursor row + for (const [y, force] of [ + [0, false], + [0, false], + [2, false], + [0, false], + [0, false], + [0, true], + [0, false], + ] as const) { + live.r.render(term, force, y, provider, 1); + const full = renderer(2, { theme, cursorStyle }); + full.r.render(term, true, y, provider, 1); + expect(live.pixels()).toEqual(full.pixels()); + } + live.r.dispose(); + } + term.free(); + }); + + test(`a restored context is repainted on the next frame (${engine})`, async () => { + const { term, provider } = await terminal(); + for (const y of [6, 0]) { + const live = renderer(2); + live.r.render(term, true, 0, provider, 1); + live.r.render(term, false, y, provider, 1); + live.lose(); + live.r.render(term, false, y, provider, 1); + const full = renderer(2); + full.r.render(term, true, y, provider, 1); + expect(live.pixels()).toEqual(full.pixels()); + // Disposed, it no longer listens + live.r.dispose(); + live.lose(); + live.r.render(term, false, y, provider, 1); + expect(live.pixels()).not.toEqual(full.pixels()); + } + term.free(); + }); + } +}); From 529b619c8e3d8a333ab841683543d4dda523a033 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 11:51:38 -0400 Subject: [PATCH 020/295] perf(client): overscan rows so smooth scrolling moves the canvas across lines The renderer now draws on a canvas of its own, 16 rows taller than the viewport above and below, moved by translateY and clipped to the terminal canvas' box. The terminal canvas keeps the layout and the mouse events. Pixel-smooth scrolling sets the sub-line offset on the renderer, so a scroll within the overscan changes only the transform and clip; a scroll past it copies the rows still in the canvas. This replaces ADR 040's one-row strip, which drew a row on every wheel event. Rows are drawn when they come into view, and the rows in view are kept exactly as a full redraw of the viewport leaves them: the last row, and the row above at a fraction of a line, without what the next line draws into them. Hit testing subtracts the offset, Kitty images and the edge strips follow it, and the row above now shows the selection. Headless Chrome at 175x64, DPR 2: canvas copied per line step drops from 0.40 to 0.04 Mpx and wheel handlers from about 0.2 to 0.01 ms per frame. Viewport rows are identical to the previous build at 37 positions on both engines and DPR 1, 1.5 and 2 (ADR 043). --- docs/adrs/040.client.pixel-smooth-scroll.md | 2 + docs/adrs/041.client.libghostty-vt.md | 2 +- .../043.client.scroll-copy-and-overscan.md | 61 +- docs/specs/client.md | 4 +- src/client/edge.ts | 33 +- src/client/graphics.ts | 12 +- src/client/index.ts | 9 +- src/client/smooth-scroll.test.ts | 66 +- src/client/smooth-scroll.ts | 106 +-- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/renderer.ts | 714 +++++++++++------- .../vendor/ghostty-web/selection-manager.ts | 45 +- src/client/vendor/ghostty-web/terminal.ts | 33 +- src/client/vendor/renderer.test.ts | 496 ++++++++++-- 14 files changed, 1108 insertions(+), 476 deletions(-) diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index fb04f6e..4f93591 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -21,6 +21,8 @@ Keep the scroll position as fractional lines in the client (`src/client/smooth-s The alternate screen and mouse-tracking apps scroll their own view in whole lines, so no pixel offset is possible there. For mouse-tracking apps (herdr, Claude Code's fullscreen TUI, vim `mouse=a`), count pixel wheel deltas by distance instead: one SGR scroll tick per cell height scrolled, 1:1 with the OS like native macOS scrolling. `mouseScrollSpeed` (ADR 017) does not apply to pixel deltas, as Ghostty skips its scroll multiplier for precision scrolls; at 1.5 it made apps scroll visibly faster than native. Previously every wheel event was one tick, so a trackpad's many small events and momentum scrolled far ahead of the fingers. Line and page deltas still count one tick per event. +**Superseded in part by [ADR 043](043.client.scroll-copy-and-overscan.md):** the strip canvas and the `renderLine` swap are gone. The renderer draws rows above and below the viewport on a canvas of its own and shows the offset itself (`setScrollOffset`), moving that canvas by `translateY` and clipping it to the viewport. The bottom edge strip now extends the last row in view, and Kitty images move with the offset. + **Update (ADR 041):** the mouse-tracking tick counting now lives in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`, `wheelTicks`), with ADR 017's SGR reports; `index.ts` keeps only the pixel-smooth scrollback handler. ## Consequences diff --git a/docs/adrs/041.client.libghostty-vt.md b/docs/adrs/041.client.libghostty-vt.md index 4174e5d..0f79c4a 100644 --- a/docs/adrs/041.client.libghostty-vt.md +++ b/docs/adrs/041.client.libghostty-vt.md @@ -55,7 +55,7 @@ Take ownership of the terminal layer: vendor ghostty-web's TypeScript into webtt 3. **Official wasm and binding, alongside the Coder engine** ([ADR 042](042.client.official-vt-engine.md)). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. 4. **Scrollback in lines** (own ADR, client and config). A new client key `scrollbackLines` (default 10,000) sets `SCROLLBACK_MAX_LINES`, with `SCROLLBACK_MAX_BYTES` at 32 MiB. `scrollbackLines: 0` means unlimited lines: `SCROLLBACK_MAX_LINES` NULL, bytes still 32 MiB. `scrollback` keeps its current meaning, the server's replay buffer in characters (`src/server/websocket.ts`), and the client stops deriving its limit from it. Official engine only. 5. **Delete the Coder engine and the workarounds the official API makes unnecessary**, after one release with `official` as the default: the Coder wasm and its sidecar, cursor style from the render-state cursor, size reports answered by Ghostty through `OPT_SIZE` (deletes the `kitty.ts` size replies; depends on step 3 installing `OPT_SIZE`), the official mouse/key encoders and `terminal_paste`, theme colours from render state. -6. **Pixel-smooth scroll through `OVERSCAN`** (own ADR, supersedes ADR 040 in part). Draw the extra rows the render state provides at a fractional offset, replacing the strip canvas and `renderLine` swap of ADR 040. When it ships, ADR 040 gets a "Superseded in part by" note and the client SPEC's ADR 040 row is updated. +6. **Pixel-smooth scroll through `OVERSCAN`** (own ADR, supersedes ADR 040 in part). Draw the extra rows the render state provides at a fractional offset, replacing the strip canvas and `renderLine` swap of ADR 040. When it ships, ADR 040 gets a "Superseded in part by" note and the client SPEC's ADR 040 row is updated. (Done in [ADR 043](043.client.scroll-copy-and-overscan.md) step 2, with the extra rows read through the scrollback provider on both engines instead of `OVERSCAN`.) Future work, not decided here: official selection, search, synchronized-output hold (mode 2026), OSC 52 and notifications through effects. diff --git a/docs/adrs/043.client.scroll-copy-and-overscan.md b/docs/adrs/043.client.scroll-copy-and-overscan.md index e990a38..38f9201 100644 --- a/docs/adrs/043.client.scroll-copy-and-overscan.md +++ b/docs/adrs/043.client.scroll-copy-and-overscan.md @@ -2,7 +2,7 @@ **SPEC:** [client](../specs/client.md) **Status:** Accepted -**Date:** 2026-10-13 +**Date:** 2026-10-13 (step 2: 2026-10-13) --- @@ -43,16 +43,33 @@ The official engine reported every row dirty while scrolled, and a full redraw o Screen scrolling caused by output is not copied. The engines report which rows changed, not that the screen scrolled, and detecting a scroll by comparing rows (including graphemes and hyperlinks) is not cheap or safe enough for this step. -**Step 2: overscan (later).** Draw extra rows above and below the viewport into a taller canvas. Pixel-smooth scroll can then translate the canvas by CSS across several lines, and redraw (by step 1's copy) only when the overscan runs out. This replaces ADR 040's one-row strip canvas and its `renderLine` context swap. It is ADR 041 step 6: on the official engine the extra rows come from the render state's `OVERSCAN`, on the Coder engine from the scrollback provider. Step 2 must keep step 1's rules: a moved frame equals a full redraw of the taller canvas, and the bottom edge strip and the cursor stay on the visible rows. When it ships, ADR 040 gets a "Superseded in part by" note. +**Step 2: overscan.** The renderer draws into a canvas of its own that holds `overscan` rows above and below the viewport (`DEFAULT_OVERSCAN`, 16). Canvas row k shows viewport row k − 16 at a viewportY the renderer picks (the band). Pixel-smooth scroll (`smooth-scroll.ts`) sets the sub-line offset on the renderer (`setScrollOffset`), and `render()` moves the canvas up by `translateY` and clips it to the viewport with `clip-path`. A scroll within the band changes only those two styles. A scroll past it picks a new band half the overscan further in the direction of the move, and copies the rows still in the canvas with step 1's `shiftRows`. This replaces ADR 040's one-row strip canvas and its `renderLine` context swap, which drew a row on every wheel event. + +The terminal's canvas element keeps its layout box and the mouse events, and draws nothing. The drawing canvas is inserted before it, absolutely positioned with no offsets, so it sits at the element's static position wherever fit's padding moves it. It ignores the mouse and is excluded from the terminal canvas lookups like the edge strips. Fit, the edge strips, Kitty graphics and the IME anchor still measure the element. Hit testing (`terminal.ts`, `selection-manager.ts`) subtracts `scrollOffset`, because viewport row 0 starts that far below the element's top. Kitty images are drawn that far lower too, so they move with the text. The edge strips copy the drawing canvas (`visibleArea`): the right strip the column of the part in view, the bottom strip the last pixel line of the last row in view, which at a fraction of a line is below the clip. The line the clip ends on runs through that row's glyphs, and stretched across the padding it streaks on every frame. An offset set for a taller cell, before a font change, is shown as at most a line, so the row above always covers the gap. Fit re-splits the scroll position after every DPR and font change, as under ADR 040, so the offset is snapped to the new device-pixel grid and cell height instead of waiting for the next wheel event. + +Each canvas row has a state: invalid, *joined* (its own cells plus what the next line draws into it, as a full redraw leaves a row), or *cut* (its own cells only). Every frame, the rows in view are brought to the state a full redraw of the viewport leaves them in. The last row is cut, the others are joined, and at a fraction of a line the row above the viewport is cut, as ADR 040's strip drew it alone. Rows out of view are not drawn until they come into view, so a move draws only the rows that came into view plus the edge rows that change between joined and cut, and no frame draws the whole band. `repaintRows` draws a run of rows on the scratch canvas, which is now only as tall as the run plus the next line. It draws on the canvas directly only when the run is cut and the rows around it are invalid, as after a full redraw. Changes invalidate rows instead of repainting them: + +- A changed row (engine, selection, link hover) invalidates itself and, when it is joined, the row above. +- An overlay (the cursor, the scrollbar) invalidates only its own row. The scrollbar is painted over the rows in view; rows it no longer covers are invalidated. +- Output at the bottom (new scrollback or any dirty row) invalidates every row out of view, since the lines may have moved. While scrolled it still repaints the viewport, as in step 1. +- Selection and regex link ranges are tracked in canvas rows. The selection is anchored to buffer lines like the canvas rows, so it repaints only when it changes; `SelectionManager.getSelectionRange` gives it unclamped. The regex link range is in viewport rows, so a scroll repaints where it was and where it is. A hyperlink hover change repaints every row that is not invalid and has the old or new id. +- After a copy, the tracked cursor row, scrollbar rows, selection and link range move with the rows. "None" is `null`, not −1: a copied row index can be negative. + +Both engines read the rows outside the viewport through the scrollback provider and the screen rows, as for the viewport. The official engine's render-state `OVERSCAN` is not needed. ## Considered Options - **Offscreen double buffer.** Copy to a second canvas and back. That is two copies of the whole canvas per scroll; the scratch canvas only ever copies the repainted rows. Drawing the canvas onto itself works in Chrome: the browser check below found no difference from a full redraw. +- **`object-position` on the taller canvas, with the element itself as the window.** One element, no hit-testing changes, and the box clips. Chrome rounds `object-position` to whole CSS pixels (−10.5px shows what −10px shows), so at DPR 2 every half-pixel offset was one device pixel off. `translateY` places to the device pixel. +- **Drawing every row of the band (eagerly) on a full redraw and on a new band.** That is up to 96 rows instead of 64 for each full redraw, and a band change draws 8 rows at once. Drawing rows when they come into view costs the same per line and keeps output at the bottom as cheap as before. +- **Joined rows at the viewport edges.** Without the cut last row and cut row above, a line crossing draws nothing when its rows are drawn. But at rest the last row would show what the line below draws into it (box-drawing stems, accents on capitals, emoji), which a full redraw of the viewport does not. - **Drawing the run on the canvas, clipped to it.** Chrome skips a glyph whose box lies outside the clip, even where its antialiased edge reaches inside. A combining mark of the row below whose top sits just under the run (the diaeresis of `ạ̈`, the stack of `ặ`) then loses the faint edge it draws on the run's last device-pixel line: up to 21 levels on about 10 pixels per row. Copied frames carry those pixels along until the next full redraw. Clip edges through the middle of a row avoid it in practice, but not for every glyph height. - **Unclipped neighbour rows (the old dirty-row rule).** Drawing a row again over an unchanged neighbour draws its overflow twice, which differs from a full redraw on anti-aliased edges. ## Measurements +### Step 1 + Apple M3 Pro. Bun 1.3.11 with a recording fake canvas (`scrollbench.ts`: 200×60, DPR 2, 3,000 lines, one-line steps, 600 frames, best of 3): | Per scrolled frame | Before | After | @@ -77,6 +94,41 @@ In the browser, the canvas after whole-line steps (1 to 5 lines, up and down, ba Unit tests (`renderer.test.ts`) draw into a rasterising fake canvas where glyphs overflow into the rows above and below, and, like Chrome, a glyph whose cell is outside the clip is not drawn. They check that each step draws at most the new rows plus four, and that the result equals a fresh full redraw at DPR 1 and 1.5. A third test does the same at DPR 2 with a selection, a hovered link range and the cursor. +### Step 2 + +Bun with a recording fake canvas (`scrollbench`: 175×64, DPR 2, 3,000 lines, best of 3). Per frame: glyphs drawn and canvas pixels copied (the self-copy plus the scratch copies). Step 1 also counts ADR 040's strip row, which was drawn on every frame at a fraction of a line: + +| Per frame (official / Coder) | Step 1 | N = 4 | N = 16 | N = 64 | +|---|---|---|---|---| +| One-line steps, `fillText` | 416 / 216 | 416 / 216 | 416 / 216 | 416 / 216 | +| One-line steps, Mpx copied | 5.46 / 5.46 | 1.03 / 1.16 | 0.44 / 0.50 | 0.27 / 0.30 | +| Trackpad, 0.3 line per frame, `fillText` | 319 / 213 | 209 / 141 | 209 / 141 | 209 / 141 | +| Trackpad, 0.3 line per frame, Mpx copied | 1.64 / 1.64 | 0.35 / 0.36 | 0.15 / 0.16 | 0.10 / 0.10 | +| Trackpad, 1.4 lines per frame, Mpx copied | 5.46 / 5.46 | 1.50 / 1.60 | 0.65 / 0.69 | 0.38 / 0.40 | + +Glyphs per line step stay the same: the rows that come into view are drawn either way, and the edge rows that turn joined or cut replace step 1's row below the new rows. What goes is the copy of the whole canvas on every line, and the strip row on every fractional frame. N = 16 copies an eighth of step 1. N = 64 copies little less and makes the canvas three times as tall (about 60 MB instead of 20 MB at 175×64, DPR 2); N = 16 is 30 MB. An idle frame costs 1–2 µs in both. + +Headless Chrome 155, 1400×900 at DPR 2, the same mixed output. Script time of `requestAnimationFrame` callbacks plus wheel handlers, per frame: + +| (official / Coder) | Step 1 | N = 16 | +|---|---|---| +| Wheel handlers, trackpad | 0.21 / 0.10 ms | 0.009 / 0.011 ms | +| Wheel handlers, line steps | 0.21 / 0.07 ms | 0.04 / 0.04 ms | +| Canvas copied, line steps | 0.40 / 0.40 Mpx | 0.04 / 0.04 Mpx | +| Canvas copied, trackpad | 0.19 / 0.19 Mpx | 0.02 / 0.02 Mpx | +| `fillText`, trackpad 3.3 px events | 71 / 72 | 25 / 25 | + +The total script time per frame is 0.2–0.9 ms in both builds, and differs from run to run by more than the two differ, so it is not given. Most of the wheel handler time was the strip. The copies are GPU work that script time does not show. + +Screenshots of the step 1 and step 2 builds were compared at 37 scroll positions per run. The positions include trackpad and line steps up and down, jumps past the overscan, the top and the bottom of the scrollback, a selection and a parked, faded scrollbar. They were compared for the official engine at DPR 2 and the Coder engine at DPR 1, 1.5 and 2. Every viewport row was identical, and so was the bottom edge strip, which extends the last row's own last pixel line in both. The differences were all in places step 1 got wrong: + +- **The row above the viewport, at a fraction of a line.** At DPR 2 and 1.5, ADR 040's strip sat one device pixel below the canvas grid (the band matches step 1's shifted down by one row). Its last pixel line was hidden and the line above it showed. At DPR 1 it is identical. The strip also drew the row unselected; it is now drawn with the selection. +- **The scrollbar while fading**, which depends on when the screenshot was taken. + +A Kitty image placed with text after it moves with that text at a fraction of a line. Under ADR 040 it stayed behind. + +Unit tests (`renderer.test.ts`) check the rows in view against the step 1 renderer drawn independently. The viewport is drawn as a screen of its own lines, and the row above on its own. Positions are trackpad and line steps up and down, jumps past the overscan and the top of the scrollback, at overscan 1, 2, 3 and 16 and DPR 1 and 2. A fixed pseudo-random run of 500 steps per overscan mixes scrolls, output, selection, regex and OSC 8 hover and scrollbar changes, and checks each step against a full redraw. Further tests cover a hover change on a row just out of view, output at the bottom followed by a scroll, a selection change on a frame that copies rows, the translucent cursor, a restored context, and the number of rows a scroll draws. + ## Consequences - Scrolling through scrollback draws only the rows that came into view. A terminal parked in scrollback draws nothing. @@ -85,7 +137,10 @@ Unit tests (`renderer.test.ts`) draw into a rasterising fake canvas where glyphs - A blinking cursor no longer redraws its row on every frame, only when it toggles. - A frame that drops the scrollbar repaints every row. That is once per scrollbar fade-out, and once after a resize or font change, which now pass the current scrollbar opacity instead of drawing it at full opacity. - Output that scrolls the screen still redraws every dirty row, which is every row. -- Self-copy `drawImage` and the `copy` operator were checked in Chrome only. Firefox and Safari need the same check before step 2 ships. +- Pixel-smooth scroll moves the canvas by CSS across up to 16 lines and draws only the rows that come into view and the edge rows. It no longer draws a row on every wheel event or copies the whole canvas on every line. +- The drawing canvas is a second element next to the terminal's canvas. Code that reads the terminal's pixels must use `visibleArea`, and code that maps mouse positions to rows must subtract `scrollOffset`. +- The canvas is 1.5 times as tall (about 30 MB at 175×64, DPR 2). The scratch canvas is only as tall as the largest run drawn on it. +- Self-copy `drawImage`, the `copy` operator and step 2's moved and clipped canvas were checked in Chrome only. Firefox and Safari still need the same check. ## Related Decisions diff --git a/docs/specs/client.md b/docs/specs/client.md index 5380c27..d8b3dea 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -191,14 +191,14 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | | IME / dictation at cursor | The terminal keeps focus on its input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, is followed by the renderer, which repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | -| Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | +| Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (the renderer's overscan canvas moved by the offset, ADR 043; the one-row strip is gone); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | | Official engine by default | The default flips to `official` once ADR 042's acceptance matrix (Chrome, Firefox, Safari; resize/reflow; query round trips; #180; parity) is recorded; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | -| Smooth scroll via `OVERSCAN` | Pixel-smooth scrollback draws extra rows above and below the viewport (Ghostty's render state `OVERSCAN` on the official engine) and translates the canvas across several lines before redrawing, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ⬜ | +| Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | diff --git a/src/client/edge.ts b/src/client/edge.ts index c8b20f7..5d84a07 100644 --- a/src/client/edge.ts +++ b/src/client/edge.ts @@ -47,6 +47,18 @@ export function computeEdgeRects( }; } +/** A canvas and the rows of it in view, in canvas pixels. */ +export interface VisibleArea { + canvas: HTMLCanvasElement; + y: number; + height: number; + /** + * The bottom pixel line of the last row in view. At a fraction of a line the part in + * view ends inside that row, through its glyphs; its own last line is the background. + */ + lastLine: number; +} + interface Strip { el: HTMLCanvasElement; ctx: CanvasRenderingContext2D; @@ -89,8 +101,13 @@ export class EdgeExtender { constructor( private readonly container: HTMLElement, private readonly padding: number, + /** + * The canvas the terminal draws on and the part of it in view, in canvas pixels, when + * that is not the terminal canvas (ADR 043). Undefined: all of the terminal canvas. + */ + private readonly visibleArea: () => VisibleArea | undefined = () => undefined, ) { - // Appended after the terminal canvas, so `querySelector('canvas')` still finds that one. + // Marked so the lookups of the terminal canvas skip them. for (const strip of [this.bottom, this.right]) { if (strip) container.append(strip.el); } @@ -124,15 +141,21 @@ export class EdgeExtender { private readonly paint = (): void => { this.frame = null; - const src = this.terminalCanvas(); - if (!src || src.width === 0 || src.height === 0) return; + const terminal = this.terminalCanvas(); + const area = + this.visibleArea() ?? + (terminal + ? { canvas: terminal, y: 0, height: terminal.height, lastLine: terminal.height - 1 } + : null); + const src = area?.canvas; + if (!area || !src || src.width === 0 || area.height === 0) return; if (this.bottom?.visible) { const { ctx, el } = this.bottom; - ctx.drawImage(src, 0, src.height - 1, src.width, 1, 0, 0, el.width, el.height); + ctx.drawImage(src, 0, area.lastLine, src.width, 1, 0, 0, el.width, el.height); } if (this.right?.visible) { const { ctx, el } = this.right; - ctx.drawImage(src, src.width - 1, 0, 1, src.height, 0, 0, el.width, el.height); + ctx.drawImage(src, src.width - 1, area.y, 1, area.height, 0, 0, el.width, el.height); } if (this.bottom?.visible || this.right?.visible) { this.frame = window.requestAnimationFrame(this.paint); diff --git a/src/client/graphics.ts b/src/client/graphics.ts index 9b0018a..f208077 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -582,7 +582,12 @@ export class KittyGraphics { } private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-kitty])'); + return this.container.querySelector('canvas:not([data-kitty]):not([data-edge])'); + } + + /** The pixel offset of pixel-smooth scrolling (ADR 040, ADR 043). */ + private scrollOffset(): number { + return this.term.renderer?.scrollOffset ?? 0; } private tick = (): void => { @@ -594,6 +599,7 @@ export class KittyGraphics { const signature = [ this.scrollbackLength(), this.term.viewportY, + this.scrollOffset(), cell.width, cell.height, canvas.offsetLeft, @@ -628,6 +634,8 @@ export class KittyGraphics { ctx.clearRect(0, 0, w, h); const topRow = this.scrollbackLength() - this.term.viewportY; + // Viewport row 0 starts the scroll offset below the canvas top (ADR 043) + const offset = this.scrollOffset(); for (const p of this.placements.values()) { const img = this.images.get(p.imageId)?.bitmap; if (!img) continue; @@ -647,7 +655,7 @@ export class KittyGraphics { const dw = span ? span.cols * cell.width : sw / p.scale.x; const dh = span ? span.rows * cell.height : sh / p.scale.y; const x = p.col * cell.width + p.offX / p.scale.x; - const y = (p.absRow - topRow) * cell.height + p.offY / p.scale.y; + const y = (p.absRow - topRow) * cell.height + p.offY / p.scale.y + offset; if (y + dh < 0 || y > h || x + dw < 0 || x > w) continue; ctx.drawImage(img, sx, sy, sw, sh, x, y, dw, dh); } diff --git a/src/client/index.ts b/src/client/index.ts index 93eb0fd..98a1eff 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -97,12 +97,13 @@ if (paddingColor) { container.style.background = paddingColor; } await term.open(container); -const edges = new EdgeExtender(container, config.padding); +// The strips copy the edges of the part of the canvas in view (ADR 043). +const edges = new EdgeExtender(container, config.padding, () => term.renderer?.visibleArea); // IME / dictation composition shows at the cursor in the theme's colours, not as a // stray line under the canvas. See ADR 038. new Composer(term, container, terminalTheme); -// Pixel-smooth scrollback scrolling for trackpads. See ADR 040. -const smoothScroll = new SmoothScroll(term, container); +// Pixel-smooth scrollback scrolling for trackpads. See ADR 040 and ADR 043. +const smoothScroll = new SmoothScroll(term); // FitAddon sizes the grid from the full container less its CSS padding, so the // padding is set to config.padding on every side first (#terminal is border-box, so @@ -123,7 +124,7 @@ function fit(): void { // already sent the new cell pixels with resize; this sends them when only the // pixels changed, for the official engine's size reports (ADR 042). term.syncCellPixelSize(); - const canvas = container.querySelector('canvas') as HTMLElement | null; + const canvas = container.querySelector('canvas:not([data-edge])') as HTMLElement | null; if (!canvas) return; const insets = computeInsets( Math.max(0, container.clientWidth - canvas.offsetWidth), diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 20ae8a4..32252a5 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { splitScroll, wheelLines } from './smooth-scroll'; +import { SmoothScroll, splitScroll, wheelLines } from './smooth-scroll'; +import type { Terminal } from './vendor/ghostty-web'; describe('splitScroll', () => { test('a whole-line position has no offset', () => { @@ -36,3 +37,66 @@ describe('wheelLines', () => { expect(wheelLines({ deltaY: -1, deltaMode: 2 }, 14, 40)).toBe(-40); }); }); + +describe('SmoothScroll', () => { + function fake(cellHeight: number) { + const metrics = { height: cellHeight }; + const offsets: number[] = []; + const term = { + viewportY: 0, + rows: 40, + wasmTerm: { isAlternateScreen: () => false }, + renderer: { + getMetrics: () => metrics, + setScrollOffset: (offset: number) => offsets.push(offset), + }, + onScroll: () => {}, + onResize: () => {}, + getScrollbackLength: () => 100, + scrollToLine(line: number) { + term.viewportY = line; + }, + }; + return { term, metrics, offsets, scroll: new SmoothScroll(term as unknown as Terminal) }; + } + const wheel = (deltaY: number) => ({ deltaY, deltaMode: 0 }) as WheelEvent; + + test('update re-snaps the offset to a new device pixel ratio', () => { + const g = globalThis as { window?: unknown }; + const saved = g.window; + try { + g.window = { devicePixelRatio: 1 }; + const { term, offsets, scroll } = fake(13); + // 0.69 x 13 = 8.97 CSS px, snapped to 9 at 1x. + scroll.handleWheel(wheel(-13 * 5.69)); + expect(term.viewportY).toBe(5); + expect(offsets.at(-1)).toBe(9); + g.window = { devicePixelRatio: 2 }; + scroll.update(); + expect(offsets.at(-1)).toBe(9); + g.window = { devicePixelRatio: 1.5 }; + scroll.update(); + // 8.97 x 1.5 = 13.455 device px, snapped to 13. + expect(offsets.at(-1)).toBeCloseTo(13 / 1.5); + } finally { + g.window = saved; + } + }); + + test('update re-splits the position for a new cell height', () => { + const g = globalThis as { window?: unknown }; + const saved = g.window; + try { + g.window = { devicePixelRatio: 1 }; + const { term, metrics, offsets, scroll } = fake(14); + scroll.handleWheel(wheel(-14 * 2.5)); + expect(offsets.at(-1)).toBe(7); + metrics.height = 20; + scroll.update(); + expect(term.viewportY).toBe(2); + expect(offsets.at(-1)).toBe(10); + } finally { + g.window = saved; + } + }); +}); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 66cf9f8..283101d 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -1,16 +1,16 @@ /** - * Pixel-smooth scrollback scrolling. See ADR 040. + * Pixel-smooth scrollback scrolling. See ADR 040 and ADR 043. * * ghostty-web draws whole rows only: its viewport is a line count, and a fractional * viewportY is floored when rows are picked. A trackpad reports pixel deltas, so * scrolling steps a whole line at a time behind a short easing animation. * * This keeps the scroll position as fractional lines. ghostty-web renders the whole - * part as usual; the fraction becomes a CSS offset: the canvas moves down by - * `fraction x cellHeight` (its bottom edge clipped), and a one-row strip above it - * shows the scrollback line that slides into view, drawn with ghostty-web's own - * renderLine. At a whole-line position the canvas has no transform and the strip is - * hidden, so a resting terminal renders exactly as before. + * part as usual; the fraction becomes a pixel offset the renderer shows the viewport + * at: its canvas also holds rows above and below the viewport, and shows the + * viewport moved down by `fraction x cellHeight`, with the bottom of the row above + * sliding into view. At a whole-line position there is no offset, so a resting + * terminal renders exactly as before. * * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. @@ -44,32 +44,11 @@ export function wheelLines( return e.deltaY / cellHeight; } -type Cell = NonNullable>[number]; - -interface RendererInternals { - ctx: CanvasRenderingContext2D; - renderLine(line: Cell[], row: number, cols: number, grapheme: (col: number) => string): void; -} - export class SmoothScroll { /** Lines scrolled up from the bottom; fractional between rows. */ private position = 0; - private readonly strip: HTMLCanvasElement; - private readonly stripCtx: CanvasRenderingContext2D | null; - - constructor( - private readonly term: Terminal, - private readonly container: HTMLElement, - ) { - this.strip = document.createElement('canvas'); - this.stripCtx = this.strip.getContext('2d'); - this.strip.style.position = 'absolute'; - this.strip.style.pointerEvents = 'none'; - this.strip.style.display = 'none'; - // Excluded from the edge and graphics `canvas` lookups like the edge strips. - this.strip.dataset.edge = 'true'; - container.append(this.strip); + constructor(private readonly term: Terminal) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard // scrolling and scrollbar drags. Follow it whenever it lands on a different line. term.onScroll((y: number) => { @@ -95,7 +74,10 @@ export class SmoothScroll { if (this.term.viewportY === 0 && this.position !== 0) this.jump(0); } - /** Re-applies the offset after a fit moved or resized the canvas. */ + /** + * Re-splits the position after a fit: a DPR or font change moves the device-pixel + * grid and the cell height the offset was snapped to. + */ update(): void { this.apply(); } @@ -106,71 +88,17 @@ export class SmoothScroll { } private apply(): void { - const renderer = this.term.renderer as unknown as RendererInternals | undefined; - const metrics = this.term.renderer?.getMetrics(); - const canvas = this.container.querySelector('canvas:not([data-edge])'); - if (!renderer || !metrics || !canvas) return; + const renderer = this.term.renderer; + const metrics = renderer?.getMetrics(); + if (!renderer || !metrics) return; const { line, offset } = splitScroll( this.position, metrics.height, window.devicePixelRatio || 1, ); - // Changes ghostty-web's viewportY synchronously; its render loop redraws the rows on - // the next frame, the same frame the transform below lands in. + // Changes ghostty-web's viewportY synchronously. Its render loop draws the rows that + // came into view and shows the offset on the next frame. if (line !== this.term.viewportY) this.term.scrollToLine(line); - if (offset === 0) { - canvas.style.transform = ''; - canvas.style.clipPath = ''; - this.strip.style.display = 'none'; - return; - } - canvas.style.transform = `translateY(${offset}px)`; - canvas.style.clipPath = `inset(0 0 ${offset}px 0)`; - this.drawStrip(renderer, canvas, line, offset, metrics.height); - } - - /** Draws the scrollback line just above the viewport into the strip over the canvas' top edge. */ - private drawStrip( - renderer: RendererInternals, - canvas: HTMLCanvasElement, - line: number, - offset: number, - cellHeight: number, - ): void { - const ctx = this.stripCtx; - const sbOffset = this.term.getScrollbackLength() - line - 1; - const cells = this.term.getScrollbackLine(sbOffset); - if (!ctx || !cells) { - this.strip.style.display = 'none'; - return; - } - const dpr = this.term.renderer?.devicePixelRatio ?? 1; - const width = canvas.offsetWidth; - const w = Math.round(width * dpr); - const h = Math.round(cellHeight * dpr); - if (this.strip.width !== w) this.strip.width = w; - if (this.strip.height !== h) this.strip.height = h; - this.strip.style.width = `${width}px`; - this.strip.style.height = `${cellHeight}px`; - this.strip.style.left = `${canvas.offsetLeft}px`; - this.strip.style.top = `${canvas.offsetTop + offset - cellHeight}px`; - this.strip.style.clipPath = `inset(${cellHeight - offset}px 0 0 0)`; - this.strip.style.display = 'block'; - - // renderLine draws row `r` at y = r x cellHeight into renderer.ctx. Borrow it with the - // strip's context, shifted down a row so row -1 lands at y = 0. Row -1 is never in a - // selection, so the line draws unselected. Graphemes come from the same scrollback row. - ctx.setTransform(dpr, 0, 0, dpr, 0, cellHeight * dpr); - ctx.textBaseline = 'alphabetic'; - ctx.textAlign = 'left'; - const own = renderer.ctx; - renderer.ctx = ctx; - try { - renderer.renderLine(cells, -1, this.term.cols, (col) => - this.term.getScrollbackGraphemeString(sbOffset, col), - ); - } finally { - renderer.ctx = own; - } + renderer.setScrollOffset(offset); } } diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 2eaeeaf..daa7615 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -24,4 +24,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `renderer.ts`: the client runs either engine (ADR 042). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; on the official engine bell and title come from `readEffects` instead of the stream scan; `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours, and `buildWasmConfig` fills `colorsSet` from it; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder engine). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The official engine reports its real dirty state while scrolled. +- `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), a hyperlink hover change repaints the rows out of view too, and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 3aef10d..a16b33e 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -64,8 +64,23 @@ export interface RendererOptions { cursorBlink?: boolean; // Default: false theme?: ITheme; devicePixelRatio?: number; // Default: window.devicePixelRatio, rounded up and followed + overscan?: number; // Rows drawn above and below the viewport. Default: DEFAULT_OVERSCAN (webtty, ADR 043) } +/** + * Rows drawn above and below the viewport, so pixel-smooth scrolling moves the canvas + * across lines without drawing (webtty, ADR 043). At least 1: the row above the + * viewport shows while it is at a fraction of a line. + */ +export const DEFAULT_OVERSCAN = 16; + +// The state of a canvas row's pixels (webtty, ADR 043) +const ROW_INVALID = 0; +// Its own cells plus what the next line draws into it, as a full redraw leaves a row +const ROW_JOINED = 1; +// Its own cells only, as a full redraw leaves the last row +const ROW_CUT = 2; + /** * The ratio the canvas is drawn at: the device pixel ratio rounded up to a whole * number. A fractional ratio (browser zoom, Windows 125%/150%) would put cell edges @@ -122,6 +137,42 @@ export function frameLines( }; } +/** A selection: start and end cell, rows inclusive. */ +interface CellRange { + startCol: number; + startRow: number; + endCol: number; + endRow: number; +} + +/** A hovered regex link: start and end cell, rows inclusive. */ +interface LinkRange { + startX: number; + startY: number; + endX: number; + endY: number; +} + +/** A selection moved down by `rows` rows (up when negative). */ +function moveRows(range: CellRange | null, rows: number): CellRange | null { + return range && { ...range, startRow: range.startRow + rows, endRow: range.endRow + rows }; +} + +/** A link range moved down by `rows` rows (up when negative). */ +function moveRange(range: LinkRange | null, rows: number): LinkRange | null { + return range && { ...range, startY: range.startY + rows, endY: range.endY + rows }; +} + +function sameRange(a: CellRange | null, b: CellRange | null): boolean { + if (!a || !b) return a === b; + return ( + a.startCol === b.startCol && + a.startRow === b.startRow && + a.endCol === b.endCol && + a.endRow === b.endRow + ); +} + export interface FontMetrics { width: number; // Character cell width in CSS pixels height: number; // Character cell height in CSS pixels @@ -164,6 +215,11 @@ export const DEFAULT_THEME: Required = { // ============================================================================ export class CanvasRenderer { + // The terminal's canvas element: it takes the viewport's place in the layout and gets + // the mouse events, and draws nothing (webtty, ADR 043) + private element: HTMLCanvasElement; + // The canvas the rows are drawn on. It also holds the overscan rows, sits over the + // element, moved up by a transform, and is clipped to the element's box (webtty, ADR 043) private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private fontSize: number; @@ -179,8 +235,8 @@ export class CanvasRenderer { // Cursor blinking state private cursorVisible: boolean = true; private cursorBlinkInterval?: number; - // Where the last frame drew the cursor: its canvas row (-1: not drawn) and column (webtty) - private cursorRow: number = -1; + // Where the last frame drew the cursor: its canvas row (null: not drawn) and column (webtty) + private cursorRow: number | null = null; private cursorCol: number = 0; // Cursor style or blink changed since the last frame: repaint the cursor cell so // the old shape does not stay under the new one (webtty) @@ -190,11 +246,29 @@ export class CanvasRenderer { private lastViewportY: number = 0; private lastScrollbackLength: number = 0; + // The canvas holds `overscan` rows above and below the viewport and is moved and + // clipped to show the viewport's part of it. Canvas row k shows viewport row k - overscan at + // viewportY `bandY`, so a scroll within the band only moves the canvas + // (webtty, ADR 043). + private readonly overscan: number; + private bandY: number = 0; + // ROW_INVALID, ROW_JOINED or ROW_CUT per canvas row. Rows out of view stay invalid + // until they come into view. + private rowState: Uint8Array = new Uint8Array(0); + // The pixel offset below its whole line the viewport shows at, in CSS pixels (ADR 040) + private requestedScrollOffset: number = 0; + private shownScrollOffset: number = 0; + // CSS pixels from the canvas top to the top of what is shown + private shownTop: number = 0; + private shownRows: number = 0; + private shownTransform: string = ''; + private shownClip: string = ''; + // The first canvas row the last frame painted the scrollbar over (null: none) + private scrollbarRow: number | null = null; + // The canvas no longer matches its rows (theme, font or ratio change); the next frame // repaints every row (webtty, ADR 043) private canvasStale: boolean = false; - // The last frame painted the scrollbar over the right edge of the rows (webtty, ADR 043) - private scrollbarPainted: boolean = false; // The browser restored a lost context: the backing store is blank and the context // state reset, so the next frame resizes and repaints every row (webtty, ADR 043) private contextRestored: boolean = false; @@ -212,38 +286,44 @@ export class CanvasRenderer { // Selection manager (for rendering selection) private selectionManager?: SelectionManager; - // Cached selection coordinates for current render pass (viewport-relative) - private currentSelectionCoords: { - startCol: number; - startRow: number; - endCol: number; - endRow: number; - } | null = null; + // Cached selection coordinates for current render pass, in canvas rows and not + // clamped to the viewport (webtty, ADR 043) + private currentSelectionCoords: CellRange | null = null; + // The selection the canvas rows were drawn with, in canvas rows (webtty, ADR 043) + private drawnSelection: CellRange | null = null; // Link rendering state private hoveredHyperlinkId: number = 0; private previousHoveredHyperlinkId: number = 0; - // Regex link hover tracking (for links without hyperlink_id) + // Regex link hover tracking (for links without hyperlink_id), in viewport rows private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null = null; - private previousHoveredLinkRange: { - startX: number; - startY: number; - endX: number; - endY: number; - } | null = null; + // The regex link range this frame draws and the one the canvas rows were drawn with, + // in canvas rows (webtty, ADR 043) + private currentLinkRange: LinkRange | null = null; + private drawnLinkRange: LinkRange | null = null; constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { - this.canvas = canvas; - const ctx = canvas.getContext('2d', { alpha: true }); + this.element = canvas; + // Positioned with no offsets, the drawing canvas sits where the element's box starts + // (its static position), wherever padding moves it. Positioned, it paints over the + // element, and lets the mouse through to it. (webtty, ADR 043) + this.canvas = document.createElement('canvas'); + this.canvas.style.position = 'absolute'; + this.canvas.style.pointerEvents = 'none'; + this.canvas.style.display = 'block'; + // Excluded from the lookups of the terminal canvas, like the edge strips + this.canvas.dataset.edge = 'true'; + canvas.parentNode?.insertBefore(this.canvas, canvas); + const ctx = this.canvas.getContext('2d', { alpha: true }); if (!ctx) { throw new Error('Failed to get 2D rendering context'); } this.ctx = ctx; // Frames only draw what changed, so a canvas the browser blanked would stay blank // (webtty, ADR 043) - canvas.addEventListener('contextrestored', this.onContextRestored); + this.canvas.addEventListener('contextrestored', this.onContextRestored); this.scratch = document.createElement('canvas'); this.scratch.width = 0; this.scratch.height = 0; @@ -253,6 +333,7 @@ export class CanvasRenderer { this.fontFamily = options.fontFamily ?? 'monospace'; this.cursorStyle = options.cursorStyle ?? 'block'; this.cursorBlink = options.cursorBlink ?? false; + this.overscan = Math.max(1, Math.floor(options.overscan ?? DEFAULT_OVERSCAN)); this.theme = { ...DEFAULT_THEME, ...options.theme }; this._devicePixelRatio = wholeDevicePixelRatio( options.devicePixelRatio ?? window.devicePixelRatio @@ -355,13 +436,19 @@ export class CanvasRenderer { const cssWidth = cols * this.metrics.width; const cssHeight = rows * this.metrics.height; - // Set CSS size (what user sees) - this.canvas.style.width = `${cssWidth}px`; - this.canvas.style.height = `${cssHeight}px`; + // Set CSS size (what user sees). The element draws nothing. (webtty, ADR 043) + this.element.style.width = `${cssWidth}px`; + this.element.style.height = `${cssHeight}px`; + this.element.width = 0; + this.element.height = 0; - // Set actual canvas size (scaled for DPI) + // Set actual canvas size (scaled for DPI). The canvas also holds the overscan rows; + // render() moves and clips it to show the viewport's rows. (webtty, ADR 043) + const canvasHeight = (rows + 2 * this.overscan) * this.metrics.height; + this.canvas.style.width = `${cssWidth}px`; + this.canvas.style.height = `${canvasHeight}px`; this.canvas.width = Math.round(cssWidth * this.devicePixelRatio); - this.canvas.height = Math.round(cssHeight * this.devicePixelRatio); + this.canvas.height = Math.round(canvasHeight * this.devicePixelRatio); // Scale context to match DPI (setting canvas.width/height resets the context) this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); @@ -372,7 +459,10 @@ export class CanvasRenderer { // Fill background after resize this.ctx.fillStyle = this.theme.background; - this.ctx.fillRect(0, 0, cssWidth, cssHeight); + this.ctx.fillRect(0, 0, cssWidth, canvasHeight); + // Free the scratch canvas' backing store; the next partial repaint sizes it + this.scratch.width = 0; + this.scratch.height = 0; this.canvasStale = true; } @@ -383,11 +473,13 @@ export class CanvasRenderer { /** * Render the terminal buffer to canvas * - * Only the canvas rows that changed are repainted. When the viewport moves by whole - * lines and nothing else changed, the rows still in view are copied to their new place - * with one drawImage of the canvas onto itself and only the rows that came into view - * are drawn. A partial frame leaves the canvas exactly as a full redraw would (webtty, - * ADR 043; see repaintRows). + * The canvas holds `overscan` rows above and below the viewport, and is moved and + * clipped to show the viewport's part of it. A scroll within those rows only moves the + * canvas; one past them moves the canvas content with one + * drawImage of the canvas onto itself. Only rows that come into view or changed are + * drawn, and rows out of view are drawn when they come into view. What the element + * shows is exactly a full redraw of the viewport, plus at a fraction of a line the row + * above it drawn on its own. (webtty, ADR 043; see repaintRows) */ public render( buffer: IRenderable, @@ -409,6 +501,9 @@ export class CanvasRenderer { const getLine = frameLines(buffer, dims.cols, dims.rows); this.currentLine = getLine; const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; + const rows = dims.rows; + const total = rows + 2 * this.overscan; + const view = Math.max(0, Math.floor(viewportY)); // Check if buffer needs full redraw (e.g., screen change between normal/alternate) if (buffer.needsFullRedraw?.()) { @@ -419,13 +514,17 @@ export class CanvasRenderer { const needsResize = this.contextRestored || this.canvas.width !== Math.round(dims.cols * this.metrics.width * this.devicePixelRatio) || - this.canvas.height !== Math.round(dims.rows * this.metrics.height * this.devicePixelRatio); + this.canvas.height !== Math.round(total * this.metrics.height * this.devicePixelRatio); if (needsResize) { this.resize(dims.cols, dims.rows); this.contextRestored = false; forceAll = true; // Force full render after resize } + if (this.rowState.length !== total) { + this.rowState = new Uint8Array(total); + forceAll = true; + } // A theme, font or ratio change since the last frame changes every row (webtty, ADR 043) if (this.canvasStale) { @@ -433,215 +532,171 @@ export class CanvasRenderer { this.canvasStale = false; } - // The scrollbar is painted over the right edge of every row, so a frame that does not - // paint it repaints every row: none keeps the old thumb (webtty, ADR 043) - const paintsScrollbar = !!scrollbackProvider && scrollbarOpacity > 0; - if (this.scrollbarPainted && !paintsScrollbar) { - forceAll = true; - } - - // Check if we need to redraw selection-related lines - const hasSelection = this.selectionManager && this.selectionManager.hasSelection(); - const selectionRows = new Set(); - - // Cache selection coordinates for use during cell rendering - // This is used by isInSelection() to determine if a cell needs selection colors - this.currentSelectionCoords = hasSelection ? this.selectionManager!.getSelectionCoords() : null; - - // Mark current selection rows for redraw (includes programmatic selections) - if (this.currentSelectionCoords) { - const coords = this.currentSelectionCoords; - for (let row = coords.startRow; row <= coords.endRow; row++) { - selectionRows.add(row); + // Rows the engine changed, in screen rows + const dirtyRows: number[] = []; + if (!forceAll) { + for (let y = 0; y < rows; y++) { + if (buffer.isRowDirty(y)) dirtyRows.push(y); } } - // Always mark dirty selection rows for redraw (to clear old overlay). They were - // marked at the viewport of their time, so a viewport move with any of them - // repaints every row (webtty, ADR 043) - let selectionDirty = false; - if (this.selectionManager) { - const dirtyRows = this.selectionManager.getDirtySelectionRows(); - if (dirtyRows.size > 0) { - selectionDirty = true; - for (const row of dirtyRows) { - selectionRows.add(row); - } - // Clear the dirty rows tracking after marking for redraw - this.selectionManager.clearDirtySelectionRows(); - } + // A fractional viewportY (ghostty-web's own eased scroll) repaints every row + const moved = viewportY !== this.lastViewportY; + if (moved && !(Number.isInteger(viewportY) && Number.isInteger(this.lastViewportY))) { + forceAll = true; } + this.lastViewportY = viewportY; + // New scrollback or a changed row may have moved every line: the screen scrolled. + // While scrolled, or on a move, that repaints every row. At the bottom, the screen's + // rows are repainted as dirty and the rows out of view when they come into view. + const linesMoved = scrollbackLength !== this.lastScrollbackLength || dirtyRows.length > 0; + if (linesMoved && (view > 0 || moved)) forceAll = true; + this.lastScrollbackLength = scrollbackLength; - // Track rows with hyperlinks that need redraw when hover changes - const hyperlinkRows = new Set(); - const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; - const linkRangeChanged = - JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange); - // The regex link underline the last frame drew (webtty, ADR 043) - const drawnLinkRange = this.previousHoveredLinkRange; - - if (hyperlinkChanged) { - // Find rows containing the old or new hovered hyperlink - // Must check the correct buffer based on viewportY (scrollback vs screen) - for (let y = 0; y < dims.rows; y++) { - let line: GhosttyCell[] | null = null; - - // Same logic as rendering: fetch from scrollback or screen - if (viewportY > 0) { - if (y < viewportY && scrollbackProvider) { - // This row is from scrollback - // Floor viewportY for array access (handles fractional values during smooth scroll) - const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; - line = scrollbackProvider.getScrollbackLine(scrollbackOffset); - } else { - // This row is from visible screen - const screenRow = y - Math.floor(viewportY); - line = getLine(screenRow); - } - } else { - // At bottom - fetch from visible screen - line = getLine(y); - } - - if (line) { - for (const cell of line) { - if ( - cell.hyperlink_id === this.hoveredHyperlinkId || - cell.hyperlink_id === this.previousHoveredHyperlinkId - ) { - hyperlinkRows.add(y); - break; // Found hyperlink in this row - } - } + // The row above the viewport shows while it is at a fraction of a line (ADR 040) + // An offset set for a taller cell (before a font change) shows at most a line. + const maxOffset = this.metrics.height - 1 / this.devicePixelRatio; + const offset = view < scrollbackLength ? Math.min(this.requestedScrollOffset, maxOffset) : 0; + const above = offset > 0 ? 1 : 0; + + // Keep the canvas rows while the rows in view are among them. Otherwise draw them for + // a viewportY half the overscan further in the direction of the move, so the next + // moves that way stay within them, and copy the rows still in view. + const fits = (band: number) => + band - view <= this.overscan && this.overscan + band - view - above >= 0; + let band = this.bandY; + if (forceAll) { + band = view; + } else if (!fits(band)) { + const ahead = Math.sign(view - band) * Math.floor(this.overscan / 2); + band = Math.max(0, Math.min(Math.max(scrollbackLength, view), view + ahead)); + if (!fits(band)) band = view; + const shift = band - this.bandY; + if (Math.abs(shift) < total) { + this.shiftRows(shift); + const state = new Uint8Array(total); + for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { + state[k + shift] = this.rowState[k]; } + this.rowState = state; + if (this.cursorRow !== null) this.cursorRow += shift; + if (this.scrollbarRow !== null) this.scrollbarRow += shift; + this.drawnSelection = moveRows(this.drawnSelection, shift); + this.drawnLinkRange = moveRange(this.drawnLinkRange, shift); + } else { + forceAll = true; } - // Update previous state - this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; } - - // Track rows affected by link range changes (for regex URLs) - if (linkRangeChanged) { - // Add rows from old range - if (this.previousHoveredLinkRange) { - for ( - let y = this.previousHoveredLinkRange.startY; - y <= this.previousHoveredLinkRange.endY; - y++ - ) { - hyperlinkRows.add(y); - } - } - // Add rows from new range - if (this.hoveredLinkRange) { - for (let y = this.hoveredLinkRange.startY; y <= this.hoveredLinkRange.endY; y++) { - hyperlinkRows.add(y); + this.bandY = band; + // The canvas row of viewport row 0 + const top = this.overscan + band - view; + + const state = this.rowState; + if (forceAll) state.fill(ROW_INVALID); + // A row whose own pixels changed (an overlay over it) + const invalidate = (k: number) => { + if (k >= 0 && k < total) state[k] = ROW_INVALID; + }; + // A row whose content or highlight changed: the row above may hold its overflow + const changed = (k: number) => { + invalidate(k); + if (k > 0 && k <= total && state[k - 1] === ROW_JOINED) state[k - 1] = ROW_INVALID; + }; + if (!forceAll) { + for (const y of dirtyRows) changed(top + y); + if (linesMoved) { + for (let k = 0; k < total; k++) { + if (k < top || k >= top + rows) state[k] = ROW_INVALID; } } - this.previousHoveredLinkRange = this.hoveredLinkRange; } - // Rows the engine changed. They are screen rows, which are canvas rows only at the - // bottom; while scrolled, any change repaints every row. (webtty, ADR 043) - const dirtyRows: number[] = []; - if (!forceAll) { - for (let y = 0; y < dims.rows; y++) { - if (buffer.isRowDirty(y)) dirtyRows.push(y); + // The selection, in canvas rows. It is anchored to buffer lines like the canvas rows, + // so it only repaints rows when it changed. (webtty, ADR 043) + const selection = + this.selectionManager && this.selectionManager.hasSelection() + ? this.selectionManager.getSelectionRange() + : null; + this.currentSelectionCoords = moveRows(selection, top); + if (!sameRange(this.currentSelectionCoords, this.drawnSelection)) { + for (const range of [this.drawnSelection, this.currentSelectionCoords]) { + if (!range) continue; + const last = Math.min(range.endRow, total - 1); + for (let k = Math.max(0, range.startRow); k <= last; k++) changed(k); } } - - // Canvas rows the kept rows moved down by (up when negative) this frame - let shift = 0; - if (viewportY !== this.lastViewportY) { - // The viewport moved. When only the viewport moved, by whole lines and by less than - // a screen, copy the rows still in view to their new place; otherwise repaint - // every row as before. (webtty, ADR 043) - const moved = viewportY - this.lastViewportY; - const onlyViewportMoved = - !forceAll && - Number.isInteger(viewportY) && - Number.isInteger(this.lastViewportY) && - Math.abs(moved) < dims.rows && - scrollbackLength === this.lastScrollbackLength && - dirtyRows.length === 0 && - !selectionDirty; - if (onlyViewportMoved) { - this.shiftRows(moved); - shift = moved; - } else { - forceAll = true; + this.drawnSelection = this.currentSelectionCoords; + this.selectionManager?.clearDirtySelectionRows(); + + // Rows with the old or new hovered hyperlink. Rows not drawn yet are drawn with it. + const rowAt = (k: number) => + this.rowAt(k - this.overscan, band, rows, getLine, buffer, scrollbackProvider, scrollbackLength); + if (this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId) { + for (let k = 0; k < total; k++) { + if (state[k] === ROW_INVALID) continue; + const line = rowAt(k).line; + if ( + line?.some( + (cell) => + cell.hyperlink_id === this.hoveredHyperlinkId || + cell.hyperlink_id === this.previousHoveredHyperlinkId + ) + ) { + changed(k); + } } - this.lastViewportY = viewportY; - } else if ( - viewportY > 0 && - (dirtyRows.length > 0 || scrollbackLength !== this.lastScrollbackLength) - ) { - forceAll = true; + this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; } - this.lastScrollbackLength = scrollbackLength; - // Canvas rows whose pixels are repainted. A row whose content or highlight changed - // also repaints the row above, which its glyphs may overflow into. - const rows = dims.rows; - const repaint = new Uint8Array(rows); - const region = (y: number) => { - if (y >= 0 && y < rows) repaint[y] = 1; - }; - const changed = (y: number) => { - region(y); - region(y - 1); - }; - if (forceAll) { - repaint.fill(1); - } else { - for (const y of dirtyRows) changed(y); - for (const y of selectionRows) changed(y); - for (const y of hyperlinkRows) changed(y); - if (shift > 0) { - // Rows came into view at the top. The last row lost the row below it, whose - // glyphs overflowed into it. - for (let y = 0; y < shift; y++) changed(y); - region(rows - 1); - } else if (shift < 0) { - // Rows came into view at the bottom - for (let y = rows + shift; y < rows; y++) changed(y); - } - if (shift !== 0) { - // A regex link underline is drawn at fixed canvas rows, so it did not move with - // the rows: clear it where the copy moved it and draw it where it is. - for (const range of [drawnLinkRange, this.hoveredLinkRange]) { - if (!range) continue; - for (let y = range.startY; y <= range.endY; y++) { - changed(y); - changed(y + shift); - } - } + // A regex link underline is drawn at viewport rows, which are other canvas rows after a + // scroll: clear it where it was drawn and draw it where it is. + this.currentLinkRange = moveRange(this.hoveredLinkRange, top); + if (JSON.stringify(this.currentLinkRange) !== JSON.stringify(this.drawnLinkRange)) { + for (const range of [this.drawnLinkRange, this.currentLinkRange]) { + if (!range) continue; + for (let k = range.startY; k <= range.endY; k++) changed(k); } } + this.drawnLinkRange = this.currentLinkRange; // The cursor is drawn over its cell after the rows. Repaint the cell the last frame's // cursor is in now unless the same cursor is drawn there again. - const drawsCursor = viewportY === 0 && cursor.visible && this.cursorVisible; + const drawsCursor = view === 0 && cursor.visible && this.cursorVisible; + const cursorRow = top + cursor.y; let sameCursor = false; - if (this.cursorRow >= 0) { - const row = this.cursorRow + shift; + if (this.cursorRow !== null) { sameCursor = - drawsCursor && row === cursor.y && cursor.x === this.cursorCol && !this.cursorShapeChanged; - if (!sameCursor) region(row); + drawsCursor && + this.cursorRow === cursorRow && + cursor.x === this.cursorCol && + !this.cursorShapeChanged; + if (!sameCursor) invalidate(this.cursorRow); } this.cursorShapeChanged = false; - // The same cursor over an unrepainted row is still on the canvas. Drawing it again - // would blend a translucent cursor colour over itself until it is opaque. - const cursorKept = sameCursor && !repaint[cursor.y]; - - // Repaint each run of rows - for (let y = 0; y < rows; y++) { - if (!repaint[y]) continue; - let end = y; - while (end + 1 < rows && repaint[end + 1]) end++; - this.repaintRows(y, end, dims.cols, rows, (row) => - this.rowAt(row, viewportY, getLine, buffer, scrollbackProvider, scrollbackLength) - ); - y = end; + + // The scrollbar is painted over the right edge of the rows in view. Rows it no longer + // covers are repainted, so none keeps the old thumb. + const paintsScrollbar = !!scrollbackProvider && scrollbarOpacity > 0; + const scrollbarRow = paintsScrollbar ? top : null; + if (this.scrollbarRow !== null && this.scrollbarRow !== scrollbarRow) { + for (let k = this.scrollbarRow; k < this.scrollbarRow + rows; k++) { + if (scrollbarRow === null || k < scrollbarRow || k >= scrollbarRow + rows) invalidate(k); + } + } + + // Repaint the rows in view that are not as a full redraw of the viewport leaves them: + // the last row and the row above the viewport have nothing drawn into them from below. + const first = top - above; + const last = top + rows - 1; + const wanted = (k: number) => (k === last || k < top ? ROW_CUT : ROW_JOINED); + const repainted = new Uint8Array(total); + for (let k = first; k <= last; k++) { + if (state[k] === wanted(k)) continue; + let end = k; + while (end < last && wanted(end) === ROW_JOINED && state[end + 1] !== wanted(end + 1)) end++; + this.repaintRows(k, end, wanted(end) === ROW_CUT, dims.cols, rowAt); + repainted.fill(1, k, end + 1); + k = end; } // Selection highlighting is now integrated into renderCellBackground/renderCellText @@ -649,18 +704,38 @@ export class CanvasRenderer { // Link underlines are drawn during cell rendering (see renderCell) - // Render cursor (only if we're at the bottom, not scrolled) - if (drawsCursor && !cursorKept) { - this.renderCursor(cursor.x, cursor.y); + // Render cursor (only if we're at the bottom, not scrolled). The same cursor over a + // row that was not repainted is still on the canvas: drawing it again would blend a + // translucent cursor colour over itself until it is opaque. + if (drawsCursor && !(sameCursor && !repainted[cursorRow])) { + this.renderCursor(cursor.x, cursorRow, cursor.y); } - this.cursorRow = drawsCursor ? cursor.y : -1; + this.cursorRow = drawsCursor ? cursorRow : null; this.cursorCol = cursor.x; // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) if (paintsScrollbar) { - this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity); + this.renderScrollbar(viewportY, scrollbackLength, rows, scrollbarOpacity, top); + } + this.scrollbarRow = scrollbarRow; + + // Show the viewport's rows, moved down by the offset: move the canvas up by what is + // above them and clip it to the element's box. A transform places it to the device + // pixel; object-position would round to CSS pixels. + this.shownScrollOffset = offset; + this.shownTop = top * this.metrics.height - offset; + this.shownRows = rows; + const below = (total - rows) * this.metrics.height - this.shownTop; + const transform = `translateY(${-this.shownTop}px)`; + const clip = `inset(${this.shownTop}px 0px ${below}px 0px)`; + if (transform !== this.shownTransform) { + this.canvas.style.transform = transform; + this.shownTransform = transform; + } + if (clip !== this.shownClip) { + this.canvas.style.clipPath = clip; + this.shownClip = clip; } - this.scrollbarPainted = paintsScrollbar; // Without scrollback rows a scrolled frame left its top rows unpainted if (viewportY > 0 && !scrollbackProvider) this.canvasStale = true; @@ -672,79 +747,78 @@ export class CanvasRenderer { } /** - * The line shown at canvas row `y`, from scrollback or the screen, with a grapheme - * lookup on the same source: canvas row y is not screen row y while scrolled. (webtty) + * The line at viewport row `y` for `viewportY`, from scrollback or the screen, with a + * grapheme lookup on the same source: canvas row y is not screen row y while scrolled. + * Null above the scrollback and below the screen. (webtty) */ private rowAt( y: number, viewportY: number, + rows: number, getLine: (y: number) => GhosttyCell[] | null, buffer: IRenderable, scrollbackProvider: IScrollbackProvider | undefined, scrollbackLength: number ): { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } { - if (viewportY > 0) { - // Scrolled up - need to fetch from scrollback + visible screen - // When scrolled up N lines, we want to show: - // - Scrollback lines (from the end) + visible screen lines - - // Check if this row should come from scrollback or visible screen - if (y < viewportY && scrollbackProvider) { - // This row is from scrollback (upper part of viewport) - // Get from end of scrollback buffer - // Floor viewportY for array access (handles fractional values during smooth scroll) - const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; - return { - line: scrollbackProvider.getScrollbackLine(scrollbackOffset), - grapheme: this.scrollbackGraphemes(scrollbackProvider, scrollbackOffset), - }; - } - // This row is from visible screen (lower part of viewport) - const screenRow = y - Math.floor(viewportY); - return { line: getLine(screenRow), grapheme: this.screenGraphemes(buffer, screenRow) }; + if (y < viewportY) { + // This row is from scrollback, counted from its end + const scrollbackOffset = scrollbackLength - viewportY + y; + if (!scrollbackProvider || scrollbackOffset < 0) return { line: null }; + return { + line: scrollbackProvider.getScrollbackLine(scrollbackOffset), + grapheme: this.scrollbackGraphemes(scrollbackProvider, scrollbackOffset), + }; } - // At bottom - fetch from visible screen - return { line: getLine(y) }; + // This row is from the screen + const screenRow = y - viewportY; + if (screenRow >= rows) return { line: null }; + return { line: getLine(screenRow), grapheme: this.screenGraphemes(buffer, screenRow) }; } /** * Repaints canvas rows `first` to `last` exactly as a full redraw leaves them. A full * redraw draws every row in order, each clearing its own cell area first, so a row's - * pixels are its own cells plus whatever the row below draws over it (tall glyphs, + * pixels are its own cells plus whatever the next line draws over it (tall glyphs, * combining marks); what the row above overflows into it is cleared. So the rows are - * drawn in order, plus the row below the last. + * drawn in order, plus the next line unless the last row is `cut`: drawn with its own + * cells only, as the last row of a full redraw is. * - * A run short of the whole canvas is drawn on the scratch canvas and its rows copied - * into the canvas. Drawing it clipped to the run is not the same: Chrome skips a glyph - * whose box is outside the clip, so a combining mark of the row below loses the - * antialiased edge it draws on the run's last pixel line. (webtty, ADR 043) + * The rows are drawn on the scratch canvas and copied into the canvas, unless they are + * cut and the rows they draw into around them are invalid anyway. A run that is not cut + * ends before a row that is kept. Drawing them clipped to the rows is + * not the same: Chrome skips a glyph whose box is outside the clip, so a combining mark + * of the next line loses the antialiased edge it draws on the last pixel line. + * (webtty, ADR 043) */ private repaintRows( first: number, last: number, + cut: boolean, cols: number, - rows: number, - rowAt: (y: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + rowAt: (k: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } ): void { - const whole = first === 0 && last === rows - 1; + const state = this.rowState; + const total = state.length; + const end = cut ? last : last + 1; + const free = (k: number) => k < 0 || k >= total || state[k] === ROW_INVALID; + // Drawn on the canvas, the row above gets the first row's overflow and the row below + // the last one's + const direct = cut && free(first - 1) && free(last + 1); const own = this.ctx; - const scratch = whole ? null : this.scratchContext(); + const rowHeight = this.metrics.height * this.devicePixelRatio; + const scratch = direct ? null : this.scratchContext(first, end - first + 1); if (scratch) this.ctx = scratch; try { - for (let y = first; y <= Math.min(last + 1, rows - 1); y++) { - const { line, grapheme } = rowAt(y); - if (line) { - this.renderLine(line, y, cols, grapheme); - } - } + for (let k = first; k <= end; k++) this.drawRow(k, cols, rowAt(k)); } finally { this.ctx = own; } - if (!scratch) return; + for (let k = first; k <= last; k++) state[k] = ROW_JOINED; + state[last] = cut ? ROW_CUT : ROW_JOINED; + if (direct) return; // Replace the rows' pixels in device pixels: cleared first, the copy is not blended // over them, so a translucent background does not darken const width = this.canvas.width; - const rowHeight = this.metrics.height * this.devicePixelRatio; const top = first * rowHeight; const height = (last - first + 1) * rowHeight; own.save(); @@ -753,21 +827,44 @@ export class CanvasRenderer { own.globalCompositeOperation = 'source-over'; own.imageSmoothingEnabled = false; own.clearRect(0, top, width, height); - own.drawImage(this.scratch, 0, top, width, height, 0, top, width, height); + own.drawImage(this.scratch, 0, 0, width, height, 0, top, width, height); own.restore(); } - /** The scratch canvas' context, sized and scaled like the canvas. (webtty, ADR 043) */ - private scratchContext(): CanvasRenderingContext2D { - const { width, height } = this.canvas; + /** Draws canvas row `k`; a row with no line (past the buffer's ends) is left blank. */ + private drawRow( + k: number, + cols: number, + { line, grapheme }: { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + ): void { + if (line) { + this.renderLine(line, k, cols, grapheme); + return; + } + const y = k * this.metrics.height; + const width = cols * this.metrics.width; + this.ctx.clearRect(0, y, width, this.metrics.height); + this.ctx.fillStyle = this.theme.background; + this.ctx.fillRect(0, y, width, this.metrics.height); + } + + /** + * The scratch canvas' context, as wide as the canvas and at least `count` rows high, + * scaled like the canvas and moved up so canvas row `first` is drawn at its top. + * (webtty, ADR 043) + */ + private scratchContext(first: number, count: number): CanvasRenderingContext2D { + const rowHeight = this.metrics.height * this.devicePixelRatio; + const width = this.canvas.width; + const height = count * rowHeight; if (this.scratch.width !== width) this.scratch.width = width; - if (this.scratch.height !== height) this.scratch.height = height; + if (this.scratch.height < height) this.scratch.height = height; const ctx = this.scratch.getContext('2d', { alpha: true }); if (!ctx) { throw new Error('Failed to get 2D rendering context'); } // Set every time: resizing, or a lost context, resets the context state - ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, 0); + ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, -first * rowHeight); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; return ctx; @@ -777,7 +874,7 @@ export class CanvasRenderer { * Moves the canvas content down by `rows` rows (up when negative) with one drawImage of * the canvas onto itself, in device pixels. The 'copy' operator replaces pixels instead * of blending onto them (a translucent background would darken) and clears the rest of - * the canvas: the rows that came into view, which are all repainted. (webtty, ADR 043) + * the canvas: the rows that came in, which are invalid. (webtty, ADR 043) */ private shiftRows(rows: number): void { const { width, height } = this.canvas; @@ -1014,9 +1111,9 @@ export class CanvasRenderer { } } - // Draw regex link underline (for plain text URLs) - if (this.hoveredLinkRange) { - const range = this.hoveredLinkRange; + // Draw regex link underline (for plain text URLs), in canvas rows (webtty, ADR 043) + if (this.currentLinkRange) { + const range = this.currentLinkRange; // Check if this cell is within the hovered link range const isInRange = (y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) || @@ -1036,9 +1133,9 @@ export class CanvasRenderer { } /** - * Render cursor + * Render cursor at canvas row `y`, which shows screen row `screenRow` (webtty) */ - private renderCursor(x: number, y: number): void { + private renderCursor(x: number, y: number, screenRow: number): void { const cursorX = x * this.metrics.width; const cursorY = y * this.metrics.height; @@ -1050,13 +1147,20 @@ export class CanvasRenderer { this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); // Re-draw character under cursor with cursorAccent color { - const line = this.currentLine(y); + const line = this.currentLine(screenRow); if (line?.[x]) { this.ctx.save(); this.ctx.beginPath(); this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height); this.ctx.clip(); - this.renderCellText(line[x], x, y, this.theme.cursorAccent); + // The grapheme of the screen row, not of the canvas row (webtty, ADR 043) + this.renderCellText( + line[x], + x, + y, + this.theme.cursorAccent, + this.screenGraphemes(this.currentBuffer, screenRow) + ); this.ctx.restore(); } } @@ -1181,15 +1285,18 @@ export class CanvasRenderer { * Render scrollbar (Phase 2) * Shows scroll position and allows click/drag interaction * @param opacity Opacity level (0-1) for fade in/out effect + * @param row The canvas row of viewport row 0: it is painted over the rows in view (webtty) */ private renderScrollbar( viewportY: number, scrollbackLength: number, visibleRows: number, - opacity: number = 1 + opacity: number = 1, + row: number = 0 ): void { const ctx = this.ctx; - const canvasHeight = this.canvas.height / this.devicePixelRatio; + const top = row * this.metrics.height; + const canvasHeight = visibleRows * this.metrics.height; const canvasWidth = this.canvas.width / this.devicePixelRatio; // Scrollbar dimensions @@ -1199,9 +1306,9 @@ export class CanvasRenderer { const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; // Always clear the scrollbar area first (fixes ghosting when fading out) - ctx.clearRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight); + ctx.clearRect(scrollbarX - 2, top, scrollbarWidth + 6, canvasHeight); ctx.fillStyle = this.theme.background; - ctx.fillRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight); + ctx.fillRect(scrollbarX - 2, top, scrollbarWidth + 6, canvasHeight); // Don't draw scrollbar if fully transparent or no scrollback if (opacity <= 0 || scrollbackLength === 0) return; @@ -1216,13 +1323,13 @@ export class CanvasRenderer { // Draw scrollbar track (subtle background) with opacity ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; - ctx.fillRect(scrollbarX, scrollbarPadding, scrollbarWidth, scrollbarTrackHeight); + ctx.fillRect(scrollbarX, top + scrollbarPadding, scrollbarWidth, scrollbarTrackHeight); // Draw scrollbar thumb with opacity const isScrolled = viewportY > 0; const baseOpacity = isScrolled ? 0.5 : 0.3; ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; - ctx.fillRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight); + ctx.fillRect(scrollbarX, top + thumbY, scrollbarWidth, thumbHeight); } public getMetrics(): FontMetrics { return { ...this.metrics }; @@ -1232,7 +1339,45 @@ export class CanvasRenderer { * Get canvas element (needed by SelectionManager) */ public getCanvas(): HTMLCanvasElement { - return this.canvas; + return this.element; + } + + /** + * Sets the pixel offset below its whole line the viewport shows at, in CSS pixels + * snapped to device pixels, for pixel-smooth scrolling. The next frame moves what the + * element shows by it. (webtty, ADR 040, ADR 043) + */ + public setScrollOffset(offset: number): void { + this.requestedScrollOffset = offset; + } + + /** + * The offset the viewport is shown at, in CSS pixels: viewport row 0 starts + * this far below the element's top. Hit testing adds it. (webtty, ADR 043) + */ + public get scrollOffset(): number { + return this.shownScrollOffset; + } + + /** + * The canvas the rows are drawn on and the part of it in view, in canvas pixels; the + * rest is overscan. `lastLine` is the bottom pixel line of the viewport's last row, + * which at a fraction of a line is below the part in view. (webtty, ADR 043) + */ + public get visibleArea(): { + canvas: HTMLCanvasElement; + y: number; + height: number; + lastLine: number; + } { + const dpr = this.devicePixelRatio; + const bottom = this.shownTop + this.shownScrollOffset + this.shownRows * this.metrics.height; + return { + canvas: this.canvas, + y: Math.round(this.shownTop * dpr), + height: this.shownRows * this.metrics.height * dpr, + lastLine: Math.round(bottom * dpr) - 1, + }; } /** @@ -1327,6 +1472,7 @@ export class CanvasRenderer { this.stopCursorBlink(); this.stopWatchingDevicePixelRatio?.(); this.canvas.removeEventListener('contextrestored', this.onContextRestored); + this.canvas.remove?.(); // Free the scratch canvas' backing store this.scratch.width = 0; this.scratch.height = 0; diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 56d4605..69b421e 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -363,6 +363,31 @@ export class SelectionManager { return this.normalizeSelection(); } + /** + * The selection in viewport rows like getSelectionCoords, but not clamped to the + * viewport: rows above it are negative. The renderer also draws rows out of view. + * (webtty, ADR 043) + */ + getSelectionRange(): SelectionCoordinates | null { + if (!this.selectionStart || !this.selectionEnd) return null; + + let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; + let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; + + // Swap if selection goes backwards + if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { + [startCol, endCol] = [endCol, startCol]; + [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; + } + + return { + startCol, + startRow: this.absoluteRowToViewport(startAbsRow), + endCol, + endRow: this.absoluteRowToViewport(endAbsRow), + }; + } + /** * Get dirty selection rows that need redraw (for clearing old highlight) */ @@ -852,7 +877,8 @@ export class SelectionManager { const metrics = this.renderer.getMetrics(); const col = Math.floor(x / metrics.width); - const row = Math.floor(y / metrics.height); + // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) + const row = Math.floor((y - this.renderer.scrollOffset) / metrics.height); // Clamp to terminal bounds return { @@ -866,20 +892,9 @@ export class SelectionManager { * Returns coordinates in VIEWPORT space for rendering, clamped to visible area */ private normalizeSelection(): SelectionCoordinates | null { - if (!this.selectionStart || !this.selectionEnd) return null; - - let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; - let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; - - // Swap if selection goes backwards - if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { - [startCol, endCol] = [endCol, startCol]; - [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; - } - - // Convert to viewport coordinates - let startRow = this.absoluteRowToViewport(startAbsRow); - let endRow = this.absoluteRowToViewport(endAbsRow); + const range = this.getSelectionRange(); + if (!range) return null; + let { startCol, startRow, endCol, endRow } = range; // Clamp to visible viewport range const dims = this.wasmTerm.getDimensions(); diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 325e853..07d4e91 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -382,10 +382,9 @@ export class Terminal implements ITerminalCore { // Resize canvas to match new font metrics this.renderer.resize(this.cols, this.rows); - // Update canvas element dimensions to match renderer + // Update canvas element dimensions to match renderer. The element draws nothing: the + // renderer draws on a canvas of its own (webtty, ADR 043) const metrics = this.renderer.getMetrics(); - this.canvas.width = metrics.width * this.cols; - this.canvas.height = metrics.height * this.rows; this.canvas.style.width = `${metrics.width * this.cols}px`; this.canvas.style.height = `${metrics.height * this.rows}px`; @@ -604,7 +603,8 @@ export class Terminal implements ITerminalCore { }), getCanvasOffset: () => { const rect = canvas.getBoundingClientRect(); - return { left: rect.left, top: rect.top }; + // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) + return { left: rect.left, top: rect.top + renderer.scrollOffset }; }, }; @@ -876,10 +876,9 @@ export class Terminal implements ITerminalCore { // Resize renderer this.renderer!.resize(cols, rows); - // Update canvas dimensions + // Update canvas dimensions. The element draws nothing: the renderer draws on a + // canvas of its own (webtty, ADR 043) const metrics = this.renderer!.getMetrics(); - this.canvas!.width = metrics.width * cols; - this.canvas!.height = metrics.height * rows; this.canvas!.style.width = `${metrics.width * cols}px`; this.canvas!.style.height = `${metrics.height * rows}px`; @@ -1634,7 +1633,10 @@ export class Terminal implements ITerminalCore { // Convert mouse coordinates to terminal cell position const rect = this.canvas.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); - const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) + const y = Math.floor( + (e.clientY - rect.top - this.renderer.scrollOffset) / this.renderer.charHeight + ); // Get hyperlink_id directly from the cell at this position // Must account for viewportY (scrollback position) @@ -1806,7 +1808,10 @@ export class Terminal implements ITerminalCore { // Get click position const rect = this.canvas.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); - const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); + // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) + const y = Math.floor( + (e.clientY - rect.top - this.renderer.scrollOffset) / this.renderer.charHeight + ); // Calculate buffer row (same logic as processMouseMove) const viewportRow = y; @@ -1869,7 +1874,10 @@ export class Terminal implements ITerminalCore { if (ticks === 0) return; const rect = this.canvas.getBoundingClientRect(); const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); - const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); + const row = Math.max( + 1, + Math.floor((e.clientY - rect.top - this.renderer!.scrollOffset) / metrics.height) + 1 + ); this.dataEmitter.fire(wheelSgr(ticks, col, row)); return; } @@ -1934,7 +1942,8 @@ export class Terminal implements ITerminalCore { const rect = this.canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; + // The scrollbar is painted over the rows in view (webtty, ADR 043) + const mouseY = e.clientY - rect.top - this.renderer.scrollOffset; // Calculate scrollbar dimensions (match renderer's logic) // Use rect dimensions which are already in CSS pixels @@ -2013,7 +2022,7 @@ export class Terminal implements ITerminalCore { if (scrollbackLength === 0) return; const rect = this.canvas.getBoundingClientRect(); - const mouseY = e.clientY - rect.top; + const mouseY = e.clientY - rect.top - this.renderer.scrollOffset; // Calculate how much the mouse moved const deltaY = mouseY - this.scrollbarDragStart; diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 45a912f..a573405 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -189,6 +189,7 @@ function recordingCanvas(cellHeight: number) { const canvas = { getContext: () => ctx, style: {}, + dataset: {}, width: 0, height: 0, addEventListener: () => {}, @@ -220,16 +221,16 @@ describe('graphemes while scrolled', () => { getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), }; - const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1 }); + const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1, overscan: 1 }); renderer.resize(30, 6); renderer.render(term, true, 3, provider); - // Canvas rows 0-2 show the last three scrollback lines (line i is the i-th - // written); rows 3-5 the top of the screen. + // Below the overscan row, canvas rows 1-3 show the last three scrollback lines + // (line i is the i-th written); rows 4-6 the top of the screen. for (let row = 0; row < 3; row++) { - expect(drawn.get(row)).toContain('e\u0301'); - expect(drawn.get(row)?.join('')).toContain(` ${sb - 3 + row} `); + expect(drawn.get(row + 1)).toContain('e\u0301'); + expect(drawn.get(row + 1)?.join('')).toContain(` ${sb - 3 + row} `); } - expect(drawn.get(3)).toContain('e\u0301'); + expect(drawn.get(4)).toContain('e\u0301'); term.free(); } finally { globalThis.document = doc; @@ -374,6 +375,7 @@ function rasterCanvas() { const canvas = { getContext: () => ctx, style: {}, + dataset: {}, addEventListener: (type: string, listener: () => void) => { if (type === 'contextrestored') restored.add(listener); }, @@ -405,34 +407,69 @@ function rasterCanvas() { return { canvas: canvas as unknown as HTMLCanvasElement, pixels: () => pixels, lose }; } -describe('whole-line scroll copies rows (ADR 043)', () => { +describe('overscan and copied scrolls (ADR 043)', () => { const cols = 30; const rows = 8; - /** A fresh renderer on its own raster canvas, counting the rows it draws. */ + /** A fresh renderer on its own raster canvas, counting the rows it draws and its copies. */ function renderer(dpr: number, options: ConstructorParameters[1] = {}) { - const raster = rasterCanvas(); + const element = rasterCanvas(); + const created: ReturnType[] = []; const doc = globalThis.document; - // Other canvases (font measuring, the scratch canvas) rasterise on their own + // The canvas the renderer draws on is the first it creates. The others (scratch, font + // measuring) rasterise on their own. globalThis.document = { - createElement: () => rasterCanvas().canvas, + createElement: () => { + const canvas = rasterCanvas(); + created.push(canvas); + return canvas.canvas; + }, } as unknown as Document; try { - const r = new CanvasRenderer(raster.canvas, { ...options, devicePixelRatio: dpr }); + const r = new CanvasRenderer(element.canvas, { ...options, devicePixelRatio: dpr }); + const raster = created[0]; r.resize(cols, rows); const drawn: number[] = []; - const internals = r as unknown as { renderLine: (...args: unknown[]) => void }; + let copies = 0; + const internals = r as unknown as { + renderLine: (...args: unknown[]) => void; + shiftRows: (...args: unknown[]) => void; + }; const renderLine = internals.renderLine.bind(r); internals.renderLine = (...args: unknown[]) => { drawn.push(args[1] as number); renderLine(...args); }; - return { r, pixels: raster.pixels, lose: raster.lose, drawn }; + const shiftRows = internals.shiftRows.bind(r); + internals.shiftRows = (...args: unknown[]) => { + copies++; + shiftRows(...args); + }; + /** The device pixels the element shows. */ + const shown = () => { + const { y, height } = r.visibleArea; + const width = raster.canvas.width; + return raster.pixels().slice(y * width, (y + height) * width); + }; + /** The device pixels of the line the bottom edge strip stretches. */ + const lastLine = () => { + const width = raster.canvas.width; + const y = r.visibleArea.lastLine; + return raster.pixels().slice(y * width, (y + 1) * width); + }; + return { r, shown, lastLine, lose: raster.lose, drawn, copies: () => copies }; } finally { globalThis.document = doc; } } + /** Splits a fractional position into viewportY and an offset snapped to device pixels. */ + function split(position: number, cellHeight: number, dpr: number) { + const line = Math.floor(position); + const offset = Math.round((position - line) * cellHeight * dpr) / dpr; + return offset >= cellHeight ? { line: line + 1, offset: 0 } : { line, offset }; + } + for (const [engine, wasm] of [ ['coder', WASM], ['official', OFFICIAL_WASM], @@ -445,11 +482,14 @@ describe('whole-line scroll copies rows (ADR 043)', () => { for (let i = 0; i < 40; i++) { term.write( i % 3 === 0 - ? `\x1b[3${i % 8}mline ${i} é g\x1b[0m\r\n` + ? `\x1b[3${i % 8}mline ${i} e\u0301 g\x1b[0m\r\n` : i % 3 === 1 ? `\x1b[4${i % 8}m bg ${i} \x1b[0m 中 g\r\n` - : `plain ${i} á\r\n`, + : `plain ${i} a\u0301\r\n`, ); + // OSC 8 links, two lines each + if (i % 7 === 0) + term.write(`\x1b]8;;https://x/${i % 2}\x1b\\link ${i}\r\nmore g\x1b]8;;\x1b\\\r\n`); } // The cursor mid-screen, so a scroll moves it onto another row in view. term.write('$ prompt\x1b[5;3H'); @@ -463,27 +503,308 @@ describe('whole-line scroll copies rows (ADR 043)', () => { return { term, provider }; } - test(`each step draws only the rows that came into view and equals a full redraw (${engine})`, async () => { + type Term = Awaited>; + + /** + * What the previous renderer showed at a position, drawn independently: the viewport + * as a screen of its own lines, nothing below it, and at a fraction of a line the + * bottom of the line above drawn on its own (ADR 040's strip). No scrollbar. + */ + function previous({ term, provider }: Term, view: number, offset: number, dpr: number) { + const sb = provider.getScrollbackLength(); + const source = (r: number) => + r < view + ? { + line: provider.getScrollbackLine(sb - view + r), + grapheme: (c: number) => provider.getScrollbackGraphemeString(sb - view + r, c), + } + : { + line: term.getLine(r - view), + grapheme: (c: number) => term.getGraphemeString(r - view, c), + }; + const screen = (from: number, count: number, cursor: boolean) => { + const lines = Array.from({ length: count }, (_, i) => source(from + i)); + const t = renderer(dpr); + t.r.resize(cols, count); + t.r.render( + { + getLine: (y) => lines[y]?.line ?? null, + getGraphemeString: (y, c) => lines[y].grapheme(c), + getCursor: () => (cursor ? term.getCursor() : { x: 0, y: 0, visible: false }), + getDimensions: () => ({ cols, rows: count }), + isRowDirty: () => false, + clearDirty: () => {}, + }, + true, + 0, + ); + return t.shown(); + }; + const width = cols * 8 * dpr; + const cut = offset * dpr * width; + const viewport = screen(0, rows, view === 0); + if (!offset) return viewport; + const above = screen(-1, 1, false); + return [...above.slice(above.length - cut), ...viewport.slice(0, viewport.length - cut)]; + } + + test(`the rows in view equal the previous renderer at every position (${engine})`, async () => { + const t = await terminal(); + const sb = t.provider.getScrollbackLength(); + for (const [dpr, overscan] of [ + [1, 3], + [2, 1], + [2, 2], + [2, undefined], + ] as const) { + const live = renderer(dpr, { overscan }); + const h = live.r.getMetrics().height; + live.r.render(t.term, true, 0, t.provider, 0); + // Trackpad steps up and down, a jump past the overscan, line steps, the top + const positions = [ + ...Array.from({ length: 24 }, (_, i) => i * 0.35), + 18.6, + 15.4, + ...Array.from({ length: 12 }, (_, i) => 15.4 - i * 0.45), + 1, + 2, + 5, + 4, + 0, + 3, + 10, + 9, + 17, + 16, + 0.5, + 0, + sb, + sb - 0.25, + sb - 7.75, + 0, + ]; + for (const p of positions) { + const { line, offset } = split(p, h, dpr); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 0); + expect(live.r.scrollOffset).toBe(offset); + expect(live.shown()).toEqual(previous(t, line, offset, dpr)); + // The bottom strip's line is the last row's own, at any fraction: not the + // glyphs the part in view ends on, offset above it. + const area = live.r.visibleArea; + expect(area.lastLine).toBe(area.y + area.height - 1 + offset * dpr); + const whole = previous(t, line, 0, dpr); + expect(live.lastLine()).toEqual(whole.slice(whole.length - cols * 8 * dpr)); + } + } + t.term.free(); + }); + + test(`an offset set for a taller cell shows at most a line (${engine})`, async () => { + const t = await terminal(); + const live = renderer(2); + const h = live.r.getMetrics().height; + live.r.setScrollOffset(h + 6); + live.r.render(t.term, true, 3, t.provider, 0); + expect(live.r.scrollOffset).toBe(h - 0.5); + expect(live.shown()).toEqual(previous(t, 3, h - 0.5, 2)); + t.term.free(); + }); + + test(`random scrolls, output, selection, hover and scrollbar equal a full redraw (${engine})`, async () => { + const { term } = await terminal(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => + term.getScrollbackGraphemeString(o, c), + }; + // A fixed pseudo-random sequence + let seed = 7; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + let view = 0; + let selection: { + startCol: number; + startLine: number; + endCol: number; + endLine: number; + } | null = null; + const manager = { + hasSelection: () => selection !== null, + getSelectionRange: () => { + if (!selection) return null; + const sb = term.getScrollbackLength(); + return { + startCol: selection.startCol, + startRow: selection.startLine - sb + view, + endCol: selection.endCol, + endRow: selection.endLine - sb + view, + }; + }, + clearDirtySelectionRows: () => {}, + } as unknown as Parameters[0]; + let range: { startX: number; startY: number; endX: number; endY: number } | null = null; + // The OSC 8 link ids, and none + const ids = new Set([0]); + for (let o = 0; o < term.getScrollbackLength(); o++) { + for (const cell of term.getScrollbackLine(o) ?? []) ids.add(cell.hyperlink_id); + } + const links = [...ids]; + expect(links.length).toBeGreaterThan(1); + let hovered = 0; + for (const overscan of [1, 3]) { + const options = { overscan }; + const live = renderer(2, options); + live.r.setSelectionManager(manager); + const h = live.r.getMetrics().height; + let position = 0; + let opacity = 1; + for (let step = 0; step < 500; step++) { + const sb = term.getScrollbackLength(); + // Some steps also move, so a change can land on the frame that copies rows + const op = random(); + if (op >= 0.45 && random() < 0.4) position += (random() - 0.5) * 10; + if (op < 0.45) position += (random() - 0.5) * (random() < 0.3 ? 14 : 4); + else if (op < 0.5) position = 0; + else if (op < 0.6) { + const i = Math.floor(random() * 30); + term.write(i % 2 ? `out ${i} e\u0301 g\r\n` : `\x1b[4${i % 8}m${i}\x1b[0m \u4e2d g`); + } else if (op < 0.7) opacity = opacity ? 0 : 1; + else if (op < 0.8) { + const line = Math.floor(random() * (sb + rows)); + selection = + random() < 0.3 + ? null + : { + startCol: Math.floor(random() * cols), + startLine: line, + endCol: Math.floor(random() * cols), + endLine: line + Math.floor(random() * 6), + }; + } else if (op < 0.9) { + const y = Math.floor(random() * rows); + range = random() < 0.3 ? null : { startX: 2, startY: y, endX: 6, endY: y }; + } else { + hovered = links[Math.floor(random() * links.length)]; + } + position = Math.max(0, Math.min(term.getScrollbackLength(), position)); + const { line, offset } = split(position, h, 2); + view = line; + live.r.setHoveredLinkRange(range); + live.r.setHoveredHyperlinkId(hovered); + live.r.setScrollOffset(offset); + live.r.render(term, false, view, provider, opacity); + const full = renderer(2, options); + full.r.setSelectionManager(manager); + full.r.setHoveredLinkRange(range); + full.r.setHoveredHyperlinkId(hovered); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, provider, opacity); + expect(live.shown()).toEqual(full.shown()); + } + } + term.free(); + }); + + test(`a hovered link is repainted out of view too (${engine})`, async () => { const { term, provider } = await terminal(); - for (const dpr of [1, 1.5]) { - const live = renderer(dpr); - live.r.render(term, true, 0, provider, 1); - for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { - const before = live.drawn.length; - const from = (live.r as unknown as { lastViewportY: number }).lastViewportY; - live.r.render(term, false, y, provider, 1); - const steps = Math.abs(y - from); - // The rows that came into view and the one their glyphs overflow into, the last - // row (scrolling up) and the row the old cursor moved to with the row below it. - expect(live.drawn.length - before).toBeLessThanOrEqual(steps + 4); - const full = renderer(dpr); - full.r.render(term, true, y, provider, 1); - expect(live.pixels()).toEqual(full.pixels()); + const live = renderer(2, { overscan: 4 }); + live.r.render(term, true, 0, provider, 0); + // Each line in turn is the last row, then just below the viewport while the hover + // changes, then the last row again. + let hovered = 0; + for (let y = 0; y < 24; y++) { + live.r.render(term, false, y, provider, 0); + live.r.render(term, false, y + 1, provider, 0); + hovered = 1 - hovered; + live.r.setHoveredHyperlinkId(hovered); + live.r.render(term, false, y + 1, provider, 0); + live.r.render(term, false, y, provider, 0); + const full = renderer(2, { overscan: 4 }); + full.r.setHoveredHyperlinkId(hovered); + full.r.render(term, true, y, provider, 0); + expect(live.shown()).toEqual(full.shown()); + } + term.free(); + }); + + test(`rows out of view follow output at the bottom (${engine})`, async () => { + const { term } = await terminal(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => + term.getScrollbackGraphemeString(o, c), + }; + const live = renderer(2, { overscan: 4 }); + live.r.render(term, true, 0, provider, 0); + // Draw the rows above the viewport, return, and let output (a new line, then a line + // changed in place) move the lines they show + for (const output of [ + '\x1b[8;1H\r\nnew line e\u0301 g', + '\rsame line g\x1b[K', + '\r\n\x1b[41m\u4e2d\u4e2d\x1b[0m', + ]) { + for (const y of [3, 2, 1, 0]) live.r.render(term, false, y, provider, 0); + term.write(output); + live.r.render(term, false, 0, provider, 0); + for (const [y, offset] of [ + [0, 7.5], + [2, 0], + [3, 0.5], + ]) { + live.r.setScrollOffset(offset); + live.r.render(term, false, y, provider, 0); + expect(live.shown()).toEqual(previous({ term, provider }, y, offset, 2)); } + live.r.setScrollOffset(0); } term.free(); }); + test(`a scroll draws only the rows that came into view and the edges (${engine})`, async () => { + const t = await terminal(); + const live = renderer(2, { overscan: 6 }); + const h = live.r.getMetrics().height; + live.r.render(t.term, true, 0, t.provider, 1); + let view = 0; + for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { + const before = live.drawn.length; + live.r.render(t.term, false, y, t.provider, 1); + // The rows that came into view and the one their glyphs overflow into, the new + // last row, and the row the old cursor moved to with the row below it. + expect(live.drawn.length - before).toBeLessThanOrEqual(Math.abs(y - view) + 4); + view = y; + } + // Up and down within the overscan once its rows are drawn: no copy, and per line + // only the rows at the edges, which lose or gain what the line beyond draws into them + for (let p = 1; p <= 4; p += 0.25) { + const { line, offset } = split(p, h, 2); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 1); + } + const copies = live.copies(); + const before = live.drawn.length; + for (let p = 4; p >= 1; p -= 0.25) { + const { line, offset } = split(p, h, 2); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 1); + } + expect(live.copies()).toBe(copies); + expect(live.drawn.length - before).toBeLessThanOrEqual(3 * 4 + 2); + // Frames at a fraction of a line with nothing else changed draw nothing + const still = live.drawn.length; + for (const offset of [0.5, 3, 7.5, 3]) { + live.r.setScrollOffset(offset); + live.r.render(t.term, false, 1, t.provider, 1); + } + expect(live.drawn.length).toBe(still); + t.term.free(); + }); + test(`a scrolled frame with nothing changed draws no row (${engine})`, async () => { const { term, provider } = await terminal(); const live = renderer(2); @@ -492,44 +813,73 @@ describe('whole-line scroll copies rows (ADR 043)', () => { const before = live.drawn.length; for (let i = 0; i < 3; i++) live.r.render(term, false, 6, provider, 1); expect(live.drawn.length).toBe(before); - // Dropping the scrollbar repaints every row, so none keeps the thumb. + // Dropping the scrollbar repaints the rows in view, so none keeps the thumb. live.r.render(term, false, 6, provider, 0); expect(live.drawn.length - before).toBe(rows); const full = renderer(2); full.r.render(term, true, 6, provider, 0); - expect(live.pixels()).toEqual(full.pixels()); + expect(live.shown()).toEqual(full.shown()); term.free(); }); - test(`selection, link underline and cursor follow a copied scroll (${engine})`, async () => { + test(`selection, link underline and cursor follow a scroll (${engine})`, async () => { const { term, provider } = await terminal(); const sb = provider.getScrollbackLength(); let view = 0; - // A selection anchored to buffer lines, as SelectionManager keeps it. - const selection = { + // A selection anchored to buffer lines, as SelectionManager keeps it, reaching past + // the viewport. + const first = { startCol: 3, startLine: sb - 4, endCol: 12, endLine: sb + 2 }; + const second = { startCol: 0, startLine: sb - 12, endCol: 4, endLine: sb - 9 }; + let selection = first; + const manager = { hasSelection: () => true, - getSelectionCoords: () => ({ - startCol: 3, - startRow: sb - 4 - sb + view, - endCol: 12, - endRow: sb - 2 - sb + view, + getSelectionRange: () => ({ + startCol: selection.startCol, + startRow: selection.startLine - sb + view, + endCol: selection.endCol, + endRow: selection.endLine - sb + view, }), - getDirtySelectionRows: () => new Set(), clearDirtySelectionRows: () => {}, } as unknown as Parameters[0]; - const range = { startX: 2, startY: 3, endX: 9, endY: 3 }; - const live = renderer(2); - live.r.setSelectionManager(selection); - live.r.setHoveredLinkRange(range); - live.r.render(term, true, 0, provider, 1); - for (const y of [1, 3, 2, 0, 2, 7, 6]) { - view = y; - live.r.render(term, false, y, provider, 1); - const full = renderer(2); - full.r.setSelectionManager(selection); - full.r.setHoveredLinkRange(range); - full.r.render(term, true, y, provider, 1); - expect(live.pixels()).toEqual(full.pixels()); + const range = { startX: 2, startY: 6, endX: 9, endY: 6 }; + for (const overscan of [1, 4]) { + const options = { overscan, theme: { cursor: 'rgba(255, 0, 0, 0.3)' } }; + const live = renderer(2, options); + live.r.setSelectionManager(manager); + live.r.setHoveredLinkRange(range); + live.r.render(term, true, 0, provider, 1); + selection = first; + for (const [y, offset, changed] of [ + [1, 0], + [3, 0], + [3, 7.5], + [2, 0.5], + [0, 4], + [0, 0], + [2, 3], + [7, 0], + [9, 1], + [6, 0], + [6, 0, second], + [6, 2], + [11, 0], + // The selection changes on a frame that copies rows, with the old one still in view + [13, 0, first], + [12, 0, second], + [5, 3, first], + [0, 0], + ] as const) { + if (changed) selection = changed; + view = y; + live.r.setScrollOffset(offset); + live.r.render(term, false, view, provider, 1); + const full = renderer(2, options); + full.r.setSelectionManager(manager); + full.r.setHoveredLinkRange(range); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, provider, 1); + expect(live.shown()).toEqual(full.shown()); + } } term.free(); }); @@ -553,13 +903,43 @@ describe('whole-line scroll copies rows (ADR 043)', () => { live.r.render(term, force, y, provider, 1); const full = renderer(2, { theme, cursorStyle }); full.r.render(term, true, y, provider, 1); - expect(live.pixels()).toEqual(full.pixels()); + expect(live.shown()).toEqual(full.shown()); } live.r.dispose(); } term.free(); }); + test(`the block cursor redraws the grapheme of its own screen row (${engine})`, async () => { + const ghostty = await Ghostty.load(wasm, engine); + const term = ghostty.createTerminal(cols, rows, { scrollbackLimit: 10000 }); + // Every row starts with a cluster of its own, the cursor on a combining one + term.write( + Array.from({ length: rows }, (_, i) => `${'abcdefgh'[i]}\u0301\u0302 ${i}`).join('\r\n'), + ); + term.write('\x1b[3;1H'); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => + term.getScrollbackGraphemeString(o, c), + }; + const live = renderer(2, { cursorStyle: 'block' }); + const internals = live.r as unknown as { renderCellText: (...args: unknown[]) => void }; + const renderCellText = internals.renderCellText.bind(live.r); + const accented: string[] = []; + internals.renderCellText = (...args: unknown[]) => { + // The cursor draws its character in the accent colour + if (args[3] !== undefined) accented.push((args[4] as (col: number) => string)(0)); + renderCellText(...args); + }; + live.r.render(term, true, 0, provider, 1); + expect(accented).toEqual([term.getGraphemeString(2, 0)]); + expect(accented[0]).toBe('c\u0301\u0302'); + live.r.dispose(); + term.free(); + }); + test(`a restored context is repainted on the next frame (${engine})`, async () => { const { term, provider } = await terminal(); for (const y of [6, 0]) { @@ -570,12 +950,12 @@ describe('whole-line scroll copies rows (ADR 043)', () => { live.r.render(term, false, y, provider, 1); const full = renderer(2); full.r.render(term, true, y, provider, 1); - expect(live.pixels()).toEqual(full.pixels()); + expect(live.shown()).toEqual(full.shown()); // Disposed, it no longer listens live.r.dispose(); live.lose(); live.r.render(term, false, y, provider, 1); - expect(live.pixels()).not.toEqual(full.pixels()); + expect(live.shown()).not.toEqual(full.shown()); } term.free(); }); From 67e1d1d227f102e1006ea163eb14100229c86a55 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 15:47:37 -0400 Subject: [PATCH 021/295] docs(client): ADR 044, glyph cache rejected A glyph atlas copied with drawImage made full repaints about twice as slow as fillText in Chrome, in script and GPU time, at a 98% hit rate, and measureText ink boxes cut off combining marks. Record the design and the measurements; the renderer keeps fillText. --- docs/adrs/044.client.glyph-cache.md | 80 +++++++++++++++++++++++++++++ docs/specs/client.md | 1 + 2 files changed, 81 insertions(+) create mode 100644 docs/adrs/044.client.glyph-cache.md diff --git a/docs/adrs/044.client.glyph-cache.md b/docs/adrs/044.client.glyph-cache.md new file mode 100644 index 0000000..8c31c0e --- /dev/null +++ b/docs/adrs/044.client.glyph-cache.md @@ -0,0 +1,80 @@ +# ADR 044: client — Glyph cache for full repaints (rejected) + +**SPEC:** [client](../specs/client.md) +**Status:** Rejected +**Date:** 2026-10-13 + +--- + +## Context + +ADR 043 made scrolling draw only the rows that come into view, but a full repaint still draws every cell with `fillText`. Full repaints happen on output that scrolls the screen (every row is dirty), a resize, a font, theme or ratio change, and the scrollbar fade-out. At 175×64 cells that is about 11,000 `fillText` calls, each after setting `font` and `fillStyle` from strings built for that cell. + +A glyph cache is the usual answer: draw each distinct glyph once into an offscreen atlas canvas and copy it into place with `drawImage`. It must leave the canvas exactly as `fillText` does. + +## Decision + +Do not add a glyph cache. The renderer keeps drawing every glyph with `fillText`. + +In Chrome, a full repaint from the cache took about twice the script time and twice the GPU-process time of `fillText`, even though over 98% of the glyphs came from the cache (Measurements). Chrome already caches shaped runs and rasterised glyphs behind `fillText`, and one `fillText` costs less than one `drawImage` from another canvas. The cache also failed the exactness check: `measureText` placed the ink of some combining marks inside the cell when it was not. + +### The design that was measured + +- **A tile is the whole cell: the glyph drawn over its background.** A glyph drawn alone on a transparent atlas and blended into place has its premultiplied edges rounded to 8 bits one extra time, so its edges can differ from `fillText` by a level. So the atlas held the cell's opaque background with the glyph drawn over it, exactly as the row draws it: `font`, `fillStyle`, faint `globalAlpha`, and the alphabetic baseline at the cell's baseline. Copying an opaque tile onto whole device pixels gives the cell the same pixels as `fillText` over that background. +- **Key:** the font string (bold, italic, size, family), the foreground and background CSS colours, faint, the cell width (1 or 2) and the grapheme string. Selection colours and inverse are just other colours. The ratio, font and metrics are fixed for the atlas' life. +- **When a tile is used:** only when all three of these hold: + - The glyph's ink box (`actualBoundingBox*` from `measureText`, measured once per key) lies inside the tile. + - The background is opaque. A translucent theme background would be blended twice. + - No glyph drawn earlier in the row reaches into the cell. The tile would erase that ink, and a full redraw keeps it under this glyph. + + Glyphs that overflow their cell (italic overhang, colour emoji, tall combining stacks) and the cursor's clipped glyph were drawn with `fillText`. The right edge of their ink marked the cells after them as not tileable for the rest of the row. Underlines, strikethrough and link underlines were drawn after the tile, in the same order as before. +- **Bounds:** one atlas canvas of 2048×2048 device pixels, filled cell by cell, with wide glyphs taking two cells. A full atlas was cleared and filled again. The atlas was also cleared when the ratio, font or metrics changed. + +The implementation is not kept. It was a change to `renderer.ts` only (`drawTile`, `addGlyph`, `drawAtlasTile`). + +## Measurements + +Apple M3 Pro, headless Chrome 155 (ANGLE Metal), 1400×900 (175×64 cells), Menlo 13 px. The two builds ran side by side: the build before this ADR, and the same build with the cache. + +**Full repaints.** The page had the same output in both builds. "Typical" is `git log --stat --color` plus TypeScript source. "Mixed" is the generator used in ADR 043: truecolor and 256-colour runs, styles, emoji with VS16, ZWJ sequences, flags, combining marks, CJK and box drawing. Each run forced 60 full repaints, one every other frame (a `contextrestored` event, which resizes the canvas and repaints every row). The table shows the script time of the repaint frame and the GPU process' main-thread busy time (from a trace) per repaint. The atlas was already filled before the 60 repaints. + +| Per full repaint | `fillText`: script / GPU | Cache: script / GPU | `fillText` calls, before → with the cache | +|---|---|---|---| +| Official, DPR 2, typical (two runs) | 6.6 / 3.6 ms | 14.7–15.5 / 8.8–9.1 ms | 11,200 → 201 | +| Official, DPR 2, mixed | 8.4 / 4.8 ms | 13.4 / 8.7 ms | 10,883 → 1,151 | +| Coder, DPR 2, typical | 8.6 / 4.0 ms | 13.5 / 8.0 ms | 11,200 → 201 | +| Coder, DPR 2, mixed | 7.8 / 4.4 ms | 13.9 / 8.7 ms | 10,915 → 1,191 | +| Official, DPR 1, typical | 8.4 / 3.9 ms | 13.3 / 7.7 ms | 11,200 → 201 | + +A variant that built the key and looked up the tile for each cell, but still drew with `fillText`, took 9.1–9.9 ms of script and 3.6 ms of GPU time on the first row. Of the extra script time, about 3 ms is the per-cell key and lookup. The other 5 ms, and all of the extra GPU time, is `drawImage` instead of `fillText`. A cheaper key would not close the gap. + +**Raw call cost.** This was measured on a standalone page with the same Chrome and font, over 20,000 calls, best of 5: + +- One `fillText` with `font` and `fillStyle` set took 0.31–0.36 µs. That held for ASCII, CJK, colour emoji (including ZWJ and flags), combining sequences, Devanagari and box drawing alike. +- One 9-argument `drawImage` from an atlas canvas took 0.54–0.57 µs. +- A 175×64 grid drawn per cell took 4.1 ms with `fillText` and 10.1–11.7 ms from an atlas. The GPU process time was 5.2 and 7.0 ms per frame. The atlas source (a canvas, an `ImageBitmap` or an `OffscreenCanvas`) did not change this. +- One `ImageBitmap` per glyph, drawn with 3-argument `drawImage`, took 10.7 ms of script and was slower on the GPU. + +**Whole-app scenarios.** Streaming output, 20 resizes and wheel scrolling by lines and pages were slower with the cache in every run. These numbers vary from run to run more than the full-repaint numbers do: line scrolling took 1.2–3.1 ms per drawn frame without the cache and 4.1 ms with it. + +**Exactness.** Screenshots of both builds at the bottom, scrolled up 7 lines, and with a selection were compared on the mixed output. On the official engine at DPR 2, 82 device pixels differed, by up to 46 levels, at every scroll position. All of them were at combining marks (`é`, `ä`, `ñ` as base plus mark): `measureText`'s `actualBoundingBoxAscent` put the mark inside the cell, but it reaches into the row above. A full redraw keeps that ink in the row above (ADR 043's joined rows), and the tile cut it off. The Coder engine at DPR 1 differed by up to 19 levels. A safety margin on the ink box would send more glyphs back to `fillText`, including full-height box drawing and block elements, which touch the cell's edges. That would make the cache slower still. + +`scrollbench` (Bun, recording fake canvas) is not a fair comparison here. Its `drawImage` and `fillText` are both free, so it only counts calls. + +## Considered Options + +- **A transparent atlas, with tiles blended over the drawn background.** Fewer keys, because the background is not part of the key. But this is not exact: it rounds the glyph's premultiplied edges one extra time. It would cost the same `drawImage` per cell. +- **Separate small canvases or `ImageBitmap`s per glyph**, drawn with 3-argument `drawImage`. This was measured too, and it was slower. +- **Not tested, and neither decided here.** Leaving the measured gap as it is: + - Skipping `font` and `fillStyle` assignments that did not change: about 10% of the standalone grid's script time (4.1 → 3.7 ms). + - One `fillText` per run of cells with the same style: far fewer calls. But it places glyphs at the font's advance instead of at each cell, so it is not exact for fonts whose advance is not a whole cell. + +## Consequences + +- Full repaints keep their current cost: about 6.6–8.6 ms of script per 175×64 repaint in headless Chrome, plus 3.6–4.8 ms in the GPU process. +- Chrome's own glyph caching behind `fillText` is what a cache has to beat. Before another attempt at a cache, measure raw call cost and an exactness check first. +- These measurements are from headless Chrome on macOS only. Firefox and Safari were not measured, and their `fillText` and `drawImage` costs may differ. + +## Related Decisions + +- [ADR 043 — Copy rows on scroll, then overscan](043.client.scroll-copy-and-overscan.md) diff --git a/docs/specs/client.md b/docs/specs/client.md index d8b3dea..0c34781 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -199,6 +199,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | +| ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | From ccf4252f55ba775883b6ed7a884e7f7346a71ebf Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 16:34:31 -0400 Subject: [PATCH 022/295] docs(client): ADR 045, smooth mouse wheel scrolling --- docs/adrs/045.client.smooth-mouse-wheel.md | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/adrs/045.client.smooth-mouse-wheel.md diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md new file mode 100644 index 0000000..5872f6f --- /dev/null +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -0,0 +1,39 @@ +# ADR 045: client — Smooth mouse wheel scrolling + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-13 + +--- + +## Context + +Pixel-smooth scrollback (ADR 040, ADR 043) follows a trackpad pixel by pixel, but a mouse wheel notch moves the whole distance at once: Chromium and Safari on macOS report a notch as one pixel delta of several lines, and Firefox as 3 lines. Scrolling with a mouse jumps between positions while the trackpad glides. + +The browser cannot say which device sent a wheel event. Easing every event would bring back the lag ADR 040 removed for trackpads. + +## Decision + +Glide toward a mouse wheel notch's target; leave trackpad deltas immediate. In `src/client/smooth-scroll.ts`: + +- **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. +- **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. +- The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. +- A trackpad delta, output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. +- Mouse-tracking apps and the alternate screen are unchanged: they get whole-line SGR ticks (ADR 040), which they scroll themselves. + +## Consequences + +- A mouse wheel scrolls scrollback smoothly, reaching each notch's target 125 ms later. +- The classifier needs a couple of events to settle. The first notch after trackpad scrolling may jump, and a trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. +- VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers and the wheel scrolls as before (immediately). + +## Considered Options + +- **Ease every wheel event (xterm.js `smoothScrollDuration`).** Simple, but trackpad scrolling would trail the fingers again. +- **Classify by `wheelDeltaY` multiples of 120.** Non-standard, and what it reports for a notch varies by browser and OS; VS Code's classifier is proven across Chromium, Safari and Firefox. + +## Related Decisions + +- [ADR 040 — Pixel-smooth scrollback scrolling](040.client.pixel-smooth-scroll.md) +- [ADR 043 — Copy rows on scroll, then overscan](043.client.scroll-copy-and-overscan.md) From 6abe6202df502788b5c0905c3efbab647019e8f8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 16:34:31 -0400 Subject: [PATCH 023/295] feat(client): glide mouse wheel notches through scrollback A mouse wheel notch now glides to its target over 125 ms with an ease-out cubic instead of jumping there, and notches during a glide add to its target. A trackpad still scrolls immediately: VS Code's wheel classifier tells the two apart by whether recent deltas are integers that are multiples of each other (ADR 045). --- docs/adrs/040.client.pixel-smooth-scroll.md | 2 + docs/specs/client.md | 1 + src/client/smooth-scroll.test.ts | 146 +++++++++++++++++++- src/client/smooth-scroll.ts | 104 +++++++++++++- 4 files changed, 249 insertions(+), 4 deletions(-) diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index 4f93591..fbf3524 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -25,6 +25,8 @@ The alternate screen and mouse-tracking apps scroll their own view in whole line **Update (ADR 041):** the mouse-tracking tick counting now lives in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`, `wheelTicks`), with ADR 017's SGR reports; `index.ts` keeps only the pixel-smooth scrollback handler. +**Update (ADR 045):** a mouse wheel notch in scrollback now glides to its target instead of jumping there. + ## Consequences - Trackpad scrolling through scrollback follows the fingers pixel by pixel, with macOS momentum, and no easing lag. diff --git a/docs/specs/client.md b/docs/specs/client.md index 0c34781..7cc53d2 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -199,6 +199,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | +| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 32252a5..977a380 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { SmoothScroll, splitScroll, wheelLines } from './smooth-scroll'; +import { + SmoothScroll, + splitScroll, + WHEEL_GLIDE_MS, + WheelClassifier, + wheelLines, +} from './smooth-scroll'; import type { Terminal } from './vendor/ghostty-web'; describe('splitScroll', () => { @@ -89,7 +95,8 @@ describe('SmoothScroll', () => { try { g.window = { devicePixelRatio: 1 }; const { term, metrics, offsets, scroll } = fake(14); - scroll.handleWheel(wheel(-14 * 2.5)); + // A fractional delta, so the trackpad path applies it at once: 2.518 lines. + scroll.handleWheel(wheel(-14 * 2.5 - 0.25)); expect(offsets.at(-1)).toBe(7); metrics.height = 20; scroll.update(); @@ -99,4 +106,139 @@ describe('SmoothScroll', () => { g.window = saved; } }); + + describe('mouse wheel glide', () => { + function withFrames(run: (tick: (ms: number) => void) => void) { + const g = globalThis as { window?: unknown; performance?: unknown }; + const [savedWindow, savedPerformance] = [g.window, g.performance]; + let now = 0; + let pending: ((t: number) => void) | null = null; + g.window = { + devicePixelRatio: 1, + requestAnimationFrame: (cb: (t: number) => void) => { + pending = cb; + return 1; + }, + cancelAnimationFrame: () => { + pending = null; + }, + }; + g.performance = { now: () => now }; + try { + run((ms) => { + now += ms; + const cb = pending; + pending = null; + cb?.(now); + }); + } finally { + g.window = savedWindow; + g.performance = savedPerformance; + } + } + const notch = (deltaY: number) => ({ deltaX: 0, deltaY, deltaMode: 0 }) as WheelEvent; + + test('a notch glides to its target instead of jumping', () => { + withFrames((tick) => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel(notch(-40)); + expect(term.viewportY).toBe(0); + expect(offsets).toEqual([]); + tick(WHEEL_GLIDE_MS / 2); + // Ease-out cubic at t = 0.5: 0.875 of 4 lines. + expect(term.viewportY).toBe(3); + expect(offsets.at(-1)).toBe(5); + tick(WHEEL_GLIDE_MS); + expect(term.viewportY).toBe(4); + expect(offsets.at(-1)).toBe(0); + }); + }); + + test('a notch during a glide adds to its target', () => { + withFrames((tick) => { + const { term, scroll } = fake(10); + scroll.handleWheel(notch(-40)); + tick(WHEEL_GLIDE_MS / 2); + scroll.handleWheel(notch(-40)); + tick(WHEEL_GLIDE_MS); + expect(term.viewportY).toBe(8); + }); + }); + + test('line deltas glide too', () => { + withFrames((tick) => { + const { term, scroll } = fake(10); + scroll.handleWheel({ deltaX: 0, deltaY: -3, deltaMode: 1 } as WheelEvent); + expect(term.viewportY).toBe(0); + tick(WHEEL_GLIDE_MS); + expect(term.viewportY).toBe(3); + }); + }); + + test('a trackpad delta stops the glide where it is', () => { + withFrames((tick) => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel(notch(-40)); + tick(WHEEL_GLIDE_MS / 2); + scroll.handleWheel({ deltaX: 0.5, deltaY: -0.5, deltaMode: 0 } as WheelEvent); + const [line, offset] = [term.viewportY, offsets.at(-1)]; + tick(WHEEL_GLIDE_MS); + expect([term.viewportY, offsets.at(-1)]).toEqual([line, offset]); + }); + }); + + test('the glide stops at the top of scrollback', () => { + withFrames((tick) => { + const { term, scroll } = fake(10); + scroll.handleWheel(notch(-10_000)); + tick(WHEEL_GLIDE_MS); + expect(term.viewportY).toBe(100); + }); + }); + }); +}); + +describe('WheelClassifier', () => { + const feed = (deltas: [number, number][]) => { + const c = new WheelClassifier(); + for (const [x, y] of deltas) c.accept(x, y); + return c.isPhysicalMouseWheel(); + }; + + test('steady integer notches are a wheel', () => { + expect( + feed([ + [0, 4], + [0, 4], + [0, 8], + [0, 4], + ]), + ).toBe(true); + }); + + test('fractional, accelerating deltas are a trackpad', () => { + expect( + feed([ + [0, 1.5], + [0, 3.25], + [0, 7.75], + [0, 12.5], + ]), + ).toBe(false); + }); + + test('both axes at once are a trackpad', () => { + expect( + feed([ + [0, 4], + [0, 4], + [1, 4], + [2, 6], + ]), + ).toBe(false); + }); + + test('no events is not a wheel', () => { + expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); + }); }); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 283101d..c44229e 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -12,6 +12,9 @@ * sliding into view. At a whole-line position there is no offset, so a resting * terminal renders exactly as before. * + * A mouse wheel notch glides to its target instead of jumping there (ADR 045); + * trackpad deltas apply at once. + * * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ @@ -44,9 +47,72 @@ export function wheelLines( return e.deltaY / cellHeight; } +/** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ +export const WHEEL_GLIDE_MS = 125; + +/** + * Tells a physical mouse wheel from a trackpad by its recent pixel deltas. + * Adapted from VS Code's `MouseWheelClassifier` (scrollableElement.ts), + * Copyright (c) Microsoft Corporation, MIT License. + */ +export class WheelClassifier { + private static readonly CAPACITY = 5; + /** Scores of the latest events, newest last: 0 looks like a wheel, 1 like a trackpad. */ + private scores: number[] = []; + private previous: { deltaX: number; deltaY: number } | null = null; + + accept(deltaX: number, deltaY: number): void { + this.scores.push(this.score(deltaX, deltaY)); + if (this.scores.length > WheelClassifier.CAPACITY) this.scores.shift(); + this.previous = { deltaX, deltaY }; + } + + /** The newest score weighs 1/2, the next 1/4, and so on; the oldest takes the rest. */ + isPhysicalMouseWheel(): boolean { + if (this.scores.length === 0) return false; + let remaining = 1; + let total = 0; + for (let i = this.scores.length - 1; i >= 0; i--) { + const influence = i === 0 ? remaining : 2 ** -(this.scores.length - i); + remaining -= influence; + total += this.scores[i] * influence; + } + return total <= 0.5; + } + + private score(deltaX: number, deltaY: number): number { + // Both axes at once: a trackpad. + if (deltaX !== 0 && deltaY !== 0) return 1; + let score = 0.5; + if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; + // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. + const prev = this.previous; + if (prev) { + const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; + const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; + const minX = Math.max(Math.min(ax, px), 1); + const minY = Math.max(Math.min(ay, py), 1); + if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; + } + return Math.min(Math.max(score, 0), 1); + } +} + +function isAlmostInt(value: number): boolean { + return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; +} + +function easeOutCubic(t: number): number { + return 1 - (1 - t) ** 3; +} + export class SmoothScroll { /** Lines scrolled up from the bottom; fractional between rows. */ private position = 0; + private readonly classifier = new WheelClassifier(); + /** A mouse wheel glide in progress: positions and its start time. */ + private glide: { from: number; to: number; start: number } | null = null; + private frame = 0; constructor(private readonly term: Terminal) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard @@ -62,9 +128,15 @@ export class SmoothScroll { if (this.term.wasmTerm?.isAlternateScreen()) return false; const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; - const max = this.term.getScrollbackLength(); const lines = wheelLines(e, metrics.height, this.term.rows); - this.position = Math.max(0, Math.min(max, this.position - lines)); + if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); + if (e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel()) { + // A notch during a glide adds to where the glide is heading. + this.glideTo(this.clamp((this.glide?.to ?? this.position) - lines)); + return true; + } + this.stopGlide(); + this.position = this.clamp(this.position - lines); this.apply(); return true; } @@ -83,10 +155,38 @@ export class SmoothScroll { } private jump(line: number): void { + this.stopGlide(); this.position = line; this.apply(); } + private clamp(position: number): number { + return Math.max(0, Math.min(this.term.getScrollbackLength(), position)); + } + + private glideTo(to: number): void { + this.glide = { from: this.position, to, start: performance.now() }; + if (!this.frame) this.frame = window.requestAnimationFrame(this.step); + } + + private stopGlide(): void { + this.glide = null; + if (this.frame) window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + + private readonly step = (now: number): void => { + this.frame = 0; + const glide = this.glide; + if (!glide) return; + // A frame's timestamp can precede the glide's start by part of a frame. + const t = Math.min(1, Math.max(0, (now - glide.start) / WHEEL_GLIDE_MS)); + this.position = this.clamp(glide.from + (glide.to - glide.from) * easeOutCubic(t)); + if (t < 1) this.frame = window.requestAnimationFrame(this.step); + else this.glide = null; + this.apply(); + }; + private apply(): void { const renderer = this.term.renderer; const metrics = renderer?.getMetrics(); From a559fd0cc8d57704efe5797211782104fdde1c90 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 17:09:38 -0400 Subject: [PATCH 024/295] feat(client): glide sparse wheel deltas such as a TrackPoint's A TrackPoint sends fractional pixel deltas like a trackpad but 30-200 ms apart, up to 335 px each, so applying them at once jumped. A pixel delta 25 ms or more after the previous one now glides linearly over that gap plus a frame, at the speed of the input. A new glide starts from the latest frame of the one it replaces, or a frame before the event, so a stream of deltas does not stall every other frame (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 14 ++-- docs/specs/client.md | 2 +- src/client/smooth-scroll.test.ts | 59 +++++++++++++++-- src/client/smooth-scroll.ts | 76 +++++++++++++++++----- 4 files changed, 123 insertions(+), 28 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 5872f6f..fb35c83 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -10,23 +10,29 @@ Pixel-smooth scrollback (ADR 040, ADR 043) follows a trackpad pixel by pixel, but a mouse wheel notch moves the whole distance at once: Chromium and Safari on macOS report a notch as one pixel delta of several lines, and Firefox as 3 lines. Scrolling with a mouse jumps between positions while the trackpad glides. +A TrackPoint is neither. Captured in Chrome on macOS, it sends fractional pixel deltas like a trackpad, but 30–200 ms apart instead of every frame: 4.000244 px steps when pushed lightly, and up to 335 px (about 20 lines) every 30–45 ms when pushed hard. Applied at once, those deltas jump. + The browser cannot say which device sent a wheel event. Easing every event would bring back the lag ADR 040 removed for trackpads. ## Decision -Glide toward a mouse wheel notch's target; leave trackpad deltas immediate. In `src/client/smooth-scroll.ts`: +Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad deltas, which come every frame, immediate. In `src/client/smooth-scroll.ts`: - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. - **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. +- **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. +- **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. -- A trackpad delta, output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. +- A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. - Mouse-tracking apps and the alternate screen are unchanged: they get whole-line SGR ticks (ADR 040), which they scroll themselves. ## Consequences - A mouse wheel scrolls scrollback smoothly, reaching each notch's target 125 ms later. -- The classifier needs a couple of events to settle. The first notch after trackpad scrolling may jump, and a trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. -- VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers and the wheel scrolls as before (immediately). +- A TrackPoint scrolls smoothly at its own speed, about one event interval plus a frame behind the input. Replaying the capture at 60 fps moves the view on every frame, where applying deltas at once moved it by up to 335 px on one frame and not at all on the next. +- The first delta of a trackpad gesture comes after a pause, so it glides; the next delta, a frame later, finishes that glide. +- The classifier needs a couple of events to settle; until then a notch after trackpad scrolling gets the sparse glide instead of the wheel glide. A trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. +- VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers, and its notches get the sparse glide instead of the wheel glide. ## Considered Options diff --git a/docs/specs/client.md b/docs/specs/client.md index 7cc53d2..ce23b4e 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -199,7 +199,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | -| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | +| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 977a380..faa61fd 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -145,9 +145,10 @@ describe('SmoothScroll', () => { expect(term.viewportY).toBe(0); expect(offsets).toEqual([]); tick(WHEEL_GLIDE_MS / 2); - // Ease-out cubic at t = 0.5: 0.875 of 4 lines. + // The glide starts a frame before the event, so after 62.5 ms t = 79.2 / 125; + // ease-out cubic gives 0.95 of 4 lines. expect(term.viewportY).toBe(3); - expect(offsets.at(-1)).toBe(5); + expect(offsets.at(-1)).toBe(8); tick(WHEEL_GLIDE_MS); expect(term.viewportY).toBe(4); expect(offsets.at(-1)).toBe(0); @@ -175,15 +176,59 @@ describe('SmoothScroll', () => { }); }); - test('a trackpad delta stops the glide where it is', () => { + test('a trackpad delta finishes the glide and applies at once', () => { withFrames((tick) => { const { term, offsets, scroll } = fake(10); - scroll.handleWheel(notch(-40)); + scroll.handleWheel({ ...notch(-40), timeStamp: 1000 } as WheelEvent); tick(WHEEL_GLIDE_MS / 2); - scroll.handleWheel({ deltaX: 0.5, deltaY: -0.5, deltaMode: 0 } as WheelEvent); - const [line, offset] = [term.viewportY, offsets.at(-1)]; + scroll.handleWheel({ + deltaX: 0.5, + deltaY: -5.5, + deltaMode: 0, + timeStamp: 1008, + } as WheelEvent); + expect([term.viewportY, offsets.at(-1)]).toEqual([4, 5]); tick(WHEEL_GLIDE_MS); - expect([term.viewportY, offsets.at(-1)]).toEqual([line, offset]); + expect([term.viewportY, offsets.at(-1)]).toEqual([4, 5]); + }); + }); + + // Deltas from the capture of a TrackPoint in Chrome on macOS. + const point = (deltaY: number, timeStamp: number) => + ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; + + test('a sparse stream glides each delta over the time it took to arrive', () => { + withFrames((tick) => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel(point(-4.000244140625, 1000)); + scroll.handleWheel(point(-95.0897216796875, 1040)); + // The slow 4.000244 px steps count as wheel notches and glide to 0.4 lines. The + // fast delta, 9.51 lines more, glides linearly from where the first glide is, + // before its first frame, over its 40 ms gap plus a frame, starting a frame + // early: after 20 ms, t = 36.7 / 56.7, 6.41 lines. + tick(20); + expect([term.viewportY, offsets.at(-1)]).toEqual([6, 4]); + tick(20); + expect([term.viewportY, offsets.at(-1)]).toEqual([9, 9]); + }); + }); + + test('a slow sparse stream glides for at most the wheel glide time', () => { + withFrames((tick) => { + const { term, scroll } = fake(10); + scroll.handleWheel(point(-4.000244140625, 1000)); + scroll.handleWheel(point(-40.0001, 1300)); + tick(WHEEL_GLIDE_MS); + expect(term.viewportY).toBe(4); + }); + }); + + test('trackpad deltas a frame apart apply at once', () => { + withFrames(() => { + const { term, scroll } = fake(10); + scroll.handleWheel(point(-20.5, 1000)); + scroll.handleWheel(point(-21.25, 1008)); + expect(term.viewportY).toBe(4); }); }); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index c44229e..aff93e2 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -12,8 +12,9 @@ * sliding into view. At a whole-line position there is no offset, so a resting * terminal renders exactly as before. * - * A mouse wheel notch glides to its target instead of jumping there (ADR 045); - * trackpad deltas apply at once. + * A mouse wheel notch glides to its target instead of jumping there, and so does a + * sparse stream of deltas such as a TrackPoint's (ADR 045); trackpad deltas, which + * arrive every frame, apply at once. * * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. @@ -50,6 +51,15 @@ export function wheelLines( /** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ export const WHEEL_GLIDE_MS = 125; +/** + * A pixel delta this long after the previous one is part of a sparse stream: a + * TrackPoint sends one every 30-200 ms, a trackpad one per frame. + */ +export const SPARSE_GAP_MS = 25; + +/** One frame at 60 Hz. */ +const FRAME_MS = 1000 / 60; + /** * Tells a physical mouse wheel from a trackpad by its recent pixel deltas. * Adapted from VS Code's `MouseWheelClassifier` (scrollableElement.ts), @@ -106,13 +116,29 @@ function easeOutCubic(t: number): number { return 1 - (1 - t) ** 3; } +function linear(t: number): number { + return t; +} + +interface Glide { + from: number; + to: number; + start: number; + duration: number; + ease: (t: number) => number; +} + export class SmoothScroll { /** Lines scrolled up from the bottom; fractional between rows. */ private position = 0; private readonly classifier = new WheelClassifier(); - /** A mouse wheel glide in progress: positions and its start time. */ - private glide: { from: number; to: number; start: number } | null = null; + /** A glide in progress toward a wheel or sparse delta's target. */ + private glide: Glide | null = null; private frame = 0; + /** Timestamp of the glide's latest frame, whose position a new glide starts from. */ + private lastFrame: number | null = null; + /** `timeStamp` of the previous wheel event. */ + private lastWheel: number | null = null; constructor(private readonly term: Terminal) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard @@ -129,15 +155,23 @@ export class SmoothScroll { const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; const lines = wheelLines(e, metrics.height, this.term.rows); + const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; + this.lastWheel = e.timeStamp; if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); + // A delta during a glide adds to where the glide is heading. + const to = this.clamp((this.glide?.to ?? this.position) - lines); if (e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel()) { - // A notch during a glide adds to where the glide is heading. - this.glideTo(this.clamp((this.glide?.to ?? this.position) - lines)); - return true; + this.glideTo(to, WHEEL_GLIDE_MS, easeOutCubic); + } else if (gap >= SPARSE_GAP_MS) { + // Spread the delta over the time it took to arrive, so a steady stream scrolls + // at a steady speed, the speed of the input. One frame more keeps the glide + // going until the next delta when the stream slows down a little. + this.glideTo(to, Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), linear); + } else { + this.stopGlide(); + this.position = to; + this.apply(); } - this.stopGlide(); - this.position = this.clamp(this.position - lines); - this.apply(); return true; } @@ -164,13 +198,18 @@ export class SmoothScroll { return Math.max(0, Math.min(this.term.getScrollbackLength(), position)); } - private glideTo(to: number): void { - this.glide = { from: this.position, to, start: performance.now() }; + private glideTo(to: number, duration: number, ease: (t: number) => number): void { + // Start from the glide's latest frame, or a frame ago: starting at this event would + // give the next frame, whose timestamp precedes the event, nothing to move, and a + // stream of deltas would stall every other frame. + const start = this.lastFrame ?? performance.now() - FRAME_MS; + this.glide = { from: this.position, to, start, duration, ease }; if (!this.frame) this.frame = window.requestAnimationFrame(this.step); } private stopGlide(): void { this.glide = null; + this.lastFrame = null; if (this.frame) window.cancelAnimationFrame(this.frame); this.frame = 0; } @@ -180,10 +219,15 @@ export class SmoothScroll { const glide = this.glide; if (!glide) return; // A frame's timestamp can precede the glide's start by part of a frame. - const t = Math.min(1, Math.max(0, (now - glide.start) / WHEEL_GLIDE_MS)); - this.position = this.clamp(glide.from + (glide.to - glide.from) * easeOutCubic(t)); - if (t < 1) this.frame = window.requestAnimationFrame(this.step); - else this.glide = null; + const t = Math.min(1, Math.max(0, (now - glide.start) / glide.duration)); + this.position = this.clamp(glide.from + (glide.to - glide.from) * glide.ease(t)); + if (t < 1) { + this.lastFrame = now; + this.frame = window.requestAnimationFrame(this.step); + } else { + this.glide = null; + this.lastFrame = null; + } this.apply(); }; From 6d54acad9d4635cf43dc7e2bee9bf6cbdc80194f Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 17:31:46 -0400 Subject: [PATCH 025/295] feat(client): pace the scroll ticks a TrackPoint sends to apps In mouse-tracking apps and on the alternate screen, a TrackPoint's sparse deltas sent up to 20 scroll ticks in one burst every 30-60 ms, so apps like pi jumped. TickPacer now sends a sparse delta's ticks evenly over the time it took to arrive plus a frame, a few per frame. Wheel notches and trackpad deltas still send their ticks at once, and the tick counts are unchanged (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 3 +- docs/specs/client.md | 2 +- src/client/index.ts | 4 +- src/client/smooth-scroll.test.ts | 26 +++++ src/client/smooth-scroll.ts | 25 ++++- src/client/vendor/ghostty-web/terminal.ts | 109 +++++++++++++++++++-- src/client/vendor/terminal.test.ts | 88 +++++++++++++++++ 7 files changed, 240 insertions(+), 17 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index fb35c83..c63c87d 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -24,12 +24,13 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. -- Mouse-tracking apps and the alternate screen are unchanged: they get whole-line SGR ticks (ADR 040), which they scroll themselves. +- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But a sparse delta's ticks no longer go out in one burst: `TickPacer` in the vendored terminal sends them evenly over the same gap plus a frame, a few per frame, starting from its latest frame like the glide. `SmoothScroll.tickSpread` makes the call with the same classifier, so wheel notches and trackpad deltas still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. ## Consequences - A mouse wheel scrolls scrollback smoothly, reaching each notch's target 125 ms later. - A TrackPoint scrolls smoothly at its own speed, about one event interval plus a frame behind the input. Replaying the capture at 60 fps moves the view on every frame, where applying deltas at once moved it by up to 335 px on one frame and not at all on the next. +- In an app (pi, Claude Code's fullscreen mode, vim), a TrackPoint scrolls a few lines every frame instead of about 20 lines every other frame: replaying the capture, a hard push went from 0 or 17–20 ticks per frame to 8–13 on every frame. An app can only move by whole lines, so this is as smooth as it gets there. Neither pi nor Claude Code has a sub-line scroll mode, and no escape sequence for one exists. - The first delta of a trackpad gesture comes after a pause, so it glides; the next delta, a frame later, finishes that glide. - The classifier needs a couple of events to settle; until then a notch after trackpad scrolling gets the sparse glide instead of the wheel glide. A trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. - VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers, and its notches get the sparse glide instead of the wheel glide. diff --git a/docs/specs/client.md b/docs/specs/client.md index ce23b4e..ac1760b 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -199,7 +199,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | -| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | +| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, and in apps its scroll ticks are paced over that time instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index 98a1eff..bea6d92 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -242,10 +242,12 @@ function connect(): void { connect(); // Pixel-smooth scrollback for the normal screen. When the app tracks the mouse, the -// terminal reports wheel ticks to it as SGR scroll buttons (ADR 017, ADR 040). +// terminal reports wheel ticks to it as SGR scroll buttons (ADR 017, ADR 040), paced +// for sparse deltas such as a TrackPoint's (ADR 045). term.attachCustomWheelEventHandler( (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), ); +term.attachWheelTickSpread((e: WheelEvent) => smoothScroll.tickSpread(e)); // Shared by the configured-binding listener below and the Ctrl+V paste // listener further down, so the latter can tell whether a user has already diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index faa61fd..f3b8e49 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -243,6 +243,32 @@ describe('SmoothScroll', () => { }); }); +describe('SmoothScroll.tickSpread', () => { + const scroll = () => + new SmoothScroll({ onScroll: () => {}, onResize: () => {} } as unknown as Terminal); + const at = (deltaY: number, timeStamp: number, deltaMode = 0) => + ({ deltaX: 0, deltaY, deltaMode, timeStamp }) as WheelEvent; + + test('spreads a sparse delta over its gap plus a frame', () => { + const s = scroll(); + s.tickSpread(at(-4.000244140625, 1000)); + expect(s.tickSpread(at(-95.0897216796875, 1040))).toBeCloseTo(40 + 1000 / 60); + }); + + test('caps the spread at the wheel glide time', () => { + const s = scroll(); + s.tickSpread(at(-4.000244140625, 1000)); + expect(s.tickSpread(at(-30.25, 1200))).toBe(WHEEL_GLIDE_MS); + }); + + test('sends wheel notches and trackpad deltas at once', () => { + const s = scroll(); + expect(s.tickSpread(at(-3, 1000, 1))).toBe(0); + expect(s.tickSpread(at(-20.5, 1200))).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(-21.25, 1208))).toBe(0); + }); +}); + describe('WheelClassifier', () => { const feed = (deltas: [number, number][]) => { const c = new WheelClassifier(); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index aff93e2..5bba49b 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -155,12 +155,10 @@ export class SmoothScroll { const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; const lines = wheelLines(e, metrics.height, this.term.rows); - const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; - this.lastWheel = e.timeStamp; - if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); + const { wheel, gap } = this.classify(e); // A delta during a glide adds to where the glide is heading. const to = this.clamp((this.glide?.to ?? this.position) - lines); - if (e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel()) { + if (wheel) { this.glideTo(to, WHEEL_GLIDE_MS, easeOutCubic); } else if (gap >= SPARSE_GAP_MS) { // Spread the delta over the time it took to arrive, so a steady stream scrolls @@ -175,6 +173,25 @@ export class SmoothScroll { return true; } + /** + * How long to spread the scroll ticks a wheel event sends to an app over, for + * events this does not scroll itself. A sparse delta's ticks spread over its gap + * plus a frame, so a TrackPoint scrolls an app a line at a time instead of in + * bursts; a wheel notch's or trackpad delta's go at once (0). See ADR 045. + */ + tickSpread(e: WheelEvent): number { + const { wheel, gap } = this.classify(e); + return !wheel && gap >= SPARSE_GAP_MS ? Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS) : 0; + } + + /** Classifies a wheel event, once per event: a wheel notch or not, and the time since the previous one. */ + private classify(e: WheelEvent): { wheel: boolean; gap: number } { + const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; + this.lastWheel = e.timeStamp; + if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); + return { wheel: e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(), gap }; + } + /** New output arrived. ghostty-web only snaps to the bottom when viewportY > 0, so clear a sub-line offset here. */ onOutput(): void { if (this.term.viewportY === 0 && this.position !== 0) this.jump(0); diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 07d4e91..a900857 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -137,6 +137,80 @@ export function wheelTicks( return ticks === 0 ? 0 : ticks * dir; } +/** One frame at 60 Hz. */ +const FRAME_MS = 1000 / 60; + +/** + * Sends the scroll ticks of wheel events to an app spread over time instead of in + * one burst: a TrackPoint sends a delta of up to 20 lines every 30-200 ms, which + * an app would otherwise scroll in jumps. Ticks queued with a duration go out + * evenly over it, a few per frame; a new event's ticks join the ones still queued + * and spread over its duration, starting from the latest frame so the pace does + * not stall. A direction change drops the queued ticks, as `wheelTicks` drops its + * remainder. (webtty, ADR 045) + */ +export class TickPacer { + private seq = ''; + private dir = 0; + private total = 0; + private sent = 0; + private start = 0; + private duration = 0; + private lastFrame: number | null = null; + private frame = 0; + + constructor(private readonly send: (data: string) => void) {} + + /** Queues `count` ticks of `seq` in direction `dir` (-1 or 1) to send over `duration` ms; 0 sends them now. */ + push(seq: string, dir: number, count: number, duration: number): void { + if (dir !== this.dir) this.cancel(); + this.dir = dir; + // The latest event's sequence: its cell is where the pointer is now. + this.seq = seq; + const pending = this.total - this.sent; + if (duration <= 0) { + this.stop(); + this.send(seq.repeat(pending + count)); + return; + } + this.total = pending + count; + this.sent = 0; + this.start = this.lastFrame ?? performance.now() - FRAME_MS; + this.duration = duration; + if (!this.frame) this.frame = requestAnimationFrame(this.step); + } + + /** Drops the queued ticks. */ + cancel(): void { + this.stop(); + this.dir = 0; + } + + private stop(): void { + this.total = 0; + this.sent = 0; + this.lastFrame = null; + if (this.frame) cancelAnimationFrame(this.frame); + this.frame = 0; + } + + private readonly step = (now: number): void => { + this.frame = 0; + const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); + const due = Math.round(this.total * t) - this.sent; + if (due > 0) { + this.send(this.seq.repeat(due)); + this.sent += due; + } + if (this.sent < this.total) { + this.lastFrame = now; + this.frame = requestAnimationFrame(this.step); + } else { + this.stop(); + } + }; +} + /** SGR mouse report for `ticks` wheel ticks at a 1-based cell. */ export function wheelSgr(ticks: number, col: number, row: number): string { const btn = ticks < 0 ? 64 : 65; @@ -255,6 +329,8 @@ export class Terminal implements ITerminalCore { private scrollAnimationFrame?: number; private customWheelEventHandler?: (event: WheelEvent) => boolean; private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; + private customWheelTickSpread?: (event: WheelEvent) => number; + private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); private lastCursorY: number = 0; // Track cursor position for onCursorMove // DECSCUSR state: the app's cursor shape (undefined = options) and the // unfinished sequence at the end of the last write @@ -1103,6 +1179,15 @@ export class Terminal implements ITerminalCore { this.customWheelEventHandler = customWheelEventHandler; } + /** + * Sets how long, in ms, to spread the scroll ticks of a wheel event sent to the + * app over (mouse tracking, or arrow keys on the alternate screen); 0 sends them + * at once, as without a handler. Called once per such event. (webtty, ADR 045) + */ + public attachWheelTickSpread(spread?: (event: WheelEvent) => number): void { + this.customWheelTickSpread = spread; + } + // ========================================================================== // Link Detection Methods // ========================================================================== @@ -1330,6 +1415,8 @@ export class Terminal implements ITerminalCore { this.cancelRenderLoop(); this.writeQueue.length = 0; + this.tickPacer.cancel(); + // Stop smooth scroll animation if (this.scrollAnimationFrame) { cancelAnimationFrame(this.scrollAnimationFrame); @@ -1865,6 +1952,8 @@ export class Terminal implements ITerminalCore { if (this.wasmTerm?.hasMouseTracking()) { const metrics = this.renderer?.getMetrics(); if (!metrics || !this.canvas) return; + // Every event, so the spread sees the time since the previous one (webtty, ADR 045) + const spread = this.customWheelTickSpread?.(e) ?? 0; const ticks = wheelTicks( this.wheelAccumulator, e, @@ -1878,7 +1967,12 @@ export class Terminal implements ITerminalCore { 1, Math.floor((e.clientY - rect.top - this.renderer!.scrollOffset) / metrics.height) + 1 ); - this.dataEmitter.fire(wheelSgr(ticks, col, row)); + this.tickPacer.push( + wheelSgr(Math.sign(ticks), col, row), + Math.sign(ticks), + Math.abs(ticks), + spread + ); return; } @@ -1889,16 +1983,11 @@ export class Terminal implements ITerminalCore { // Alternate screen: send arrow keys to the application // Applications like vim handle scrolling internally // Standard: ~3 arrow presses per wheel "click" - const direction = e.deltaY > 0 ? 'down' : 'up'; + const dir = e.deltaY > 0 ? 1 : -1; const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5 - - for (let i = 0; i < count; i++) { - if (direction === 'up') { - this.dataEmitter.fire('\x1B[A'); // Up arrow - } else { - this.dataEmitter.fire('\x1B[B'); // Down arrow - } - } + // Up or down arrow, paced like the SGR ticks above (webtty, ADR 045) + const spread = this.customWheelTickSpread?.(e) ?? 0; + if (count > 0) this.tickPacer.push(dir < 0 ? '\x1B[A' : '\x1B[B', dir, count, spread); } else { // Normal screen: scroll viewport through history with smooth scrolling // Handle different deltaMode values for better trackpad/mouse support diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 0c41566..a8c977d 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -5,6 +5,7 @@ import { deviceCellPixels, mouseCursorFor, scanDecscusr, + TickPacer, type WheelAccumulator, wheelSgr, wheelTicks, @@ -161,6 +162,93 @@ describe('wheelSgr', () => { }); }); +describe('TickPacer', () => { + function frames(run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void) { + const g = globalThis as { + requestAnimationFrame?: unknown; + cancelAnimationFrame?: unknown; + performance?: unknown; + }; + const saved = [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance]; + let now = 0; + let pending: ((t: number) => void) | null = null; + g.requestAnimationFrame = (cb: (t: number) => void) => { + pending = cb; + return 1; + }; + g.cancelAnimationFrame = () => { + pending = null; + }; + g.performance = { now: () => now }; + const sent: string[] = []; + try { + run(new TickPacer((d) => sent.push(d)), sent, (ms) => { + now += ms; + const cb = pending; + pending = null; + cb?.(now); + }); + } finally { + [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance] = saved; + } + } + + test('a zero duration sends the ticks at once', () => { + frames((pacer, sent) => { + pacer.push('A', -1, 3, 0); + expect(sent).toEqual(['AAA']); + }); + }); + + test('spreads ticks evenly over the duration, a few per frame', () => { + frames((pacer, sent, tick) => { + // 12 ticks over 50 ms, starting a frame early: 4 per 16.7 ms frame, so the + // first frame sends 8 and the next the last 4. + pacer.push('A', -1, 12, 50); + expect(sent).toEqual([]); + tick(1000 / 60); + expect(sent).toEqual(['A'.repeat(8)]); + tick(1000 / 60); + expect(sent).toEqual(['A'.repeat(8), 'A'.repeat(4)]); + }); + }); + + test('a new event joins the ticks still queued', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 6, 50); + tick(1000 / 60); + pacer.push('B', -1, 6, 50); + for (let i = 0; i < 6; i++) tick(1000 / 60); + const all = sent.join(''); + expect(all.length).toBe(12); + // Queued ticks go out with the latest event's sequence. + expect(all.endsWith('B')).toBe(true); + }); + }); + + test('a zero-duration event flushes the queued ticks first', () => { + frames((pacer, sent) => { + pacer.push('A', 1, 6, 50); + pacer.push('B', 1, 2, 0); + expect(sent).toEqual(['BBBBBBBB']); + }); + }); + + test('a direction change drops the queued ticks', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 10, 100); + tick(1000 / 60); + const before = sent.join('').length; + pacer.push('B', 1, 1, 0); + expect(sent.join('').length).toBe(before + 1); + expect(sent.at(-1)).toBe('B'); + tick(1000 / 60); + tick(100); + expect(sent.at(-1)).toBe('B'); + }); + }); +}); + describe('cursorCellOffset', () => { const grid = { cols: 80, rows: 24 }; const cell = { width: 9, height: 18 }; From 0e5abb12eb25bcb652768e0e242e10b4285f07ae Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 17:36:41 -0400 Subject: [PATCH 026/295] fix(client): count alternate-screen wheel arrows by distance, respect reduced motion On the alternate screen without mouse tracking, the terminal sent round(deltaY / 33) arrow keys per wheel event, at most 5, with no remainder: slow trackpad scrolling and Firefox's 3-line notches did nothing in less. It now sends one arrow per line of travel, keeping the remainder. With prefers-reduced-motion, wheel and sparse deltas scroll the scrollback at once instead of gliding (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 + src/client/smooth-scroll.test.ts | 12 ++++++ src/client/smooth-scroll.ts | 11 ++++- src/client/vendor/ghostty-web/terminal.ts | 47 ++++++++++++++++++---- src/client/vendor/terminal.test.ts | 26 ++++++++++++ 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index c63c87d..33a8a4e 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -25,6 +25,8 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. - **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But a sparse delta's ticks no longer go out in one burst: `TickPacer` in the vendored terminal sends them evenly over the same gap plus a frame, a few per frame, starting from its latest frame like the glide. `SmoothScroll.tickSpread` makes the call with the same classifier, so wheel notches and trackpad deltas still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. +- **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. +- **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding. Paced ticks to apps are unchanged: they time input, they do not animate. ## Consequences diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index f3b8e49..11d0003 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -166,6 +166,18 @@ describe('SmoothScroll', () => { }); }); + test('with reduced motion a notch scrolls at once', () => { + withFrames(() => { + const g = globalThis as { window: { matchMedia?: unknown } }; + g.window.matchMedia = (q: string) => ({ + matches: q === '(prefers-reduced-motion: reduce)', + }); + const { term, scroll } = fake(10); + scroll.handleWheel(notch(-40)); + expect(term.viewportY).toBe(4); + }); + }); + test('line deltas glide too', () => { withFrames((tick) => { const { term, scroll } = fake(10); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 5bba49b..12e29a2 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -116,6 +116,11 @@ function easeOutCubic(t: number): number { return 1 - (1 - t) ** 3; } +/** The OS asks for less motion: wheel and sparse deltas then scroll at once. */ +function reducedMotion(): boolean { + return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; +} + function linear(t: number): number { return t; } @@ -158,7 +163,11 @@ export class SmoothScroll { const { wheel, gap } = this.classify(e); // A delta during a glide adds to where the glide is heading. const to = this.clamp((this.glide?.to ?? this.position) - lines); - if (wheel) { + if (reducedMotion()) { + this.stopGlide(); + this.position = to; + this.apply(); + } else if (wheel) { this.glideTo(to, WHEEL_GLIDE_MS, easeOutCubic); } else if (gap >= SPARSE_GAP_MS) { // Spread the delta over the time it took to arrive, so a steady stream scrolls diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index a900857..3881662 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -137,6 +137,33 @@ export function wheelTicks( return ticks === 0 ? 0 : ticks * dir; } +/** + * Arrow keys a wheel event sends on the alternate screen when the app does not + * track the mouse: one per line of travel, like scrolling the normal screen's + * scrollback. Pixel deltas count by cell height, line deltas by line, page deltas + * by `rows`; the remainder accumulates and a direction change drops it. Negative + * is up. (webtty: upstream sent round(deltaY / 33) per event, at most 5, so slow + * trackpad deltas and Firefox's 3-line notches sent nothing; ADR 045) + */ +export function wheelArrows( + acc: WheelAccumulator, + e: Pick, + cellHeight: number, + rows: number +): number { + const dir = e.deltaY < 0 ? -1 : 1; + if (dir !== acc.dir) { + acc.accum = 0; + acc.dir = dir; + } + const amount = Math.abs(e.deltaY); + // 0 is WheelEvent.DOM_DELTA_PIXEL, 1 DOM_DELTA_LINE. + acc.accum += e.deltaMode === 0 ? amount / cellHeight : e.deltaMode === 1 ? amount : amount * rows; + const lines = Math.trunc(acc.accum); + acc.accum -= lines; + return lines === 0 ? 0 : lines * dir; +} + /** One frame at 60 Hz. */ const FRAME_MS = 1000 / 60; @@ -329,6 +356,7 @@ export class Terminal implements ITerminalCore { private scrollAnimationFrame?: number; private customWheelEventHandler?: (event: WheelEvent) => boolean; private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; + private arrowAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private customWheelTickSpread?: (event: WheelEvent) => number; private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); private lastCursorY: number = 0; // Track cursor position for onCursorMove @@ -1980,14 +2008,19 @@ export class Terminal implements ITerminalCore { const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false; if (isAltScreen) { - // Alternate screen: send arrow keys to the application - // Applications like vim handle scrolling internally - // Standard: ~3 arrow presses per wheel "click" - const dir = e.deltaY > 0 ? 1 : -1; - const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5 - // Up or down arrow, paced like the SGR ticks above (webtty, ADR 045) + // Alternate screen: send arrow keys to the application, one per line of + // travel, paced like the SGR ticks above (webtty, ADR 045) const spread = this.customWheelTickSpread?.(e) ?? 0; - if (count > 0) this.tickPacer.push(dir < 0 ? '\x1B[A' : '\x1B[B', dir, count, spread); + const lines = wheelArrows( + this.arrowAccumulator, + e, + this.renderer?.getMetrics()?.height ?? 20, + this.rows + ); + const dir = Math.sign(lines); + if (lines !== 0) { + this.tickPacer.push(dir < 0 ? '\x1B[A' : '\x1B[B', dir, Math.abs(lines), spread); + } } else { // Normal screen: scroll viewport through history with smooth scrolling // Handle different deltaMode values for better trackpad/mouse support diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index a8c977d..3f58984 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -7,6 +7,7 @@ import { scanDecscusr, TickPacer, type WheelAccumulator, + wheelArrows, wheelSgr, wheelTicks, } from './ghostty-web/terminal'; @@ -162,6 +163,31 @@ describe('wheelSgr', () => { }); }); +describe('wheelArrows', () => { + const acc = (): WheelAccumulator => ({ accum: 0, dir: 0 }); + + test('slow trackpad deltas add up to one arrow per line', () => { + const a = acc(); + const sent = [3, 3, 3, 3, 3, 3].map((d) => wheelArrows(a, { deltaY: d, deltaMode: 0 }, 17, 40)); + expect(sent).toEqual([0, 0, 0, 0, 0, 1]); + }); + + test('a Firefox notch of 3 lines sends 3 arrows', () => { + expect(wheelArrows(acc(), { deltaY: -3, deltaMode: 1 }, 17, 40)).toBe(-3); + }); + + test('a large delta is not capped', () => { + expect(wheelArrows(acc(), { deltaY: 340, deltaMode: 0 }, 17, 40)).toBe(20); + }); + + test('page deltas scale by rows, and a direction change drops the remainder', () => { + const a = acc(); + expect(wheelArrows(a, { deltaY: 1, deltaMode: 2 }, 17, 40)).toBe(40); + expect(wheelArrows(a, { deltaY: 10, deltaMode: 0 }, 17, 40)).toBe(0); + expect(wheelArrows(a, { deltaY: -10, deltaMode: 0 }, 17, 40)).toBe(0); + }); +}); + describe('TickPacer', () => { function frames(run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void) { const g = globalThis as { From a23635022daa9b07936973e08636b6791fc41691 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 17:48:06 -0400 Subject: [PATCH 027/295] fix(client): clip the terminal container so the overscan canvas cannot scroll it The overscan canvas (ADR 043) extends 16 rows below #terminal, which made the overflow: hidden container scrollable from code: focusing the textarea or scrollIntoView could shift the view. overflow: clip cannot be scrolled at all. --- src/client/index.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/index.css b/src/client/index.css index 9548919..acede79 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -15,6 +15,9 @@ body, #terminal { position: relative; + /* The overscan canvas (ADR 043) extends below the box; clip, unlike hidden, cannot be + scrolled, so focusing the textarea or scrollIntoView cannot shift the view. */ + overflow: clip; } #terminal canvas { From 3d17d170a3108dd8a4c4e656e5087abcd2993173 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 17:50:32 -0400 Subject: [PATCH 028/295] feat(client): pace mouse wheel ticks to apps like the scrollback glide In mouse-tracking apps and on the alternate screen, a wheel notch of several lines now sends its ticks evenly over 125 ms, as the scrollback glides, instead of all at once. The pacer rounds up, so the first tick of any paced event goes out on the next frame. With reduced motion, ticks to apps go out at once (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 5 ++-- docs/specs/client.md | 2 +- src/client/smooth-scroll.test.ts | 27 ++++++++++++++-------- src/client/smooth-scroll.ts | 13 +++++++---- src/client/vendor/ghostty-web/terminal.ts | 4 +++- src/client/vendor/terminal.test.ts | 8 +++++++ 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 33a8a4e..82ef62a 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -24,14 +24,15 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. -- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But a sparse delta's ticks no longer go out in one burst: `TickPacer` in the vendored terminal sends them evenly over the same gap plus a frame, a few per frame, starting from its latest frame like the glide. `SmoothScroll.tickSpread` makes the call with the same classifier, so wheel notches and trackpad deltas still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. +- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` in the vendored terminal sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, a few per frame, starting from its latest frame like the glide. The count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `SmoothScroll.tickSpread` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. - **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. -- **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding. Paced ticks to apps are unchanged: they time input, they do not animate. +- **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding. Ticks to apps then go out at once too. ## Consequences - A mouse wheel scrolls scrollback smoothly, reaching each notch's target 125 ms later. - A TrackPoint scrolls smoothly at its own speed, about one event interval plus a frame behind the input. Replaying the capture at 60 fps moves the view on every frame, where applying deltas at once moved it by up to 335 px on one frame and not at all on the next. +- In an app, a mouse wheel notch of several lines scrolls a line at a time over 125 ms instead of all at once. - In an app (pi, Claude Code's fullscreen mode, vim), a TrackPoint scrolls a few lines every frame instead of about 20 lines every other frame: replaying the capture, a hard push went from 0 or 17–20 ticks per frame to 8–13 on every frame. An app can only move by whole lines, so this is as smooth as it gets there. Neither pi nor Claude Code has a sub-line scroll mode, and no escape sequence for one exists. - The first delta of a trackpad gesture comes after a pause, so it glides; the next delta, a frame later, finishes that glide. - The classifier needs a couple of events to settle; until then a notch after trackpad scrolling gets the sparse glide instead of the wheel glide. A trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. diff --git a/docs/specs/client.md b/docs/specs/client.md index ac1760b..360965b 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -199,7 +199,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | -| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, and in apps its scroll ticks are paced over that time instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | +| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 11d0003..3d2c52a 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -168,13 +168,17 @@ describe('SmoothScroll', () => { test('with reduced motion a notch scrolls at once', () => { withFrames(() => { - const g = globalThis as { window: { matchMedia?: unknown } }; - g.window.matchMedia = (q: string) => ({ - matches: q === '(prefers-reduced-motion: reduce)', - }); - const { term, scroll } = fake(10); - scroll.handleWheel(notch(-40)); - expect(term.viewportY).toBe(4); + const g = globalThis as { matchMedia?: unknown }; + const saved = g.matchMedia; + g.matchMedia = (q: string) => ({ matches: q === '(prefers-reduced-motion: reduce)' }); + try { + const { term, scroll } = fake(10); + scroll.handleWheel(notch(-40)); + expect(term.viewportY).toBe(4); + expect(scroll.tickSpread(notch(-40))).toBe(0); + } finally { + g.matchMedia = saved; + } }); }); @@ -273,10 +277,13 @@ describe('SmoothScroll.tickSpread', () => { expect(s.tickSpread(at(-30.25, 1200))).toBe(WHEEL_GLIDE_MS); }); - test('sends wheel notches and trackpad deltas at once', () => { + test('spreads a wheel notch over the wheel glide time', () => { + expect(scroll().tickSpread(at(-3, 1000, 1))).toBe(WHEEL_GLIDE_MS); + }); + + test('sends trackpad deltas at once', () => { const s = scroll(); - expect(s.tickSpread(at(-3, 1000, 1))).toBe(0); - expect(s.tickSpread(at(-20.5, 1200))).toBe(WHEEL_GLIDE_MS); + s.tickSpread(at(-20.5, 1200)); expect(s.tickSpread(at(-21.25, 1208))).toBe(0); }); }); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 12e29a2..fcedb7b 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -118,7 +118,7 @@ function easeOutCubic(t: number): number { /** The OS asks for less motion: wheel and sparse deltas then scroll at once. */ function reducedMotion(): boolean { - return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; + return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; } function linear(t: number): number { @@ -184,13 +184,16 @@ export class SmoothScroll { /** * How long to spread the scroll ticks a wheel event sends to an app over, for - * events this does not scroll itself. A sparse delta's ticks spread over its gap - * plus a frame, so a TrackPoint scrolls an app a line at a time instead of in - * bursts; a wheel notch's or trackpad delta's go at once (0). See ADR 045. + * events this does not scroll itself, as the scrollback glides: a wheel notch's + * over the wheel glide time, a sparse delta's over its gap plus a frame, so a + * mouse wheel or TrackPoint scrolls an app a line at a time instead of in bursts. + * Trackpad deltas, and everything with reduced motion, go at once (0). See ADR 045. */ tickSpread(e: WheelEvent): number { const { wheel, gap } = this.classify(e); - return !wheel && gap >= SPARSE_GAP_MS ? Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS) : 0; + if (reducedMotion()) return 0; + if (wheel) return WHEEL_GLIDE_MS; + return gap >= SPARSE_GAP_MS ? Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS) : 0; } /** Classifies a wheel event, once per event: a wheel notch or not, and the time since the previous one. */ diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 3881662..fdfcc25 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -224,7 +224,9 @@ export class TickPacer { private readonly step = (now: number): void => { this.frame = 0; const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); - const due = Math.round(this.total * t) - this.sent; + // Rounded up, so the first tick goes out on the first frame: a 1-tick notch + // is not held back for half the duration. + const due = Math.ceil(this.total * t) - this.sent; if (due > 0) { this.send(this.seq.repeat(due)); this.sent += due; diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 3f58984..3f0c7e3 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -219,6 +219,14 @@ describe('TickPacer', () => { } } + test('the first tick goes out on the first frame', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 1, 125); + tick(1000 / 60); + expect(sent).toEqual(['A']); + }); + }); + test('a zero duration sends the ticks at once', () => { frames((pacer, sent) => { pacer.push('A', -1, 3, 0); From 7d484d1e57d30911770805a636b723876f711283 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 17:56:31 -0400 Subject: [PATCH 029/295] fix(client): keep glides to their target, pace through short gaps, arrows for less Fixes from an end-to-end test of every input and context: - A glide stopped short when rounding put the viewport on the next line: its own onScroll looked like an outside move. It now ignores its own moves (a 100 px notch had stopped at 75 px). - A sparse stream stays sparse until two short gaps in a row, so one 24.9 ms TrackPoint gap no longer snaps 50 lines or flushes 33 ticks. - Wheel arrows on the alternate screen honour application cursor keys: less ignored ESC [ A/B, so the wheel did nothing in it. - The glide advances inside the render loop, just before it renders, instead of a frame later. - A notch within 200 ms of one in the same direction continues linearly instead of easing out again, so a spinning wheel or a slow TrackPoint no longer pulses (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 13 ++- src/client/index.ts | 1 + src/client/smooth-scroll.test.ts | 74 +++++++++--- src/client/smooth-scroll.ts | 128 ++++++++++++++------- src/client/vendor/ghostty-web/terminal.ts | 18 ++- 5 files changed, 169 insertions(+), 65 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 82ef62a..554fb8b 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -19,13 +19,15 @@ The browser cannot say which device sent a wheel event. Easing every event would Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad deltas, which come every frame, immediate. In `src/client/smooth-scroll.ts`: - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. -- **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. -- **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. +- **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. A notch within 200 ms of one in the same direction continues linearly over its gap plus a frame instead of easing out again, so a spinning wheel, or a TrackPoint's slow 4 px steps every 150 ms, scroll steadily instead of pulsing. +- **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. +- **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. +- **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. - **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` in the vendored terminal sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, a few per frame, starting from its latest frame like the glide. The count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `SmoothScroll.tickSpread` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. -- **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. +- **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. With application cursor keys (DECCKM), which `less` sets, they are `ESC O A`/`ESC O B`; `less` ignored `ESC [ A`/`ESC [ B`, so the wheel did nothing in it. - **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding. Ticks to apps then go out at once too. ## Consequences @@ -34,12 +36,15 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - A TrackPoint scrolls smoothly at its own speed, about one event interval plus a frame behind the input. Replaying the capture at 60 fps moves the view on every frame, where applying deltas at once moved it by up to 335 px on one frame and not at all on the next. - In an app, a mouse wheel notch of several lines scrolls a line at a time over 125 ms instead of all at once. - In an app (pi, Claude Code's fullscreen mode, vim), a TrackPoint scrolls a few lines every frame instead of about 20 lines every other frame: replaying the capture, a hard push went from 0 or 17–20 ticks per frame to 8–13 on every frame. An app can only move by whole lines, so this is as smooth as it gets there. Neither pi nor Claude Code has a sub-line scroll mode, and no escape sequence for one exists. -- The first delta of a trackpad gesture comes after a pause, so it glides; the next delta, a frame later, finishes that glide. +- The first deltas of a trackpad gesture come after a pause, so they glide over a frame or two; the second short gap in a row ends that. +- End-to-end in headless Chrome (wheel notches, a 16 ms trackpad stream and the replayed TrackPoint capture; shell scrollback, a mouse-tracking app and an alternate-screen app): tick and arrow totals match one per cell height of travel, trackpad scrolling paints in the frame its event arrives, and reversals, output while gliding and the ends of scrollback behave. - The classifier needs a couple of events to settle; until then a notch after trackpad scrolling gets the sparse glide instead of the wheel glide. A trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. - VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers, and its notches get the sparse glide instead of the wheel glide. ## Considered Options +- **Smooth the TrackPoint's speed** with a critically damped follower. The linear glide over each gap follows the input's own speed, which swings with its 29–44 ms gaps (84–227 px per frame at a hard push); a follower would even that out at the cost of more lag. Not done: the result was already reported smooth. + - **Ease every wheel event (xterm.js `smoothScrollDuration`).** Simple, but trackpad scrolling would trail the fingers again. - **Classify by `wheelDeltaY` multiples of 120.** Non-standard, and what it reports for a notch varies by browser and OS; VS Code's classifier is proven across Chromium, Safari and Firefox. diff --git a/src/client/index.ts b/src/client/index.ts index bea6d92..5143783 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -248,6 +248,7 @@ term.attachCustomWheelEventHandler( (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), ); term.attachWheelTickSpread((e: WheelEvent) => smoothScroll.tickSpread(e)); +term.attachBeforeRender((now: number) => smoothScroll.frame(now)); // Shared by the configured-binding listener below and the Ctrl+V paste // listener further down, so the latter can tell whether a user has already diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 3d2c52a..86f3a4f 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -44,6 +44,9 @@ describe('wheelLines', () => { }); }); +/** Scrolls made by `fake`, whose `frame` the render loop stand-in calls. */ +const live: SmoothScroll[] = []; + describe('SmoothScroll', () => { function fake(cellHeight: number) { const metrics = { height: cellHeight }; @@ -63,7 +66,9 @@ describe('SmoothScroll', () => { term.viewportY = line; }, }; - return { term, metrics, offsets, scroll: new SmoothScroll(term as unknown as Terminal) }; + const scroll = new SmoothScroll(term as unknown as Terminal); + live.push(scroll); + return { term, metrics, offsets, scroll }; } const wheel = (deltaY: number) => ({ deltaY, deltaMode: 0 }) as WheelEvent; @@ -112,24 +117,14 @@ describe('SmoothScroll', () => { const g = globalThis as { window?: unknown; performance?: unknown }; const [savedWindow, savedPerformance] = [g.window, g.performance]; let now = 0; - let pending: ((t: number) => void) | null = null; - g.window = { - devicePixelRatio: 1, - requestAnimationFrame: (cb: (t: number) => void) => { - pending = cb; - return 1; - }, - cancelAnimationFrame: () => { - pending = null; - }, - }; + g.window = { devicePixelRatio: 1 }; g.performance = { now: () => now }; + live.length = 0; try { + // One frame of the render loop, `ms` after the last. run((ms) => { now += ms; - const cb = pending; - pending = null; - cb?.(now); + for (const scroll of live) scroll.frame(now); }); } finally { g.window = savedWindow; @@ -182,6 +177,33 @@ describe('SmoothScroll', () => { }); }); + test('a glide is not stopped by its own move onto the next line', () => { + withFrames((tick) => { + const { term, offsets, scroll } = fake(10); + // ghostty-web fires onScroll synchronously from scrollToLine. + let onScroll = (_y: number) => {}; + const t = term as unknown as { onScroll: unknown; scrollToLine: (l: number) => void }; + const real = t.scrollToLine; + t.scrollToLine = (l: number) => { + real(l); + onScroll(l); + }; + const s = new SmoothScroll({ + ...term, + onScroll: (cb: (y: number) => void) => { + onScroll = cb; + }, + } as unknown as Terminal); + live.push(s); + void scroll; + // Frames 0.5 ms apart are sure to land within 0.05 lines below a line, where the + // offset rounds up to the next line. + s.handleWheel(notch(-40)); + for (let i = 0; i < 300; i++) tick(0.5); + expect([term.viewportY, offsets.at(-1)]).toEqual([4, 0]); + }); + }); + test('line deltas glide too', () => { withFrames((tick) => { const { term, scroll } = fake(10); @@ -281,10 +303,30 @@ describe('SmoothScroll.tickSpread', () => { expect(scroll().tickSpread(at(-3, 1000, 1))).toBe(WHEEL_GLIDE_MS); }); + test('a sparse stream stays paced through one short gap', () => { + const s = scroll(); + s.tickSpread(at(-23.5, 1000)); + expect(s.tickSpread(at(-63.25, 1040))).toBeCloseTo(40 + 1000 / 60); + // The capture's 25 ms gap replays as 24.9 ms. + expect(s.tickSpread(at(-95.5, 1064.9))).toBeCloseTo(24.9 + 1000 / 60); + expect(s.tickSpread(at(-120.75, 1100))).toBeCloseTo(35.1 + 1000 / 60); + }); + + test('a wheel notch soon after one in the same direction continues linearly', () => { + const s = scroll(); + expect(s.tickSpread(at(-3, 1000, 1))).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(-3, 1150, 1))).toBeCloseTo(150 + 1000 / 60); + // Reversing, or after a pause, a notch eases out again. + expect(s.tickSpread(at(3, 1200, 1))).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(3, 1500, 1))).toBe(WHEEL_GLIDE_MS); + }); + test('sends trackpad deltas at once', () => { const s = scroll(); s.tickSpread(at(-20.5, 1200)); - expect(s.tickSpread(at(-21.25, 1208))).toBe(0); + s.tickSpread(at(-21.25, 1208)); + // Two short gaps in a row end the sparse stream a gesture starts as. + expect(s.tickSpread(at(-22.75, 1216))).toBe(0); }); }); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index fcedb7b..82c70c3 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -57,6 +57,13 @@ export const WHEEL_GLIDE_MS = 125; */ export const SPARSE_GAP_MS = 25; +/** + * A wheel notch this soon after one in the same direction continues the scroll at + * a steady speed instead of easing out, so a spinning wheel or a slow TrackPoint + * (4 px every 150 ms) does not pulse. + */ +export const STREAM_MS = 200; + /** One frame at 60 Hz. */ const FRAME_MS = 1000 / 60; @@ -125,6 +132,12 @@ function linear(t: number): number { return t; } +/** How a wheel event scrolls: over `duration` ms with `ease`, or at once when 0. */ +interface Motion { + duration: number; + ease: (t: number) => number; +} + interface Glide { from: number; to: number; @@ -139,17 +152,22 @@ export class SmoothScroll { private readonly classifier = new WheelClassifier(); /** A glide in progress toward a wheel or sparse delta's target. */ private glide: Glide | null = null; - private frame = 0; /** Timestamp of the glide's latest frame, whose position a new glide starts from. */ private lastFrame: number | null = null; - /** `timeStamp` of the previous wheel event. */ + /** `timeStamp` and direction of the previous wheel event. */ private lastWheel: number | null = null; + private lastDir = 0; + /** In a sparse stream, and how many short gaps in a row it has had. */ + private sparse = false; + private shortGaps = 0; + /** Set while `apply` moves ghostty-web's viewport, whose onScroll is then our own. */ + private applying = false; constructor(private readonly term: Terminal) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard // scrolling and scrollbar drags. Follow it whenever it lands on a different line. term.onScroll((y: number) => { - if (y !== Math.floor(this.position)) this.jump(y); + if (!this.applying && y !== Math.floor(this.position)) this.jump(y); }); term.onResize(() => this.jump(term.viewportY)); } @@ -160,20 +178,11 @@ export class SmoothScroll { const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; const lines = wheelLines(e, metrics.height, this.term.rows); - const { wheel, gap } = this.classify(e); + const motion = this.classify(e); // A delta during a glide adds to where the glide is heading. const to = this.clamp((this.glide?.to ?? this.position) - lines); - if (reducedMotion()) { - this.stopGlide(); - this.position = to; - this.apply(); - } else if (wheel) { - this.glideTo(to, WHEEL_GLIDE_MS, easeOutCubic); - } else if (gap >= SPARSE_GAP_MS) { - // Spread the delta over the time it took to arrive, so a steady stream scrolls - // at a steady speed, the speed of the input. One frame more keeps the glide - // going until the next delta when the stream slows down a little. - this.glideTo(to, Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), linear); + if (motion.duration > 0) { + this.glideTo(to, motion.duration, motion.ease); } else { this.stopGlide(); this.position = to; @@ -182,6 +191,25 @@ export class SmoothScroll { return true; } + /** + * Advances a glide. ghostty-web's render loop calls this just before it renders, + * so the frame shows the glide's position for that frame, not the one before. + */ + frame(now: number): void { + const glide = this.glide; + if (!glide) return; + // A frame's timestamp can precede the glide's start by part of a frame. + const t = Math.min(1, Math.max(0, (now - glide.start) / glide.duration)); + this.position = this.clamp(glide.from + (glide.to - glide.from) * glide.ease(t)); + if (t < 1) { + this.lastFrame = now; + } else { + this.glide = null; + this.lastFrame = null; + } + this.apply(); + } + /** * How long to spread the scroll ticks a wheel event sends to an app over, for * events this does not scroll itself, as the scrollback glides: a wheel notch's @@ -190,18 +218,43 @@ export class SmoothScroll { * Trackpad deltas, and everything with reduced motion, go at once (0). See ADR 045. */ tickSpread(e: WheelEvent): number { - const { wheel, gap } = this.classify(e); - if (reducedMotion()) return 0; - if (wheel) return WHEEL_GLIDE_MS; - return gap >= SPARSE_GAP_MS ? Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS) : 0; + return this.classify(e).duration; } - /** Classifies a wheel event, once per event: a wheel notch or not, and the time since the previous one. */ - private classify(e: WheelEvent): { wheel: boolean; gap: number } { + /** + * How a wheel event scrolls; call once per event, as it tracks the stream. + * - A wheel notch eases out over the wheel glide time, or, soon after one in the + * same direction, continues linearly over its gap plus a frame. + * - A sparse delta, 25 ms or more after the previous one, glides linearly over + * that gap plus a frame, so a stream scrolls at the speed it is sent at; the + * extra frame keeps the glide going when the next delta is a little late. A + * stream stays sparse until two short gaps in a row, so one delta that comes + * a little early does not snap or flush. + * - Trackpad deltas, a frame apart, and everything with reduced motion: at once. + */ + private classify(e: WheelEvent): Motion { const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; this.lastWheel = e.timeStamp; + const dir = Math.sign(e.deltaY); + const sameDir = dir === this.lastDir; + this.lastDir = dir; if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); - return { wheel: e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(), gap }; + const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); + if (gap >= SPARSE_GAP_MS) { + this.sparse = true; + this.shortGaps = 0; + } else if (this.sparse && ++this.shortGaps >= 2) { + this.sparse = false; + } + let motion: Motion = { duration: 0, ease: linear }; + if (wheel && gap > 0 && gap <= STREAM_MS && sameDir) { + motion = { duration: Math.min(gap + FRAME_MS, STREAM_MS), ease: linear }; + } else if (wheel) { + motion = { duration: WHEEL_GLIDE_MS, ease: easeOutCubic }; + } else if (this.sparse) { + motion = { duration: Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), ease: linear }; + } + return reducedMotion() ? { duration: 0, ease: linear } : motion; } /** New output arrived. ghostty-web only snaps to the bottom when viewportY > 0, so clear a sub-line offset here. */ @@ -233,33 +286,13 @@ export class SmoothScroll { // stream of deltas would stall every other frame. const start = this.lastFrame ?? performance.now() - FRAME_MS; this.glide = { from: this.position, to, start, duration, ease }; - if (!this.frame) this.frame = window.requestAnimationFrame(this.step); } private stopGlide(): void { this.glide = null; this.lastFrame = null; - if (this.frame) window.cancelAnimationFrame(this.frame); - this.frame = 0; } - private readonly step = (now: number): void => { - this.frame = 0; - const glide = this.glide; - if (!glide) return; - // A frame's timestamp can precede the glide's start by part of a frame. - const t = Math.min(1, Math.max(0, (now - glide.start) / glide.duration)); - this.position = this.clamp(glide.from + (glide.to - glide.from) * glide.ease(t)); - if (t < 1) { - this.lastFrame = now; - this.frame = window.requestAnimationFrame(this.step); - } else { - this.glide = null; - this.lastFrame = null; - } - this.apply(); - }; - private apply(): void { const renderer = this.term.renderer; const metrics = renderer?.getMetrics(); @@ -271,7 +304,16 @@ export class SmoothScroll { ); // Changes ghostty-web's viewportY synchronously. Its render loop draws the rows that // came into view and shows the offset on the next frame. - if (line !== this.term.viewportY) this.term.scrollToLine(line); + // Rounding the offset can land on the next line while the position is still just + // below it; that is our own move, not one to follow. + if (line !== this.term.viewportY) { + this.applying = true; + try { + this.term.scrollToLine(line); + } finally { + this.applying = false; + } + } renderer.setScrollOffset(offset); } } diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index fdfcc25..7f5878c 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -360,6 +360,7 @@ export class Terminal implements ITerminalCore { private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private arrowAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private customWheelTickSpread?: (event: WheelEvent) => number; + private beforeRender?: (now: number) => void; private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); private lastCursorY: number = 0; // Track cursor position for onCursorMove // DECSCUSR state: the app's cursor shape (undefined = options) and the @@ -1218,6 +1219,14 @@ export class Terminal implements ITerminalCore { this.customWheelTickSpread = spread; } + /** + * Runs `callback` in every frame of the render loop, just before it renders, so + * an animation that moves the viewport shows in the same frame. (webtty, ADR 045) + */ + public attachBeforeRender(callback?: (now: number) => void): void { + this.beforeRender = callback; + } + // ========================================================================== // Link Detection Methods // ========================================================================== @@ -1556,8 +1565,10 @@ export class Terminal implements ITerminalCore { */ private startRenderLoop(): void { if (this.animationFrameId) return; // already running - const loop = () => { + const loop = (now?: number) => { if (!this.isDisposed && this.isOpen) { + this.beforeRender?.(now ?? performance.now()); + // Render using WASM's native dirty tracking // The render() method: // 1. Calls update() once to sync state and check dirty flags @@ -2021,7 +2032,10 @@ export class Terminal implements ITerminalCore { ); const dir = Math.sign(lines); if (lines !== 0) { - this.tickPacer.push(dir < 0 ? '\x1B[A' : '\x1B[B', dir, Math.abs(lines), spread); + // Application cursor keys (DECCKM), as less sets: ESC O A/B, else ESC [ A/B + const app = this.wasmTerm?.getMode(1, false) ?? false; + const seq = (app ? '\x1BO' : '\x1B[') + (dir < 0 ? 'A' : 'B'); + this.tickPacer.push(seq, dir, Math.abs(lines), spread); } } else { // Normal screen: scroll viewport through history with smooth scrolling From a4cf56bbb59d0724d607f11eee1a0fe115bf914e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:05:56 -0400 Subject: [PATCH 030/295] feat(client): follow a spring while a TrackPoint stream is fast A hard TrackPoint push sends a delta every 29-45 ms, and gliding linearly over each gap swung the speed with them (93-223 px per frame). While a sparse stream's average gap is under 60 ms, the scrollback and the ticks paced to apps now follow a critically damped spring instead, taking over at the current speed: a steady 156-166 px per frame, 9-10 ticks per frame, for about 35 ms more lag. Slower streams keep the linear glide. With reduced motion, a sparse stream's ticks to apps stay paced; only wheel notches go at once (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 9 +- docs/specs/client.md | 2 +- src/client/smooth-scroll.test.ts | 45 ++++++--- src/client/smooth-scroll.ts | 109 +++++++++++++++------ src/client/vendor/ghostty-web/terminal.ts | 89 ++++++++++++++--- src/client/vendor/terminal.test.ts | 33 +++++-- 6 files changed, 216 insertions(+), 71 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 554fb8b..3e4b9e6 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -21,14 +21,15 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. - **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. A notch within 200 ms of one in the same direction continues linearly over its gap plus a frame instead of easing out again, so a spinning wheel, or a TrackPoint's slow 4 px steps every 150 ms, scroll steadily instead of pulsing. - **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. +- **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. -- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` in the vendored terminal sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, a few per frame, starting from its latest frame like the glide. The count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `SmoothScroll.tickSpread` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. +- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` in the vendored terminal sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `SmoothScroll.tickSpread` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. - **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. With application cursor keys (DECCKM), which `less` sets, they are `ESC O A`/`ESC O B`; `less` ignored `ESC [ A`/`ESC [ B`, so the wheel did nothing in it. -- **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding. Ticks to apps then go out at once too. +- **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding, and a wheel notch's ticks go to apps at once. A sparse stream's ticks stay paced: pacing times input, it does not animate, and sending them at once brought back bursts of 20 ticks every other frame. ## Consequences @@ -43,7 +44,9 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de ## Considered Options -- **Smooth the TrackPoint's speed** with a critically damped follower. The linear glide over each gap follows the input's own speed, which swings with its 29–44 ms gaps (84–227 px per frame at a hard push); a follower would even that out at the cost of more lag. Not done: the result was already reported smooth. +- **A spring for every sparse stream.** Evens out fast streams, but makes slow ones (deltas 120 ms apart) pulse: the spring settles between deltas (summed frame-to-frame change 16 → 80–200 px in simulation). +- **A spring whose stiffness follows the stream's gap.** Smooth at every speed, but slow streams lagged by 230 ms. +- **A steady speed from a running average of the input's rate.** It fell behind and then had to drain the backlog, which swung the speed more than the linear glide. - **Ease every wheel event (xterm.js `smoothScrollDuration`).** Simple, but trackpad scrolling would trail the fingers again. - **Classify by `wheelDeltaY` multiples of 120.** Non-standard, and what it reports for a notch varies by browser and OS; VS Code's classifier is proven across Chromium, Safari and Firefox. diff --git a/docs/specs/client.md b/docs/specs/client.md index 360965b..3095f22 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -199,7 +199,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | -| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | +| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 86f3a4f..593e308 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { SmoothScroll, + STREAM_OMEGA, splitScroll, WHEEL_GLIDE_MS, WheelClassifier, @@ -170,7 +171,13 @@ describe('SmoothScroll', () => { const { term, scroll } = fake(10); scroll.handleWheel(notch(-40)); expect(term.viewportY).toBe(4); - expect(scroll.tickSpread(notch(-40))).toBe(0); + expect(scroll.tickSpread(notch(-40)).duration).toBe(0); + // A sparse stream's ticks to an app are still paced: that times input, it + // does not animate. + const point = (deltaY: number, timeStamp: number) => + ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; + scroll.tickSpread(point(-95.5, 4000)); + expect(scroll.tickSpread(point(-97.25, 4040)).duration).toBeGreaterThan(0); } finally { g.matchMedia = saved; } @@ -290,35 +297,49 @@ describe('SmoothScroll.tickSpread', () => { test('spreads a sparse delta over its gap plus a frame', () => { const s = scroll(); s.tickSpread(at(-4.000244140625, 1000)); - expect(s.tickSpread(at(-95.0897216796875, 1040))).toBeCloseTo(40 + 1000 / 60); + expect(s.tickSpread(at(-95.0897216796875, 1040)).duration).toBeCloseTo(40 + 1000 / 60); }); test('caps the spread at the wheel glide time', () => { const s = scroll(); s.tickSpread(at(-4.000244140625, 1000)); - expect(s.tickSpread(at(-30.25, 1200))).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(-30.25, 1200)).duration).toBe(WHEEL_GLIDE_MS); }); test('spreads a wheel notch over the wheel glide time', () => { - expect(scroll().tickSpread(at(-3, 1000, 1))).toBe(WHEEL_GLIDE_MS); + expect(scroll().tickSpread(at(-3, 1000, 1)).duration).toBe(WHEEL_GLIDE_MS); }); test('a sparse stream stays paced through one short gap', () => { const s = scroll(); s.tickSpread(at(-23.5, 1000)); - expect(s.tickSpread(at(-63.25, 1040))).toBeCloseTo(40 + 1000 / 60); + expect(s.tickSpread(at(-63.25, 1040)).duration).toBeCloseTo(40 + 1000 / 60); // The capture's 25 ms gap replays as 24.9 ms. - expect(s.tickSpread(at(-95.5, 1064.9))).toBeCloseTo(24.9 + 1000 / 60); - expect(s.tickSpread(at(-120.75, 1100))).toBeCloseTo(35.1 + 1000 / 60); + expect(s.tickSpread(at(-95.5, 1064.9)).duration).toBeCloseTo(24.9 + 1000 / 60); + expect(s.tickSpread(at(-120.75, 1100)).duration).toBeCloseTo(35.1 + 1000 / 60); }); test('a wheel notch soon after one in the same direction continues linearly', () => { const s = scroll(); - expect(s.tickSpread(at(-3, 1000, 1))).toBe(WHEEL_GLIDE_MS); - expect(s.tickSpread(at(-3, 1150, 1))).toBeCloseTo(150 + 1000 / 60); + expect(s.tickSpread(at(-3, 1000, 1)).duration).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(-3, 1150, 1)).duration).toBeCloseTo(150 + 1000 / 60); // Reversing, or after a pause, a notch eases out again. - expect(s.tickSpread(at(3, 1200, 1))).toBe(WHEEL_GLIDE_MS); - expect(s.tickSpread(at(3, 1500, 1))).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(3, 1200, 1)).duration).toBe(WHEEL_GLIDE_MS); + expect(s.tickSpread(at(3, 1500, 1)).duration).toBe(WHEEL_GLIDE_MS); + }); + + test('a fast sparse stream follows a spring', () => { + const s = scroll(); + let t = 1000; + s.tickSpread(at(-23.5, t)); + const paces = [40, 35, 30, 45, 33, 38, 31].map((gap, i) => { + t += gap; + return s.tickSpread(at(-(100.5 + i * 1.25), t)); + }); + // The pause before the stream counts as a slow gap, so it starts linear and + // turns into a spring once the average gap drops under 60 ms. + expect(paces[0].omega).toBeUndefined(); + expect(paces.at(-1)?.omega).toBe(STREAM_OMEGA); }); test('sends trackpad deltas at once', () => { @@ -326,7 +347,7 @@ describe('SmoothScroll.tickSpread', () => { s.tickSpread(at(-20.5, 1200)); s.tickSpread(at(-21.25, 1208)); // Two short gaps in a row end the sparse stream a gesture starts as. - expect(s.tickSpread(at(-22.75, 1216))).toBe(0); + expect(s.tickSpread(at(-22.75, 1216)).duration).toBe(0); }); }); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 82c70c3..15ef3ab 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -20,6 +20,7 @@ * when the app tracks the mouse, the app moves its own view in whole lines. */ import type { Terminal } from './vendor/ghostty-web'; +import { springStep, type WheelPace } from './vendor/ghostty-web/terminal'; export interface ScrollSplit { /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ @@ -64,6 +65,17 @@ export const SPARSE_GAP_MS = 25; */ export const STREAM_MS = 200; +/** + * A sparse stream whose deltas come this often or faster on average (a TrackPoint + * pushed hard, every 30-45 ms) follows a spring instead of gliding linearly over + * each gap: its gaps vary, and gliding over each one swung the speed by half + * from frame to frame. + */ +export const FAST_STREAM_MS = 60; + +/** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ +export const STREAM_OMEGA = 28; + /** One frame at 60 Hz. */ const FRAME_MS = 1000 / 60; @@ -132,19 +144,26 @@ function linear(t: number): number { return t; } -/** How a wheel event scrolls: over `duration` ms with `ease`, or at once when 0. */ -interface Motion { - duration: number; +/** + * How a wheel event scrolls: over `duration` ms with `ease`, at once when 0, or + * following a spring of stiffness `omega`. + */ +interface Motion extends WheelPace { ease: (t: number) => number; + /** A wheel notch's motion, which reduced motion turns off everywhere. */ + wheel: boolean; } -interface Glide { - from: number; - to: number; - start: number; - duration: number; - ease: (t: number) => number; -} +type Glide = + | { + kind: 'ease'; + from: number; + to: number; + start: number; + duration: number; + ease: (t: number) => number; + } + | { kind: 'spring'; to: number; omega: number }; export class SmoothScroll { /** Lines scrolled up from the bottom; fractional between rows. */ @@ -157,9 +176,12 @@ export class SmoothScroll { /** `timeStamp` and direction of the previous wheel event. */ private lastWheel: number | null = null; private lastDir = 0; - /** In a sparse stream, and how many short gaps in a row it has had. */ + /** In a sparse stream, how many short gaps in a row it has had, and its average gap. */ private sparse = false; private shortGaps = 0; + private averageGap = 0; + /** Scroll velocity in lines per second, kept across glides so a spring takes over smoothly. */ + private velocity = 0; /** Set while `apply` moves ghostty-web's viewport, whose onScroll is then our own. */ private applying = false; @@ -178,10 +200,14 @@ export class SmoothScroll { const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; const lines = wheelLines(e, metrics.height, this.term.rows); - const motion = this.classify(e); + const classified = this.classify(e); + const motion = reducedMotion() ? { ...classified, duration: 0, omega: undefined } : classified; // A delta during a glide adds to where the glide is heading. const to = this.clamp((this.glide?.to ?? this.position) - lines); - if (motion.duration > 0) { + if (motion.omega) { + if (this.glide?.kind !== 'spring') this.lastFrame ??= performance.now() - FRAME_MS; + this.glide = { kind: 'spring', to, omega: motion.omega }; + } else if (motion.duration > 0) { this.glideTo(to, motion.duration, motion.ease); } else { this.stopGlide(); @@ -198,14 +224,27 @@ export class SmoothScroll { frame(now: number): void { const glide = this.glide; if (!glide) return; - // A frame's timestamp can precede the glide's start by part of a frame. - const t = Math.min(1, Math.max(0, (now - glide.start) / glide.duration)); - this.position = this.clamp(glide.from + (glide.to - glide.from) * glide.ease(t)); - if (t < 1) { - this.lastFrame = now; + const before = this.position; + const dt = Math.min(Math.max(now - (this.lastFrame ?? now - FRAME_MS), 0), 50) / 1000; + let done: boolean; + if (glide.kind === 'spring') { + const [x, v] = springStep(this.position, this.velocity, glide.to, glide.omega, dt); + this.velocity = v; + done = Math.abs(glide.to - x) < 1e-3 && Math.abs(v) < 0.05; + this.position = this.clamp(done ? glide.to : x); } else { + // A frame's timestamp can precede the glide's start by part of a frame. + const t = Math.min(1, Math.max(0, (now - glide.start) / glide.duration)); + this.position = this.clamp(glide.from + (glide.to - glide.from) * glide.ease(t)); + if (dt > 0) this.velocity = (this.position - before) / dt; + done = t >= 1; + } + if (done) { this.glide = null; this.lastFrame = null; + this.velocity = 0; + } else { + this.lastFrame = now; } this.apply(); } @@ -217,8 +256,11 @@ export class SmoothScroll { * mouse wheel or TrackPoint scrolls an app a line at a time instead of in bursts. * Trackpad deltas, and everything with reduced motion, go at once (0). See ADR 045. */ - tickSpread(e: WheelEvent): number { - return this.classify(e).duration; + tickSpread(e: WheelEvent): WheelPace { + const { duration, omega, wheel } = this.classify(e); + // Pacing a sparse stream times input, it does not animate: kept with reduced motion. + if (wheel && reducedMotion()) return { duration: 0 }; + return omega ? { duration, omega } : { duration }; } /** @@ -229,8 +271,9 @@ export class SmoothScroll { * that gap plus a frame, so a stream scrolls at the speed it is sent at; the * extra frame keeps the glide going when the next delta is a little late. A * stream stays sparse until two short gaps in a row, so one delta that comes - * a little early does not snap or flush. - * - Trackpad deltas, a frame apart, and everything with reduced motion: at once. + * a little early does not snap or flush. While its deltas come every 60 ms or + * faster on average, it follows a spring instead, which evens out the speed. + * - Trackpad deltas, a frame apart: at once. */ private classify(e: WheelEvent): Motion { const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; @@ -241,20 +284,25 @@ export class SmoothScroll { if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); if (gap >= SPARSE_GAP_MS) { + // A stream's first gap is the pause before it; count it as the slowest gap. + const capped = Math.min(gap, WHEEL_GLIDE_MS); + this.averageGap = this.sparse ? 0.4 * capped + 0.6 * this.averageGap : WHEEL_GLIDE_MS; this.sparse = true; this.shortGaps = 0; } else if (this.sparse && ++this.shortGaps >= 2) { this.sparse = false; } - let motion: Motion = { duration: 0, ease: linear }; if (wheel && gap > 0 && gap <= STREAM_MS && sameDir) { - motion = { duration: Math.min(gap + FRAME_MS, STREAM_MS), ease: linear }; - } else if (wheel) { - motion = { duration: WHEEL_GLIDE_MS, ease: easeOutCubic }; - } else if (this.sparse) { - motion = { duration: Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), ease: linear }; + return { duration: Math.min(gap + FRAME_MS, STREAM_MS), ease: linear, wheel }; + } + if (wheel) return { duration: WHEEL_GLIDE_MS, ease: easeOutCubic, wheel }; + if (this.sparse && this.averageGap < FAST_STREAM_MS) { + return { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA, ease: linear, wheel }; + } + if (this.sparse) { + return { duration: Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), ease: linear, wheel }; } - return reducedMotion() ? { duration: 0, ease: linear } : motion; + return { duration: 0, ease: linear, wheel }; } /** New output arrived. ghostty-web only snaps to the bottom when viewportY > 0, so clear a sub-line offset here. */ @@ -285,12 +333,13 @@ export class SmoothScroll { // give the next frame, whose timestamp precedes the event, nothing to move, and a // stream of deltas would stall every other frame. const start = this.lastFrame ?? performance.now() - FRAME_MS; - this.glide = { from: this.position, to, start, duration, ease }; + this.glide = { kind: 'ease', from: this.position, to, start, duration, ease }; } private stopGlide(): void { this.glide = null; this.lastFrame = null; + this.velocity = 0; } private apply(): void { diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 7f5878c..dfd7d1d 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -167,6 +167,35 @@ export function wheelArrows( /** One frame at 60 Hz. */ const FRAME_MS = 1000 / 60; +/** + * How to send a wheel event's ticks: spread evenly over `duration` ms (0: at + * once), or, with `omega`, following a critically damped spring of that stiffness + * (rad/s) toward the queued total. (webtty, ADR 045) + */ +export interface WheelPace { + duration: number; + omega?: number; +} + +/** + * Advances a critically damped spring at `x` with velocity `v` (per second) + * toward a fixed `target` by `dt` seconds. Exact for a fixed target, so it is + * stable at any frame time, and it never overshoots a target it approaches from + * rest. Returns the new position and velocity. (webtty, ADR 045) + */ +export function springStep( + x: number, + v: number, + target: number, + omega: number, + dt: number +): [number, number] { + const e = x - target; + const k = Math.exp(-omega * dt); + const c = v + omega * e; + return [target + (e + c * dt) * k, (v - omega * c * dt) * k]; +} + /** * Sends the scroll ticks of wheel events to an app spread over time instead of in * one burst: a TrackPoint sends a delta of up to 20 lines every 30-200 ms, which @@ -183,27 +212,44 @@ export class TickPacer { private sent = 0; private start = 0; private duration = 0; + /** Spring stiffness while following a fast stream, else 0; and its position and velocity in ticks. */ + private omega = 0; + private x = 0; + private v = 0; private lastFrame: number | null = null; private frame = 0; constructor(private readonly send: (data: string) => void) {} - /** Queues `count` ticks of `seq` in direction `dir` (-1 or 1) to send over `duration` ms; 0 sends them now. */ - push(seq: string, dir: number, count: number, duration: number): void { + /** Queues `count` ticks of `seq` in direction `dir` (-1 or 1) to send as `pace` says; a 0 duration sends them now. */ + push(seq: string, dir: number, count: number, pace: WheelPace): void { if (dir !== this.dir) this.cancel(); this.dir = dir; // The latest event's sequence: its cell is where the pointer is now. this.seq = seq; const pending = this.total - this.sent; - if (duration <= 0) { + if (pace.omega) { + if (!this.omega) { + // Take over at the linear pace's speed, so the rate does not jump. + const linear = this.frame && this.duration > 0 ? (this.total / this.duration) * 1000 : 0; + this.total = pending; + this.sent = 0; + this.x = 0; + this.v = linear; + } + this.omega = pace.omega; + this.total += count; + } else if (pace.duration <= 0) { this.stop(); this.send(seq.repeat(pending + count)); return; + } else { + this.omega = 0; + this.total = pending + count; + this.sent = 0; + this.start = this.lastFrame ?? performance.now() - FRAME_MS; + this.duration = pace.duration; } - this.total = pending + count; - this.sent = 0; - this.start = this.lastFrame ?? performance.now() - FRAME_MS; - this.duration = duration; if (!this.frame) this.frame = requestAnimationFrame(this.step); } @@ -216,6 +262,9 @@ export class TickPacer { private stop(): void { this.total = 0; this.sent = 0; + this.omega = 0; + this.x = 0; + this.v = 0; this.lastFrame = null; if (this.frame) cancelAnimationFrame(this.frame); this.frame = 0; @@ -223,10 +272,18 @@ export class TickPacer { private readonly step = (now: number): void => { this.frame = 0; - const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); + let progress: number; + if (this.omega) { + const dt = Math.min(now - (this.lastFrame ?? now - FRAME_MS), 50) / 1000; + [this.x, this.v] = springStep(this.x, this.v, this.total, this.omega, dt); + progress = Math.min(this.total, this.x); + } else { + const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); + progress = this.total * t; + } // Rounded up, so the first tick goes out on the first frame: a 1-tick notch // is not held back for half the duration. - const due = Math.ceil(this.total * t) - this.sent; + const due = Math.min(this.total, Math.ceil(progress - 1e-9)) - this.sent; if (due > 0) { this.send(this.seq.repeat(due)); this.sent += due; @@ -359,7 +416,7 @@ export class Terminal implements ITerminalCore { private customWheelEventHandler?: (event: WheelEvent) => boolean; private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private arrowAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; - private customWheelTickSpread?: (event: WheelEvent) => number; + private customWheelTickSpread?: (event: WheelEvent) => WheelPace; private beforeRender?: (now: number) => void; private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); private lastCursorY: number = 0; // Track cursor position for onCursorMove @@ -1211,11 +1268,11 @@ export class Terminal implements ITerminalCore { } /** - * Sets how long, in ms, to spread the scroll ticks of a wheel event sent to the - * app over (mouse tracking, or arrow keys on the alternate screen); 0 sends them - * at once, as without a handler. Called once per such event. (webtty, ADR 045) + * Sets how to pace the scroll ticks of a wheel event sent to the app (mouse + * tracking, or arrow keys on the alternate screen); a 0 duration sends them at + * once, as without a handler. Called once per such event. (webtty, ADR 045) */ - public attachWheelTickSpread(spread?: (event: WheelEvent) => number): void { + public attachWheelTickSpread(spread?: (event: WheelEvent) => WheelPace): void { this.customWheelTickSpread = spread; } @@ -1994,7 +2051,7 @@ export class Terminal implements ITerminalCore { const metrics = this.renderer?.getMetrics(); if (!metrics || !this.canvas) return; // Every event, so the spread sees the time since the previous one (webtty, ADR 045) - const spread = this.customWheelTickSpread?.(e) ?? 0; + const spread = this.customWheelTickSpread?.(e) ?? { duration: 0 }; const ticks = wheelTicks( this.wheelAccumulator, e, @@ -2023,7 +2080,7 @@ export class Terminal implements ITerminalCore { if (isAltScreen) { // Alternate screen: send arrow keys to the application, one per line of // travel, paced like the SGR ticks above (webtty, ADR 045) - const spread = this.customWheelTickSpread?.(e) ?? 0; + const spread = this.customWheelTickSpread?.(e) ?? { duration: 0 }; const lines = wheelArrows( this.arrowAccumulator, e, diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 3f0c7e3..34fda35 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -221,15 +221,30 @@ describe('TickPacer', () => { test('the first tick goes out on the first frame', () => { frames((pacer, sent, tick) => { - pacer.push('A', -1, 1, 125); + pacer.push('A', -1, 1, { duration: 125 }); tick(1000 / 60); expect(sent).toEqual(['A']); }); }); + test('with a spring, ticks follow it and all arrive', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 20, { duration: 125, omega: 28 }); + const perFrame: number[] = []; + for (let i = 0; i < 30; i++) { + const before = sent.join('').length; + tick(1000 / 60); + perFrame.push(sent.join('').length - before); + } + expect(sent.join('').length).toBe(20); + // Spread over several frames, not sent at once. + expect(Math.max(...perFrame)).toBeLessThan(10); + }); + }); + test('a zero duration sends the ticks at once', () => { frames((pacer, sent) => { - pacer.push('A', -1, 3, 0); + pacer.push('A', -1, 3, { duration: 0 }); expect(sent).toEqual(['AAA']); }); }); @@ -238,7 +253,7 @@ describe('TickPacer', () => { frames((pacer, sent, tick) => { // 12 ticks over 50 ms, starting a frame early: 4 per 16.7 ms frame, so the // first frame sends 8 and the next the last 4. - pacer.push('A', -1, 12, 50); + pacer.push('A', -1, 12, { duration: 50 }); expect(sent).toEqual([]); tick(1000 / 60); expect(sent).toEqual(['A'.repeat(8)]); @@ -249,9 +264,9 @@ describe('TickPacer', () => { test('a new event joins the ticks still queued', () => { frames((pacer, sent, tick) => { - pacer.push('A', -1, 6, 50); + pacer.push('A', -1, 6, { duration: 50 }); tick(1000 / 60); - pacer.push('B', -1, 6, 50); + pacer.push('B', -1, 6, { duration: 50 }); for (let i = 0; i < 6; i++) tick(1000 / 60); const all = sent.join(''); expect(all.length).toBe(12); @@ -262,18 +277,18 @@ describe('TickPacer', () => { test('a zero-duration event flushes the queued ticks first', () => { frames((pacer, sent) => { - pacer.push('A', 1, 6, 50); - pacer.push('B', 1, 2, 0); + pacer.push('A', 1, 6, { duration: 50 }); + pacer.push('B', 1, 2, { duration: 0 }); expect(sent).toEqual(['BBBBBBBB']); }); }); test('a direction change drops the queued ticks', () => { frames((pacer, sent, tick) => { - pacer.push('A', -1, 10, 100); + pacer.push('A', -1, 10, { duration: 100 }); tick(1000 / 60); const before = sent.join('').length; - pacer.push('B', 1, 1, 0); + pacer.push('B', 1, 1, { duration: 0 }); expect(sent.join('').length).toBe(before + 1); expect(sent.at(-1)).toBe('B'); tick(1000 / 60); From 3c376dd42d6e48b348f631f5ef1277e0910c7ee8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:16:50 -0400 Subject: [PATCH 031/295] fix(client): keep a fast stream on its spring until it pauses, stop sooner after From the end-to-end re-run of the spring: - Two short gaps in a row left the spring and applied its pending distance at once (985 px in one frame). A stream now stays on the spring until it pauses for 125 ms or reverses; this also ends a push with a smooth taper instead of speeding up and stopping dead. - Once no event has come for 60 ms the spring stiffens to double, so the view stops in about 200 ms instead of coasting for 350 ms, and it settles within half a device pixel (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 +- src/client/smooth-scroll.ts | 20 +++++++++++++++++--- src/client/vendor/ghostty-web/terminal.ts | 17 ++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 3e4b9e6..1a43762 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -21,7 +21,7 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. - **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. A notch within 200 ms of one in the same direction continues linearly over its gap plus a frame instead of easing out again, so a spinning wheel, or a TrackPoint's slow 4 px steps every 150 ms, scroll steadily instead of pulsing. - **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. -- **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. +- **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. Once on the spring, a stream stays on it until it pauses for 125 ms or reverses: leaving it on two short gaps in a row applied the spring's pending distance at once (985 px in one frame in a replay with 22 ms gaps), and leaving it as the gaps grew at the end of a push sped the view up and then stopped it dead. Once no event has come for 60 ms, the spring's stiffness doubles, so the view stops in about 200 ms instead of coasting for 350 ms; it is settled within half a device pixel. End to end, a hard push's frames went from a frame-to-frame standard deviation of 24–43 px to 10.5–15 px, with 13 → 55 ms of lag. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 15ef3ab..fb51566 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -20,7 +20,7 @@ * when the app tracks the mouse, the app moves its own view in whole lines. */ import type { Terminal } from './vendor/ghostty-web'; -import { springStep, type WheelPace } from './vendor/ghostty-web/terminal'; +import { springOmega, springStep, type WheelPace } from './vendor/ghostty-web/terminal'; export interface ScrollSplit { /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ @@ -180,6 +180,8 @@ export class SmoothScroll { private sparse = false; private shortGaps = 0; private averageGap = 0; + /** Following the spring: until the stream pauses, every delta keeps doing so. */ + private springing = false; /** Scroll velocity in lines per second, kept across glides so a spring takes over smoothly. */ private velocity = 0; /** Set while `apply` moves ghostty-web's viewport, whose onScroll is then our own. */ @@ -228,9 +230,13 @@ export class SmoothScroll { const dt = Math.min(Math.max(now - (this.lastFrame ?? now - FRAME_MS), 0), 50) / 1000; let done: boolean; if (glide.kind === 'spring') { - const [x, v] = springStep(this.position, this.velocity, glide.to, glide.omega, dt); + const omega = springOmega(glide.omega, now - (this.lastWheel ?? now)); + const [x, v] = springStep(this.position, this.velocity, glide.to, omega, dt); this.velocity = v; - done = Math.abs(glide.to - x) < 1e-3 && Math.abs(v) < 0.05; + // Settled once within half a device pixel and moving less than that per frame. + const height = this.term.renderer?.getMetrics()?.height ?? 15; + const half = 0.5 / (height * (window.devicePixelRatio || 1)); + done = Math.abs(glide.to - x) < half && Math.abs(v) * (FRAME_MS / 1000) < half; this.position = this.clamp(done ? glide.to : x); } else { // A frame's timestamp can precede the glide's start by part of a frame. @@ -292,11 +298,19 @@ export class SmoothScroll { } else if (this.sparse && ++this.shortGaps >= 2) { this.sparse = false; } + // A fast stream stays on the spring until it pauses: leaving it on a short gap + // applied all the spring's pending distance at once (654 px in one frame), and + // leaving it as the gaps grew at the end of a push sped up, then stopped hard. + if (!wheel && this.springing && gap < WHEEL_GLIDE_MS && sameDir) { + return { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA, ease: linear, wheel }; + } + this.springing = false; if (wheel && gap > 0 && gap <= STREAM_MS && sameDir) { return { duration: Math.min(gap + FRAME_MS, STREAM_MS), ease: linear, wheel }; } if (wheel) return { duration: WHEEL_GLIDE_MS, ease: easeOutCubic, wheel }; if (this.sparse && this.averageGap < FAST_STREAM_MS) { + this.springing = true; return { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA, ease: linear, wheel }; } if (this.sparse) { diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index dfd7d1d..c0bdb0c 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -177,6 +177,18 @@ export interface WheelPace { omega?: number; } +/** + * A spring's stiffness doubles once no wheel event has come for this long: the + * stream has stopped, so the view should stop soon rather than coast for many + * lines, while a running stream keeps the softer, steadier spring. (webtty, ADR 045) + */ +export const SPRING_RELEASE_MS = 60; + +/** The stiffness of a spring with stiffness `omega` `sinceEvent` ms after the latest wheel event. */ +export function springOmega(omega: number, sinceEvent: number): number { + return sinceEvent > SPRING_RELEASE_MS ? omega * 2 : omega; +} + /** * Advances a critically damped spring at `x` with velocity `v` (per second) * toward a fixed `target` by `dt` seconds. Exact for a fixed target, so it is @@ -216,6 +228,7 @@ export class TickPacer { private omega = 0; private x = 0; private v = 0; + private lastPush = 0; private lastFrame: number | null = null; private frame = 0; @@ -227,6 +240,7 @@ export class TickPacer { this.dir = dir; // The latest event's sequence: its cell is where the pointer is now. this.seq = seq; + this.lastPush = performance.now(); const pending = this.total - this.sent; if (pace.omega) { if (!this.omega) { @@ -275,7 +289,8 @@ export class TickPacer { let progress: number; if (this.omega) { const dt = Math.min(now - (this.lastFrame ?? now - FRAME_MS), 50) / 1000; - [this.x, this.v] = springStep(this.x, this.v, this.total, this.omega, dt); + const omega = springOmega(this.omega, now - this.lastPush); + [this.x, this.v] = springStep(this.x, this.v, this.total, omega, dt); progress = Math.min(this.total, this.x); } else { const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); From 8abfeec9e279910553d0e323972b7e5c206beab8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:26:54 -0400 Subject: [PATCH 032/295] refactor(client): group the wheel scrolling code by role The scrolling code had grown in layers: the glide logic (timed glide, spring, starting a frame early, taking over at the current speed, stiffening after release) existed twice, in SmoothScroll and in TickPacer; event classification lived inside SmoothScroll and the app path called into it; the app wheel helpers sat at the top of the 2,400-line terminal.ts; and upstream's eased normal-screen scrolling was dead code that would have left viewportY fractional. - vendor/ghostty-web/glide.ts: Glide, the one motion both follow. - vendor/ghostty-web/wheel.ts: what a wheel event sends to an app (wheelTicks, wheelArrows, wheelSgr, wheelArrowKey) and TickPacer. - wheel-stream.ts: WheelStream classifies each event once and picks its pace, forScrollback and forApp. - smooth-scroll.ts: SmoothScroll, the scrollback position only. - terminal.ts: handleWheel only routes; attachWheelPace replaces attachWheelTickSpread; smoothScrollTo, animateScroll and the smoothScrollDuration option are removed. Replaying the TrackPoint captures, wheel notches, a trackpad stream and a short-gap stream before and after: the same per-frame scrollback motion except a smoother end-of-push taper, and the same tick totals, with single ticks shifted by a frame. Ticks to apps stay evenly paced whatever the pace's easing. --- docs/adrs/040.client.pixel-smooth-scroll.md | 2 +- docs/adrs/045.client.smooth-mouse-wheel.md | 11 +- src/client/index.ts | 15 +- src/client/smooth-scroll.test.ts | 142 +------ src/client/smooth-scroll.ts | 292 ++------------- src/client/vendor/ghostty-web/PROVENANCE.md | 3 +- src/client/vendor/ghostty-web/glide.ts | 157 ++++++++ src/client/vendor/ghostty-web/interfaces.ts | 1 - src/client/vendor/ghostty-web/terminal.ts | 393 ++------------------ src/client/vendor/ghostty-web/wheel.ts | 149 ++++++++ src/client/vendor/glide.test.ts | 97 +++++ src/client/vendor/terminal.test.ts | 193 ---------- src/client/vendor/wheel.test.ts | 209 +++++++++++ src/client/wheel-stream.test.ts | 154 ++++++++ src/client/wheel-stream.ts | 187 ++++++++++ 15 files changed, 1036 insertions(+), 969 deletions(-) create mode 100644 src/client/vendor/ghostty-web/glide.ts create mode 100644 src/client/vendor/ghostty-web/wheel.ts create mode 100644 src/client/vendor/glide.test.ts create mode 100644 src/client/vendor/wheel.test.ts create mode 100644 src/client/wheel-stream.test.ts create mode 100644 src/client/wheel-stream.ts diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index fbf3524..f9fe4d3 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -25,7 +25,7 @@ The alternate screen and mouse-tracking apps scroll their own view in whole line **Update (ADR 041):** the mouse-tracking tick counting now lives in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`, `wheelTicks`), with ADR 017's SGR reports; `index.ts` keeps only the pixel-smooth scrollback handler. -**Update (ADR 045):** a mouse wheel notch in scrollback now glides to its target instead of jumping there. +**Update (ADR 045):** a mouse wheel notch in scrollback now glides to its target instead of jumping there. The tick counting for apps is in `src/client/vendor/ghostty-web/wheel.ts`. ## Consequences diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 1a43762..1604c97 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -16,7 +16,14 @@ The browser cannot say which device sent a wheel event. Easing every event would ## Decision -Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad deltas, which come every frame, immediate. In `src/client/smooth-scroll.ts`: +Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad deltas, which come every frame, immediate. The code is in four parts: + +- `src/client/wheel-stream.ts`: `WheelStream` classifies each event once and picks its `Pace`, for the scrollback (`forScrollback`) and for apps (`forApp`). +- `src/client/smooth-scroll.ts`: `SmoothScroll` moves the scrollback position at that pace. +- `src/client/vendor/ghostty-web/wheel.ts`: what a wheel event sends to an app, and `TickPacer`, which paces it. +- `src/client/vendor/ghostty-web/glide.ts`: `Glide`, the one motion both follow (timed glide or spring). + +In detail: - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. - **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. A notch within 200 ms of one in the same direction continues linearly over its gap plus a frame instead of easing out again, so a spinning wheel, or a TrackPoint's slow 4 px steps every 150 ms, scroll steadily instead of pulsing. @@ -27,7 +34,7 @@ Glide toward a mouse wheel notch's or a sparse delta's target; leave trackpad de - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. -- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` in the vendored terminal sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `SmoothScroll.tickSpread` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. +- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The ticks go out evenly whatever the pace's easing, as an app moves in whole lines, and the count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `WheelStream.forApp` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. - **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. With application cursor keys (DECCKM), which `less` sets, they are `ESC O A`/`ESC O B`; `less` ignored `ESC [ A`/`ESC [ B`, so the wheel did nothing in it. - **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding, and a wheel notch's ticks go to apps at once. A sparse stream's ticks stay paced: pacing times input, it does not animate, and sending them at once brought back bursts of 20 ticks every other frame. diff --git a/src/client/index.ts b/src/client/index.ts index 5143783..e85596d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -8,6 +8,7 @@ import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; import { FitAddon, Ghostty, Terminal } from './vendor/ghostty-web'; import { watchDevicePixelRatio } from './vendor/ghostty-web/renderer'; +import { WheelStream } from './wheel-stream'; interface KeyboardBinding { key: string; @@ -103,7 +104,8 @@ const edges = new EdgeExtender(container, config.padding, () => term.renderer?.v // stray line under the canvas. See ADR 038. new Composer(term, container, terminalTheme); // Pixel-smooth scrollback scrolling for trackpads. See ADR 040 and ADR 043. -const smoothScroll = new SmoothScroll(term); +const wheelStream = new WheelStream(); +const smoothScroll = new SmoothScroll(term, wheelStream); // FitAddon sizes the grid from the full container less its CSS padding, so the // padding is set to config.padding on every side first (#terminal is border-box, so @@ -241,13 +243,16 @@ function connect(): void { connect(); -// Pixel-smooth scrollback for the normal screen. When the app tracks the mouse, the -// terminal reports wheel ticks to it as SGR scroll buttons (ADR 017, ADR 040), paced -// for sparse deltas such as a TrackPoint's (ADR 045). +// Wheel scrolling (ADR 017, ADR 040, ADR 045). WheelStream tells a mouse wheel, a +// trackpad and a TrackPoint apart and picks how each event moves: +// - on the normal screen, unless the app tracks the mouse, SmoothScroll glides the +// scrollback pixel by pixel, advanced just before each render; +// - otherwise the terminal sends the app SGR scroll buttons, or arrow keys on the +// alternate screen, paced the same way. term.attachCustomWheelEventHandler( (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), ); -term.attachWheelTickSpread((e: WheelEvent) => smoothScroll.tickSpread(e)); +term.attachWheelPace((e: WheelEvent) => wheelStream.forApp(e)); term.attachBeforeRender((now: number) => smoothScroll.frame(now)); // Shared by the configured-binding listener below and the Ctrl+V paste diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts index 593e308..872549c 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/smooth-scroll.test.ts @@ -1,13 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { - SmoothScroll, - STREAM_OMEGA, - splitScroll, - WHEEL_GLIDE_MS, - WheelClassifier, - wheelLines, -} from './smooth-scroll'; +import { SmoothScroll, splitScroll, wheelLines } from './smooth-scroll'; import type { Terminal } from './vendor/ghostty-web'; +import { WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; describe('splitScroll', () => { test('a whole-line position has no offset', () => { @@ -67,7 +61,7 @@ describe('SmoothScroll', () => { term.viewportY = line; }, }; - const scroll = new SmoothScroll(term as unknown as Terminal); + const scroll = new SmoothScroll(term as unknown as Terminal, new WheelStream()); live.push(scroll); return { term, metrics, offsets, scroll }; } @@ -171,13 +165,6 @@ describe('SmoothScroll', () => { const { term, scroll } = fake(10); scroll.handleWheel(notch(-40)); expect(term.viewportY).toBe(4); - expect(scroll.tickSpread(notch(-40)).duration).toBe(0); - // A sparse stream's ticks to an app are still paced: that times input, it - // does not animate. - const point = (deltaY: number, timeStamp: number) => - ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; - scroll.tickSpread(point(-95.5, 4000)); - expect(scroll.tickSpread(point(-97.25, 4040)).duration).toBeGreaterThan(0); } finally { g.matchMedia = saved; } @@ -195,12 +182,15 @@ describe('SmoothScroll', () => { real(l); onScroll(l); }; - const s = new SmoothScroll({ - ...term, - onScroll: (cb: (y: number) => void) => { - onScroll = cb; - }, - } as unknown as Terminal); + const s = new SmoothScroll( + { + ...term, + onScroll: (cb: (y: number) => void) => { + onScroll = cb; + }, + } as unknown as Terminal, + new WheelStream(), + ); live.push(s); void scroll; // Frames 0.5 ms apart are sure to land within 0.05 lines below a line, where the @@ -287,111 +277,3 @@ describe('SmoothScroll', () => { }); }); }); - -describe('SmoothScroll.tickSpread', () => { - const scroll = () => - new SmoothScroll({ onScroll: () => {}, onResize: () => {} } as unknown as Terminal); - const at = (deltaY: number, timeStamp: number, deltaMode = 0) => - ({ deltaX: 0, deltaY, deltaMode, timeStamp }) as WheelEvent; - - test('spreads a sparse delta over its gap plus a frame', () => { - const s = scroll(); - s.tickSpread(at(-4.000244140625, 1000)); - expect(s.tickSpread(at(-95.0897216796875, 1040)).duration).toBeCloseTo(40 + 1000 / 60); - }); - - test('caps the spread at the wheel glide time', () => { - const s = scroll(); - s.tickSpread(at(-4.000244140625, 1000)); - expect(s.tickSpread(at(-30.25, 1200)).duration).toBe(WHEEL_GLIDE_MS); - }); - - test('spreads a wheel notch over the wheel glide time', () => { - expect(scroll().tickSpread(at(-3, 1000, 1)).duration).toBe(WHEEL_GLIDE_MS); - }); - - test('a sparse stream stays paced through one short gap', () => { - const s = scroll(); - s.tickSpread(at(-23.5, 1000)); - expect(s.tickSpread(at(-63.25, 1040)).duration).toBeCloseTo(40 + 1000 / 60); - // The capture's 25 ms gap replays as 24.9 ms. - expect(s.tickSpread(at(-95.5, 1064.9)).duration).toBeCloseTo(24.9 + 1000 / 60); - expect(s.tickSpread(at(-120.75, 1100)).duration).toBeCloseTo(35.1 + 1000 / 60); - }); - - test('a wheel notch soon after one in the same direction continues linearly', () => { - const s = scroll(); - expect(s.tickSpread(at(-3, 1000, 1)).duration).toBe(WHEEL_GLIDE_MS); - expect(s.tickSpread(at(-3, 1150, 1)).duration).toBeCloseTo(150 + 1000 / 60); - // Reversing, or after a pause, a notch eases out again. - expect(s.tickSpread(at(3, 1200, 1)).duration).toBe(WHEEL_GLIDE_MS); - expect(s.tickSpread(at(3, 1500, 1)).duration).toBe(WHEEL_GLIDE_MS); - }); - - test('a fast sparse stream follows a spring', () => { - const s = scroll(); - let t = 1000; - s.tickSpread(at(-23.5, t)); - const paces = [40, 35, 30, 45, 33, 38, 31].map((gap, i) => { - t += gap; - return s.tickSpread(at(-(100.5 + i * 1.25), t)); - }); - // The pause before the stream counts as a slow gap, so it starts linear and - // turns into a spring once the average gap drops under 60 ms. - expect(paces[0].omega).toBeUndefined(); - expect(paces.at(-1)?.omega).toBe(STREAM_OMEGA); - }); - - test('sends trackpad deltas at once', () => { - const s = scroll(); - s.tickSpread(at(-20.5, 1200)); - s.tickSpread(at(-21.25, 1208)); - // Two short gaps in a row end the sparse stream a gesture starts as. - expect(s.tickSpread(at(-22.75, 1216)).duration).toBe(0); - }); -}); - -describe('WheelClassifier', () => { - const feed = (deltas: [number, number][]) => { - const c = new WheelClassifier(); - for (const [x, y] of deltas) c.accept(x, y); - return c.isPhysicalMouseWheel(); - }; - - test('steady integer notches are a wheel', () => { - expect( - feed([ - [0, 4], - [0, 4], - [0, 8], - [0, 4], - ]), - ).toBe(true); - }); - - test('fractional, accelerating deltas are a trackpad', () => { - expect( - feed([ - [0, 1.5], - [0, 3.25], - [0, 7.75], - [0, 12.5], - ]), - ).toBe(false); - }); - - test('both axes at once are a trackpad', () => { - expect( - feed([ - [0, 4], - [0, 4], - [1, 4], - [2, 6], - ]), - ).toBe(false); - }); - - test('no events is not a wheel', () => { - expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); - }); -}); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index fb51566..7c386dc 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -1,5 +1,5 @@ /** - * Pixel-smooth scrollback scrolling. See ADR 040 and ADR 043. + * Pixel-smooth scrollback scrolling. See ADR 040, ADR 043 and ADR 045. * * ghostty-web draws whole rows only: its viewport is a line count, and a fractional * viewportY is floored when rows are picked. A trackpad reports pixel deltas, so @@ -12,15 +12,16 @@ * sliding into view. At a whole-line position there is no offset, so a resting * terminal renders exactly as before. * - * A mouse wheel notch glides to its target instead of jumping there, and so does a - * sparse stream of deltas such as a TrackPoint's (ADR 045); trackpad deltas, which + * The position moves as a `Glide`, at the pace `WheelStream` picks for each event: + * a mouse wheel notch or a TrackPoint's sparse deltas glide, trackpad deltas, which * arrive every frame, apply at once. * * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ import type { Terminal } from './vendor/ghostty-web'; -import { springOmega, springStep, type WheelPace } from './vendor/ghostty-web/terminal'; +import { Glide } from './vendor/ghostty-web/glide'; +import type { WheelStream } from './wheel-stream'; export interface ScrollSplit { /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ @@ -49,149 +50,20 @@ export function wheelLines( return e.deltaY / cellHeight; } -/** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ -export const WHEEL_GLIDE_MS = 125; - -/** - * A pixel delta this long after the previous one is part of a sparse stream: a - * TrackPoint sends one every 30-200 ms, a trackpad one per frame. - */ -export const SPARSE_GAP_MS = 25; - -/** - * A wheel notch this soon after one in the same direction continues the scroll at - * a steady speed instead of easing out, so a spinning wheel or a slow TrackPoint - * (4 px every 150 ms) does not pulse. - */ -export const STREAM_MS = 200; - -/** - * A sparse stream whose deltas come this often or faster on average (a TrackPoint - * pushed hard, every 30-45 ms) follows a spring instead of gliding linearly over - * each gap: its gaps vary, and gliding over each one swung the speed by half - * from frame to frame. - */ -export const FAST_STREAM_MS = 60; - -/** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ -export const STREAM_OMEGA = 28; - -/** One frame at 60 Hz. */ -const FRAME_MS = 1000 / 60; - -/** - * Tells a physical mouse wheel from a trackpad by its recent pixel deltas. - * Adapted from VS Code's `MouseWheelClassifier` (scrollableElement.ts), - * Copyright (c) Microsoft Corporation, MIT License. - */ -export class WheelClassifier { - private static readonly CAPACITY = 5; - /** Scores of the latest events, newest last: 0 looks like a wheel, 1 like a trackpad. */ - private scores: number[] = []; - private previous: { deltaX: number; deltaY: number } | null = null; - - accept(deltaX: number, deltaY: number): void { - this.scores.push(this.score(deltaX, deltaY)); - if (this.scores.length > WheelClassifier.CAPACITY) this.scores.shift(); - this.previous = { deltaX, deltaY }; - } - - /** The newest score weighs 1/2, the next 1/4, and so on; the oldest takes the rest. */ - isPhysicalMouseWheel(): boolean { - if (this.scores.length === 0) return false; - let remaining = 1; - let total = 0; - for (let i = this.scores.length - 1; i >= 0; i--) { - const influence = i === 0 ? remaining : 2 ** -(this.scores.length - i); - remaining -= influence; - total += this.scores[i] * influence; - } - return total <= 0.5; - } - - private score(deltaX: number, deltaY: number): number { - // Both axes at once: a trackpad. - if (deltaX !== 0 && deltaY !== 0) return 1; - let score = 0.5; - if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; - // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. - const prev = this.previous; - if (prev) { - const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; - const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; - const minX = Math.max(Math.min(ax, px), 1); - const minY = Math.max(Math.min(ay, py), 1); - if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; - } - return Math.min(Math.max(score, 0), 1); - } -} - -function isAlmostInt(value: number): boolean { - return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; -} - -function easeOutCubic(t: number): number { - return 1 - (1 - t) ** 3; -} - -/** The OS asks for less motion: wheel and sparse deltas then scroll at once. */ -function reducedMotion(): boolean { - return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; -} - -function linear(t: number): number { - return t; -} - -/** - * How a wheel event scrolls: over `duration` ms with `ease`, at once when 0, or - * following a spring of stiffness `omega`. - */ -interface Motion extends WheelPace { - ease: (t: number) => number; - /** A wheel notch's motion, which reduced motion turns off everywhere. */ - wheel: boolean; -} - -type Glide = - | { - kind: 'ease'; - from: number; - to: number; - start: number; - duration: number; - ease: (t: number) => number; - } - | { kind: 'spring'; to: number; omega: number }; - export class SmoothScroll { - /** Lines scrolled up from the bottom; fractional between rows. */ - private position = 0; - private readonly classifier = new WheelClassifier(); - /** A glide in progress toward a wheel or sparse delta's target. */ - private glide: Glide | null = null; - /** Timestamp of the glide's latest frame, whose position a new glide starts from. */ - private lastFrame: number | null = null; - /** `timeStamp` and direction of the previous wheel event. */ - private lastWheel: number | null = null; - private lastDir = 0; - /** In a sparse stream, how many short gaps in a row it has had, and its average gap. */ - private sparse = false; - private shortGaps = 0; - private averageGap = 0; - /** Following the spring: until the stream pauses, every delta keeps doing so. */ - private springing = false; - /** Scroll velocity in lines per second, kept across glides so a spring takes over smoothly. */ - private velocity = 0; + /** The position in lines scrolled up from the bottom, fractional between rows. */ + private readonly glide = new Glide(); /** Set while `apply` moves ghostty-web's viewport, whose onScroll is then our own. */ private applying = false; - constructor(private readonly term: Terminal) { + constructor( + private readonly term: Terminal, + private readonly stream: WheelStream, + ) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard // scrolling and scrollbar drags. Follow it whenever it lands on a different line. term.onScroll((y: number) => { - if (!this.applying && y !== Math.floor(this.position)) this.jump(y); + if (!this.applying && y !== Math.floor(this.glide.value)) this.jump(y); }); term.onResize(() => this.jump(term.viewportY)); } @@ -202,20 +74,10 @@ export class SmoothScroll { const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; const lines = wheelLines(e, metrics.height, this.term.rows); - const classified = this.classify(e); - const motion = reducedMotion() ? { ...classified, duration: 0, omega: undefined } : classified; // A delta during a glide adds to where the glide is heading. - const to = this.clamp((this.glide?.to ?? this.position) - lines); - if (motion.omega) { - if (this.glide?.kind !== 'spring') this.lastFrame ??= performance.now() - FRAME_MS; - this.glide = { kind: 'spring', to, omega: motion.omega }; - } else if (motion.duration > 0) { - this.glideTo(to, motion.duration, motion.ease); - } else { - this.stopGlide(); - this.position = to; - this.apply(); - } + const to = this.clamp(this.glide.target - lines); + this.glide.retarget(to, this.stream.forScrollback(e), performance.now()); + if (!this.glide.active) this.apply(); return true; } @@ -224,104 +86,17 @@ export class SmoothScroll { * so the frame shows the glide's position for that frame, not the one before. */ frame(now: number): void { - const glide = this.glide; - if (!glide) return; - const before = this.position; - const dt = Math.min(Math.max(now - (this.lastFrame ?? now - FRAME_MS), 0), 50) / 1000; - let done: boolean; - if (glide.kind === 'spring') { - const omega = springOmega(glide.omega, now - (this.lastWheel ?? now)); - const [x, v] = springStep(this.position, this.velocity, glide.to, omega, dt); - this.velocity = v; - // Settled once within half a device pixel and moving less than that per frame. - const height = this.term.renderer?.getMetrics()?.height ?? 15; - const half = 0.5 / (height * (window.devicePixelRatio || 1)); - done = Math.abs(glide.to - x) < half && Math.abs(v) * (FRAME_MS / 1000) < half; - this.position = this.clamp(done ? glide.to : x); - } else { - // A frame's timestamp can precede the glide's start by part of a frame. - const t = Math.min(1, Math.max(0, (now - glide.start) / glide.duration)); - this.position = this.clamp(glide.from + (glide.to - glide.from) * glide.ease(t)); - if (dt > 0) this.velocity = (this.position - before) / dt; - done = t >= 1; - } - if (done) { - this.glide = null; - this.lastFrame = null; - this.velocity = 0; - } else { - this.lastFrame = now; - } + if (!this.glide.active) return; + // A spring has arrived once within half a device pixel. + const height = this.term.renderer?.getMetrics()?.height ?? 15; + this.glide.step(now, 0.5 / (height * (window.devicePixelRatio || 1))); + this.glide.value = this.clamp(this.glide.value); this.apply(); } - /** - * How long to spread the scroll ticks a wheel event sends to an app over, for - * events this does not scroll itself, as the scrollback glides: a wheel notch's - * over the wheel glide time, a sparse delta's over its gap plus a frame, so a - * mouse wheel or TrackPoint scrolls an app a line at a time instead of in bursts. - * Trackpad deltas, and everything with reduced motion, go at once (0). See ADR 045. - */ - tickSpread(e: WheelEvent): WheelPace { - const { duration, omega, wheel } = this.classify(e); - // Pacing a sparse stream times input, it does not animate: kept with reduced motion. - if (wheel && reducedMotion()) return { duration: 0 }; - return omega ? { duration, omega } : { duration }; - } - - /** - * How a wheel event scrolls; call once per event, as it tracks the stream. - * - A wheel notch eases out over the wheel glide time, or, soon after one in the - * same direction, continues linearly over its gap plus a frame. - * - A sparse delta, 25 ms or more after the previous one, glides linearly over - * that gap plus a frame, so a stream scrolls at the speed it is sent at; the - * extra frame keeps the glide going when the next delta is a little late. A - * stream stays sparse until two short gaps in a row, so one delta that comes - * a little early does not snap or flush. While its deltas come every 60 ms or - * faster on average, it follows a spring instead, which evens out the speed. - * - Trackpad deltas, a frame apart: at once. - */ - private classify(e: WheelEvent): Motion { - const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; - this.lastWheel = e.timeStamp; - const dir = Math.sign(e.deltaY); - const sameDir = dir === this.lastDir; - this.lastDir = dir; - if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); - const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); - if (gap >= SPARSE_GAP_MS) { - // A stream's first gap is the pause before it; count it as the slowest gap. - const capped = Math.min(gap, WHEEL_GLIDE_MS); - this.averageGap = this.sparse ? 0.4 * capped + 0.6 * this.averageGap : WHEEL_GLIDE_MS; - this.sparse = true; - this.shortGaps = 0; - } else if (this.sparse && ++this.shortGaps >= 2) { - this.sparse = false; - } - // A fast stream stays on the spring until it pauses: leaving it on a short gap - // applied all the spring's pending distance at once (654 px in one frame), and - // leaving it as the gaps grew at the end of a push sped up, then stopped hard. - if (!wheel && this.springing && gap < WHEEL_GLIDE_MS && sameDir) { - return { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA, ease: linear, wheel }; - } - this.springing = false; - if (wheel && gap > 0 && gap <= STREAM_MS && sameDir) { - return { duration: Math.min(gap + FRAME_MS, STREAM_MS), ease: linear, wheel }; - } - if (wheel) return { duration: WHEEL_GLIDE_MS, ease: easeOutCubic, wheel }; - if (this.sparse && this.averageGap < FAST_STREAM_MS) { - this.springing = true; - return { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA, ease: linear, wheel }; - } - if (this.sparse) { - return { duration: Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), ease: linear, wheel }; - } - return { duration: 0, ease: linear, wheel }; - } - /** New output arrived. ghostty-web only snaps to the bottom when viewportY > 0, so clear a sub-line offset here. */ onOutput(): void { - if (this.term.viewportY === 0 && this.position !== 0) this.jump(0); + if (this.term.viewportY === 0 && this.glide.value !== 0) this.jump(0); } /** @@ -333,8 +108,7 @@ export class SmoothScroll { } private jump(line: number): void { - this.stopGlide(); - this.position = line; + this.glide.jump(line); this.apply(); } @@ -342,33 +116,19 @@ export class SmoothScroll { return Math.max(0, Math.min(this.term.getScrollbackLength(), position)); } - private glideTo(to: number, duration: number, ease: (t: number) => number): void { - // Start from the glide's latest frame, or a frame ago: starting at this event would - // give the next frame, whose timestamp precedes the event, nothing to move, and a - // stream of deltas would stall every other frame. - const start = this.lastFrame ?? performance.now() - FRAME_MS; - this.glide = { kind: 'ease', from: this.position, to, start, duration, ease }; - } - - private stopGlide(): void { - this.glide = null; - this.lastFrame = null; - this.velocity = 0; - } - private apply(): void { const renderer = this.term.renderer; const metrics = renderer?.getMetrics(); if (!renderer || !metrics) return; const { line, offset } = splitScroll( - this.position, + this.glide.value, metrics.height, window.devicePixelRatio || 1, ); // Changes ghostty-web's viewportY synchronously. Its render loop draws the rows that - // came into view and shows the offset on the next frame. - // Rounding the offset can land on the next line while the position is still just - // below it; that is our own move, not one to follow. + // came into view and shows the offset on the next frame. Rounding the offset can land + // on the next line while the position is still just below it; that is our own move, + // not one to follow. if (line !== this.term.viewportY) { this.applying = true; try { diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index daa7615..2845430 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -17,7 +17,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. -- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`). +- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. - `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. The official engine drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. @@ -25,4 +25,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder engine). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The official engine reports its real dirty state while scrolled. - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), a hyperlink hover change repaints the rows out of view too, and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. +- `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/glide.ts b/src/client/vendor/ghostty-web/glide.ts new file mode 100644 index 0000000..db33783 --- /dev/null +++ b/src/client/vendor/ghostty-web/glide.ts @@ -0,0 +1,157 @@ +/** + * A value that moves toward a target a frame at a time: the one motion behind + * scrolling's glides, for the scrollback position (smooth-scroll.ts) and for the + * scroll ticks paced to apps (wheel.ts). (webtty, ADR 045) + */ + +/** One frame at 60 Hz. */ +export const FRAME_MS = 1000 / 60; + +/** + * A spring's stiffness doubles once its target has not moved for this long: the + * input has stopped, so the value should stop soon rather than coast, while a + * running stream keeps the softer, steadier spring. + */ +export const SPRING_RELEASE_MS = 60; + +export type Ease = (t: number) => number; + +export const linear: Ease = (t) => t; + +export const easeOutCubic: Ease = (t) => 1 - (1 - t) ** 3; + +/** + * How a glide reaches a new target: over `duration` ms with `ease` (linear when + * absent), at once when `duration` is 0, or, with `omega`, following a critically + * damped spring of that stiffness (rad/s). + */ +export interface Pace { + duration: number; + ease?: Ease; + omega?: number; +} + +/** Reach the target at once. */ +export const AT_ONCE: Pace = { duration: 0 }; + +/** + * Advances a critically damped spring at `x` with velocity `v` (per second) + * toward a fixed `target` by `dt` seconds. Exact for a fixed target, so it is + * stable at any frame time, and it never overshoots a target it approaches from + * rest. Returns the new position and velocity. + */ +export function springStep( + x: number, + v: number, + target: number, + omega: number, + dt: number +): [number, number] { + const e = x - target; + const k = Math.exp(-omega * dt); + const c = v + omega * e; + return [target + (e + c * dt) * k, (v - omega * c * dt) * k]; +} + +/** The stiffness of a spring with stiffness `omega` `idle` ms after its target last moved. */ +export function springOmega(omega: number, idle: number): number { + return idle > SPRING_RELEASE_MS ? omega * 2 : omega; +} + +/** + * A value gliding toward a target. `retarget` sets where it is heading and how; + * `step`, called once per frame with the frame's timestamp, moves it. + * + * - A timed glide starts from the glide's latest frame, or a frame before the + * call when none is running: a frame's timestamp precedes the events handled in + * it, so a glide starting at the event would not move on its next frame, and a + * stream of retargets would stall every other frame. + * - A spring takes over at the current velocity, so the speed does not jump. + * - The velocity is kept across retargets for that reason. + */ +export class Glide { + /** Where the value is, and where it is heading. */ + value = 0; + target = 0; + /** Per second. */ + private velocity = 0; + private from = 0; + private start = 0; + private duration = 0; + private ease: Ease = linear; + /** The spring's stiffness while following one, else 0. */ + private omega = 0; + private lastFrame: number | null = null; + private lastRetarget = 0; + private moving = false; + + /** Whether the value is still moving toward the target. */ + get active(): boolean { + return this.moving; + } + + /** Heads for `target` as `pace` says; at once when its duration is 0. `now` is `performance.now()`. */ + retarget(target: number, pace: Pace, now: number): void { + this.lastRetarget = now; + if (pace.omega) { + if (!this.moving) this.lastFrame = now - FRAME_MS; + this.omega = pace.omega; + this.target = target; + this.moving = true; + } else if (pace.duration <= 0) { + this.jump(target); + } else { + this.omega = 0; + this.from = this.value; + this.target = target; + this.start = this.lastFrame ?? now - FRAME_MS; + this.duration = pace.duration; + this.ease = pace.ease ?? linear; + this.moving = true; + } + } + + /** Puts the value at `value` and stops. */ + jump(value: number): void { + this.value = value; + this.target = value; + this.stop(); + } + + /** Stops where the value is. */ + stop(): void { + this.moving = false; + this.omega = 0; + this.velocity = 0; + this.lastFrame = null; + } + + /** + * Moves the value for the frame at `now`. A spring has arrived once within + * `settle` of the target and moving less than that per frame. + */ + step(now: number, settle: number): void { + if (!this.moving) return; + const before = this.value; + const dt = Math.min(Math.max(now - (this.lastFrame ?? now - FRAME_MS), 0), 50) / 1000; + let done: boolean; + if (this.omega) { + const omega = springOmega(this.omega, now - this.lastRetarget); + const [x, v] = springStep(this.value, this.velocity, this.target, omega, dt); + this.velocity = v; + done = Math.abs(this.target - x) < settle && Math.abs(v) * (FRAME_MS / 1000) < settle; + this.value = x; + } else { + // A frame's timestamp can precede the glide's start by part of a frame. + const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); + this.value = this.from + (this.target - this.from) * this.ease(t); + if (dt > 0) this.velocity = (this.value - before) / dt; + done = t >= 1; + } + if (done) { + this.jump(this.target); + } else { + this.lastFrame = now; + } + } +} diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 1d9e53b..dced290 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -20,7 +20,6 @@ export interface ITerminalOptions { disableStdin?: boolean; // Disable keyboard input (default: false) // Scrolling options - smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant) mouseScrollSpeed?: number; // Wheel ticks per line/page wheel event when the app tracks the mouse (default: 1) // Internal: Ghostty WASM instance (optional, for test isolation) diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index c0bdb0c..4bbbc4d 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -19,6 +19,7 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; import { getGhostty } from './index'; +import { AT_ONCE, type Pace } from './glide'; import { InputHandler, type MouseTrackingConfig } from './input-handler'; import type { IBufferNamespace, @@ -37,6 +38,14 @@ import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; import type { ILink, ILinkProvider } from './types'; +import { + TickPacer, + type WheelAccumulator, + wheelArrowKey, + wheelArrows, + wheelSgr, + wheelTicks, +} from './wheel'; // ============================================================================ // Terminal Class @@ -103,221 +112,6 @@ export function deviceCellPixels(cssCell: number, dpr: number): number { return Math.max(1, Math.round(cssCell * dpr)); } -/** Wheel travel not yet sent to a mouse-tracking app, and its direction. */ -export interface WheelAccumulator { - accum: number; - dir: number; -} - -/** - * Scroll ticks a wheel event sends to an app that tracks the mouse: negative - * scrolls up (SGR button 64), positive down (65). Pixel deltas (trackpads, and - * mouse wheels in Chromium/Safari on macOS) count by distance, one tick per cell - * height, 1:1 with the OS, and `speed` does not apply (Ghostty skips its scroll - * multiplier for precision scrolls too). Line and page deltas count one tick per - * event times `speed`; below 1 the remainder accumulates. A direction change - * drops the remainder. (webtty: upstream sent arrow keys on the alternate screen - * and one SGR per event elsewhere, so trackpads scrolled far ahead of the fingers.) - */ -export function wheelTicks( - acc: WheelAccumulator, - e: Pick, - cellHeight: number, - speed: number -): number { - const dir = e.deltaY < 0 ? -1 : 1; - if (dir !== acc.dir) { - acc.accum = 0; - acc.dir = dir; - } - // 0 is WheelEvent.DOM_DELTA_PIXEL. - acc.accum += e.deltaMode === 0 ? Math.abs(e.deltaY) / cellHeight : speed; - const ticks = Math.trunc(acc.accum); - acc.accum -= ticks; - return ticks === 0 ? 0 : ticks * dir; -} - -/** - * Arrow keys a wheel event sends on the alternate screen when the app does not - * track the mouse: one per line of travel, like scrolling the normal screen's - * scrollback. Pixel deltas count by cell height, line deltas by line, page deltas - * by `rows`; the remainder accumulates and a direction change drops it. Negative - * is up. (webtty: upstream sent round(deltaY / 33) per event, at most 5, so slow - * trackpad deltas and Firefox's 3-line notches sent nothing; ADR 045) - */ -export function wheelArrows( - acc: WheelAccumulator, - e: Pick, - cellHeight: number, - rows: number -): number { - const dir = e.deltaY < 0 ? -1 : 1; - if (dir !== acc.dir) { - acc.accum = 0; - acc.dir = dir; - } - const amount = Math.abs(e.deltaY); - // 0 is WheelEvent.DOM_DELTA_PIXEL, 1 DOM_DELTA_LINE. - acc.accum += e.deltaMode === 0 ? amount / cellHeight : e.deltaMode === 1 ? amount : amount * rows; - const lines = Math.trunc(acc.accum); - acc.accum -= lines; - return lines === 0 ? 0 : lines * dir; -} - -/** One frame at 60 Hz. */ -const FRAME_MS = 1000 / 60; - -/** - * How to send a wheel event's ticks: spread evenly over `duration` ms (0: at - * once), or, with `omega`, following a critically damped spring of that stiffness - * (rad/s) toward the queued total. (webtty, ADR 045) - */ -export interface WheelPace { - duration: number; - omega?: number; -} - -/** - * A spring's stiffness doubles once no wheel event has come for this long: the - * stream has stopped, so the view should stop soon rather than coast for many - * lines, while a running stream keeps the softer, steadier spring. (webtty, ADR 045) - */ -export const SPRING_RELEASE_MS = 60; - -/** The stiffness of a spring with stiffness `omega` `sinceEvent` ms after the latest wheel event. */ -export function springOmega(omega: number, sinceEvent: number): number { - return sinceEvent > SPRING_RELEASE_MS ? omega * 2 : omega; -} - -/** - * Advances a critically damped spring at `x` with velocity `v` (per second) - * toward a fixed `target` by `dt` seconds. Exact for a fixed target, so it is - * stable at any frame time, and it never overshoots a target it approaches from - * rest. Returns the new position and velocity. (webtty, ADR 045) - */ -export function springStep( - x: number, - v: number, - target: number, - omega: number, - dt: number -): [number, number] { - const e = x - target; - const k = Math.exp(-omega * dt); - const c = v + omega * e; - return [target + (e + c * dt) * k, (v - omega * c * dt) * k]; -} - -/** - * Sends the scroll ticks of wheel events to an app spread over time instead of in - * one burst: a TrackPoint sends a delta of up to 20 lines every 30-200 ms, which - * an app would otherwise scroll in jumps. Ticks queued with a duration go out - * evenly over it, a few per frame; a new event's ticks join the ones still queued - * and spread over its duration, starting from the latest frame so the pace does - * not stall. A direction change drops the queued ticks, as `wheelTicks` drops its - * remainder. (webtty, ADR 045) - */ -export class TickPacer { - private seq = ''; - private dir = 0; - private total = 0; - private sent = 0; - private start = 0; - private duration = 0; - /** Spring stiffness while following a fast stream, else 0; and its position and velocity in ticks. */ - private omega = 0; - private x = 0; - private v = 0; - private lastPush = 0; - private lastFrame: number | null = null; - private frame = 0; - - constructor(private readonly send: (data: string) => void) {} - - /** Queues `count` ticks of `seq` in direction `dir` (-1 or 1) to send as `pace` says; a 0 duration sends them now. */ - push(seq: string, dir: number, count: number, pace: WheelPace): void { - if (dir !== this.dir) this.cancel(); - this.dir = dir; - // The latest event's sequence: its cell is where the pointer is now. - this.seq = seq; - this.lastPush = performance.now(); - const pending = this.total - this.sent; - if (pace.omega) { - if (!this.omega) { - // Take over at the linear pace's speed, so the rate does not jump. - const linear = this.frame && this.duration > 0 ? (this.total / this.duration) * 1000 : 0; - this.total = pending; - this.sent = 0; - this.x = 0; - this.v = linear; - } - this.omega = pace.omega; - this.total += count; - } else if (pace.duration <= 0) { - this.stop(); - this.send(seq.repeat(pending + count)); - return; - } else { - this.omega = 0; - this.total = pending + count; - this.sent = 0; - this.start = this.lastFrame ?? performance.now() - FRAME_MS; - this.duration = pace.duration; - } - if (!this.frame) this.frame = requestAnimationFrame(this.step); - } - - /** Drops the queued ticks. */ - cancel(): void { - this.stop(); - this.dir = 0; - } - - private stop(): void { - this.total = 0; - this.sent = 0; - this.omega = 0; - this.x = 0; - this.v = 0; - this.lastFrame = null; - if (this.frame) cancelAnimationFrame(this.frame); - this.frame = 0; - } - - private readonly step = (now: number): void => { - this.frame = 0; - let progress: number; - if (this.omega) { - const dt = Math.min(now - (this.lastFrame ?? now - FRAME_MS), 50) / 1000; - const omega = springOmega(this.omega, now - this.lastPush); - [this.x, this.v] = springStep(this.x, this.v, this.total, omega, dt); - progress = Math.min(this.total, this.x); - } else { - const t = Math.min(1, Math.max(0, (now - this.start) / this.duration)); - progress = this.total * t; - } - // Rounded up, so the first tick goes out on the first frame: a 1-tick notch - // is not held back for half the duration. - const due = Math.min(this.total, Math.ceil(progress - 1e-9)) - this.sent; - if (due > 0) { - this.send(this.seq.repeat(due)); - this.sent += due; - } - if (this.sent < this.total) { - this.lastFrame = now; - this.frame = requestAnimationFrame(this.step); - } else { - this.stop(); - } - }; -} - -/** SGR mouse report for `ticks` wheel ticks at a 1-based cell. */ -export function wheelSgr(ticks: number, col: number, row: number): string { - const btn = ticks < 0 ? 64 : 65; - return `\x1b[<${btn};${col};${row}M`.repeat(Math.abs(ticks)); -} - /** * The cursor cell's top-left corner, in CSS pixels relative to the container's * padding box. `cursor.y` is relative to the bottom screen; when scrolled back by @@ -423,15 +217,11 @@ export class Terminal implements ITerminalCore { private currentTitle: string = ''; // Phase 2: Viewport and scrolling state - public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll) - private targetViewportY: number = 0; // Target viewport position for smooth scrolling - private scrollAnimationStartTime?: number; - private scrollAnimationStartY?: number; - private scrollAnimationFrame?: number; + public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) private customWheelEventHandler?: (event: WheelEvent) => boolean; private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private arrowAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; - private customWheelTickSpread?: (event: WheelEvent) => WheelPace; + private wheelPace?: (event: WheelEvent) => Pace; private beforeRender?: (now: number) => void; private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); private lastCursorY: number = 0; // Track cursor position for onCursorMove @@ -470,7 +260,6 @@ export class Terminal implements ITerminalCore { allowTransparency: options.allowTransparency ?? false, convertEol: options.convertEol ?? false, disableStdin: options.disableStdin ?? false, - smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll mouseScrollSpeed: options.mouseScrollSpeed ?? 1, }; @@ -1284,11 +1073,11 @@ export class Terminal implements ITerminalCore { /** * Sets how to pace the scroll ticks of a wheel event sent to the app (mouse - * tracking, or arrow keys on the alternate screen); a 0 duration sends them at - * once, as without a handler. Called once per such event. (webtty, ADR 045) + * tracking, or arrow keys on the alternate screen); without it they go at once. + * Called once for each such event. (webtty, ADR 045) */ - public attachWheelTickSpread(spread?: (event: WheelEvent) => WheelPace): void { - this.customWheelTickSpread = spread; + public attachWheelPace(pace?: (event: WheelEvent) => Pace): void { + this.wheelPace = pace; } /** @@ -1412,101 +1201,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Smoothly scroll to a target viewport position - * @param targetY Target viewport Y position (in lines, can be fractional) - */ - private smoothScrollTo(targetY: number): void { - if (!this.wasmTerm) return; - - const scrollbackLength = this.getScrollbackLength(); - const maxScroll = scrollbackLength; - - // Clamp target to valid range - const newTarget = Math.max(0, Math.min(maxScroll, targetY)); - - // If smooth scrolling is disabled (duration = 0), jump immediately - const duration = this.options.smoothScrollDuration ?? 100; - if (duration === 0) { - this.viewportY = newTarget; - this.targetViewportY = newTarget; - this.scrollEmitter.fire(Math.floor(this.viewportY)); - - if (scrollbackLength > 0) { - this.showScrollbar(); - } - return; - } - - // Update target (accumulate if animation running) - this.targetViewportY = newTarget; - - // If animation is already running, don't restart it - // Just let it continue toward the updated target - // This prevents choppy restarts during continuous scrolling - if (this.scrollAnimationFrame) { - return; - } - - // Start new animation - this.scrollAnimationStartTime = Date.now(); - this.scrollAnimationStartY = this.viewportY; - this.animateScroll(); - } - - /** - * Animation loop for smooth scrolling - * Uses asymptotic approach - moves a fraction of remaining distance each frame - */ - private animateScroll = (): void => { - if (!this.wasmTerm || this.scrollAnimationStartTime === undefined) { - return; - } - - const duration = this.options.smoothScrollDuration ?? 100; - - // Calculate distance to target - const distance = this.targetViewportY - this.viewportY; - const absDistance = Math.abs(distance); - - // If very close, snap to target - if (absDistance < 0.01) { - this.viewportY = this.targetViewportY; - this.scrollEmitter.fire(Math.floor(this.viewportY)); - - const scrollbackLength = this.getScrollbackLength(); - if (scrollbackLength > 0) { - this.showScrollbar(); - } - - // Animation complete - this.scrollAnimationFrame = undefined; - this.scrollAnimationStartTime = undefined; - this.scrollAnimationStartY = undefined; - return; - } - - // Move a fraction of the remaining distance - // At 60fps, move ~1/6 of distance per frame for ~100ms total duration - // This creates smooth deceleration toward target - const framesForDuration = (duration / 1000) * 60; // Convert ms to frame count - const moveRatio = 1 - (1 / framesForDuration) ** 2; // Ease-out - this.viewportY += distance * moveRatio; - - // Fire scroll event (use floor to convert fractional to integer for API) - const intViewportY = Math.floor(this.viewportY); - this.scrollEmitter.fire(intViewportY); - - // Show scrollbar during animation - const scrollbackLength = this.getScrollbackLength(); - if (scrollbackLength > 0) { - this.showScrollbar(); - } - - // Continue animation - this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll); - }; - // ========================================================================== // Lifecycle // ========================================================================== @@ -1528,12 +1222,6 @@ export class Terminal implements ITerminalCore { this.tickPacer.cancel(); - // Stop smooth scroll animation - if (this.scrollAnimationFrame) { - cancelAnimationFrame(this.scrollAnimationFrame); - this.scrollAnimationFrame = undefined; - } - // Clear mouse move throttle timeout if (this.mouseMoveThrottleTimeout) { clearTimeout(this.mouseMoveThrottleTimeout); @@ -2065,8 +1753,7 @@ export class Terminal implements ITerminalCore { if (this.wasmTerm?.hasMouseTracking()) { const metrics = this.renderer?.getMetrics(); if (!metrics || !this.canvas) return; - // Every event, so the spread sees the time since the previous one (webtty, ADR 045) - const spread = this.customWheelTickSpread?.(e) ?? { duration: 0 }; + const pace = this.wheelPace?.(e) ?? AT_ONCE; const ticks = wheelTicks( this.wheelAccumulator, e, @@ -2080,12 +1767,7 @@ export class Terminal implements ITerminalCore { 1, Math.floor((e.clientY - rect.top - this.renderer!.scrollOffset) / metrics.height) + 1 ); - this.tickPacer.push( - wheelSgr(Math.sign(ticks), col, row), - Math.sign(ticks), - Math.abs(ticks), - spread - ); + this.tickPacer.push(wheelSgr(Math.sign(ticks), col, row), Math.sign(ticks), Math.abs(ticks), pace); return; } @@ -2095,50 +1777,21 @@ export class Terminal implements ITerminalCore { if (isAltScreen) { // Alternate screen: send arrow keys to the application, one per line of // travel, paced like the SGR ticks above (webtty, ADR 045) - const spread = this.customWheelTickSpread?.(e) ?? { duration: 0 }; + const pace = this.wheelPace?.(e) ?? AT_ONCE; const lines = wheelArrows( this.arrowAccumulator, e, this.renderer?.getMetrics()?.height ?? 20, this.rows ); - const dir = Math.sign(lines); if (lines !== 0) { - // Application cursor keys (DECCKM), as less sets: ESC O A/B, else ESC [ A/B - const app = this.wasmTerm?.getMode(1, false) ?? false; - const seq = (app ? '\x1BO' : '\x1B[') + (dir < 0 ? 'A' : 'B'); - this.tickPacer.push(seq, dir, Math.abs(lines), spread); - } - } else { - // Normal screen: scroll viewport through history with smooth scrolling - // Handle different deltaMode values for better trackpad/mouse support - let deltaLines: number; - - if (e.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { - // Pixel mode (trackpads): convert pixels to lines - // Use actual line height from renderer for accurate conversion - const lineHeight = this.renderer?.getMetrics()?.height ?? 20; - deltaLines = e.deltaY / lineHeight; - } else if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { - // Line mode (some mice): use directly - deltaLines = e.deltaY; - } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - // Page mode (rare): convert pages to lines - deltaLines = e.deltaY * this.rows; - } else { - // Fallback: assume pixel mode with legacy divisor - deltaLines = e.deltaY / 33; - } - - // Use smooth scrolling for any amount (no rounding needed) - if (deltaLines !== 0) { - // Calculate target position - // deltaY > 0 = scroll down (decrease viewportY) - // deltaY < 0 = scroll up (increase viewportY) - const targetY = this.viewportY - deltaLines; - this.smoothScrollTo(targetY); + const dir = Math.sign(lines); + const key = wheelArrowKey(dir, this.wasmTerm?.getMode(1, false) ?? false); + this.tickPacer.push(key, dir, Math.abs(lines), pace); } } + // The normal screen's scrollback is scrolled by the custom handler (webtty's + // SmoothScroll, ADR 040, ADR 045); upstream's easing here is removed. }; /** diff --git a/src/client/vendor/ghostty-web/wheel.ts b/src/client/vendor/ghostty-web/wheel.ts new file mode 100644 index 0000000..8b2fc9b --- /dev/null +++ b/src/client/vendor/ghostty-web/wheel.ts @@ -0,0 +1,149 @@ +/** + * Wheel input for apps that scroll themselves: what a wheel event sends when the + * app tracks the mouse (SGR scroll buttons) or is on the alternate screen without + * mouse tracking (arrow keys), and the pacing that spreads it over time. The + * terminal's `handleWheel` routes events here. (webtty, ADR 017, ADR 040, ADR 045) + */ +import { Glide, linear, type Pace } from './glide'; + +/** Wheel travel not yet sent to an app, and its direction. */ +export interface WheelAccumulator { + accum: number; + dir: number; +} + +/** Adds `amount` of travel in `deltaY`'s direction; returns the whole units, negative for up. */ +function accumulate(acc: WheelAccumulator, deltaY: number, amount: number): number { + const dir = deltaY < 0 ? -1 : 1; + // A direction change drops the remainder. + if (dir !== acc.dir) { + acc.accum = 0; + acc.dir = dir; + } + acc.accum += amount; + const whole = Math.trunc(acc.accum); + acc.accum -= whole; + return whole === 0 ? 0 : whole * dir; +} + +/** + * Scroll ticks a wheel event sends to an app that tracks the mouse: negative + * scrolls up (SGR button 64), positive down (65). Pixel deltas (trackpads, and + * mouse wheels in Chromium/Safari on macOS) count by distance, one tick per cell + * height, 1:1 with the OS, and `speed` does not apply (Ghostty skips its scroll + * multiplier for precision scrolls too). Line and page deltas count one tick per + * event times `speed`; below 1 the remainder accumulates. (webtty: upstream sent + * arrow keys on the alternate screen and one SGR per event elsewhere, so trackpads + * scrolled far ahead of the fingers.) + */ +export function wheelTicks( + acc: WheelAccumulator, + e: Pick, + cellHeight: number, + speed: number +): number { + // 0 is WheelEvent.DOM_DELTA_PIXEL. + return accumulate(acc, e.deltaY, e.deltaMode === 0 ? Math.abs(e.deltaY) / cellHeight : speed); +} + +/** + * Arrow keys a wheel event sends on the alternate screen when the app does not + * track the mouse: one per line of travel, like scrolling the normal screen's + * scrollback. Pixel deltas count by cell height, line deltas by line, page deltas + * by `rows`; the remainder accumulates. Negative is up. (webtty: upstream sent + * round(deltaY / 33) per event, at most 5, so slow trackpad deltas and Firefox's + * 3-line notches sent nothing.) + */ +export function wheelArrows( + acc: WheelAccumulator, + e: Pick, + cellHeight: number, + rows: number +): number { + const amount = Math.abs(e.deltaY); + // 0 is WheelEvent.DOM_DELTA_PIXEL, 1 DOM_DELTA_LINE. + const lines = e.deltaMode === 0 ? amount / cellHeight : e.deltaMode === 1 ? amount : amount * rows; + return accumulate(acc, e.deltaY, lines); +} + +/** SGR mouse report for `ticks` wheel ticks at a 1-based cell. */ +export function wheelSgr(ticks: number, col: number, row: number): string { + const btn = ticks < 0 ? 64 : 65; + return `\x1b[<${btn};${col};${row}M`.repeat(Math.abs(ticks)); +} + +/** + * One up (`dir` < 0) or down arrow key. With application cursor keys (DECCKM), + * which less sets, it is ESC O A/B; less ignores ESC [ A/B then. + */ +export function wheelArrowKey(dir: number, applicationCursor: boolean): string { + return (applicationCursor ? '\x1bO' : '\x1b[') + (dir < 0 ? 'A' : 'B'); +} + +/** + * Sends the scroll ticks of wheel events to an app spread over time instead of + * in one burst: a TrackPoint sends a delta of up to 20 lines every 30-200 ms, and + * a wheel notch several lines at once, which an app would otherwise scroll in + * jumps. The queued ticks follow a `Glide` as each event's `Pace` says, but + * evenly, a few per frame; a new event's ticks join the ones still queued. The count is rounded up, + * so the first tick goes out on the next frame and a 1-tick notch is not held + * back. A direction change drops the queued ticks, as `wheelTicks` drops its + * remainder. + */ +export class TickPacer { + /** Progress through the queued ticks: `glide.value` ticks are due, `sent` went out. */ + private readonly glide = new Glide(); + private sent = 0; + private seq = ''; + private dir = 0; + private frame = 0; + + constructor(private readonly send: (data: string) => void) {} + + /** Queues `count` ticks of `seq` in direction `dir` (-1 or 1) to send as `pace` says. */ + push(seq: string, dir: number, count: number, pace: Pace): void { + if (dir !== this.dir) this.cancel(); + this.dir = dir; + // The latest event's sequence: its cell is where the pointer is now. + this.seq = seq; + const target = this.glide.target + count; + if (pace.duration <= 0 && !pace.omega) { + this.send(seq.repeat(target - this.sent)); + this.reset(); + return; + } + // Evenly, whatever the pace's easing: an app moves in whole lines, so easing + // would only bunch the ticks. + this.glide.retarget(target, { ...pace, ease: linear }, performance.now()); + if (!this.frame) this.frame = requestAnimationFrame(this.step); + } + + /** Drops the queued ticks. */ + cancel(): void { + this.reset(); + this.dir = 0; + } + + private reset(): void { + this.glide.jump(0); + this.sent = 0; + if (this.frame) cancelAnimationFrame(this.frame); + this.frame = 0; + } + + private readonly step = (now: number): void => { + this.frame = 0; + this.glide.step(now, 1e-3); + const target = this.glide.target; + const due = Math.min(target, Math.ceil(this.glide.value - 1e-9)) - this.sent; + if (due > 0) { + this.send(this.seq.repeat(due)); + this.sent += due; + } + if (this.sent < target) { + this.frame = requestAnimationFrame(this.step); + } else { + this.reset(); + } + }; +} diff --git a/src/client/vendor/glide.test.ts b/src/client/vendor/glide.test.ts new file mode 100644 index 0000000..f112e0b --- /dev/null +++ b/src/client/vendor/glide.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { + AT_ONCE, + easeOutCubic, + FRAME_MS, + Glide, + SPRING_RELEASE_MS, + springOmega, + springStep, +} from './ghostty-web/glide'; + +describe('springStep', () => { + test('approaches a target from rest without overshooting', () => { + let [x, v] = [0, 0]; + for (let i = 0; i < 60; i++) { + [x, v] = springStep(x, v, 10, 28, FRAME_MS / 1000); + expect(x).toBeLessThanOrEqual(10); + } + expect(x).toBeCloseTo(10, 3); + }); + + test('is the same in one step as in many', () => { + const one = springStep(0, 5, 10, 28, 0.1); + let [x, v] = [0, 5]; + for (let i = 0; i < 10; i++) [x, v] = springStep(x, v, 10, 28, 0.01); + expect(x).toBeCloseTo(one[0], 9); + expect(v).toBeCloseTo(one[1], 9); + }); +}); + +describe('springOmega', () => { + test('doubles once the target has not moved for the release time', () => { + expect(springOmega(28, SPRING_RELEASE_MS)).toBe(28); + expect(springOmega(28, SPRING_RELEASE_MS + 1)).toBe(56); + }); +}); + +describe('Glide', () => { + test('at once puts the value on the target', () => { + const g = new Glide(); + g.retarget(4, AT_ONCE, 0); + expect([g.value, g.target, g.active]).toEqual([4, 4, false]); + }); + + test('a timed glide starts a frame early, so its first frame moves', () => { + const g = new Glide(); + g.retarget(4, { duration: 100 }, 1000); + g.step(1000, 1e-3); + expect(g.value).toBeCloseTo(4 * (FRAME_MS / 100)); + }); + + test('a timed glide ends on its target', () => { + const g = new Glide(); + g.retarget(4, { duration: 100, ease: easeOutCubic }, 0); + for (let now = FRAME_MS; now < 200; now += FRAME_MS) g.step(now, 1e-3); + expect([g.value, g.active]).toEqual([4, false]); + }); + + test('a retarget during a glide continues from its latest frame', () => { + const g = new Glide(); + g.retarget(4, { duration: 100 }, 0); + g.step(FRAME_MS, 1e-3); + const at = g.value; + g.retarget(8, { duration: 100 }, FRAME_MS + 5); + // Measured from the latest frame, the next frame moves a full frame's share. + g.step(2 * FRAME_MS, 1e-3); + expect(g.value).toBeCloseTo(at + (8 - at) * (FRAME_MS / 100)); + }); + + test('a spring takes over at the current speed', () => { + const g = new Glide(); + g.retarget(100, { duration: 100 }, 0); + g.step(FRAME_MS, 1e-3); + const before = g.value; + g.step(2 * FRAME_MS, 1e-3); + const linearStep = g.value - before; + g.retarget(200, { duration: 100, omega: 28 }, 2 * FRAME_MS); + const from = g.value; + g.step(3 * FRAME_MS, 1e-3); + // Not starting from rest: the first spring frame keeps most of the speed. + expect(g.value - from).toBeGreaterThan(linearStep * 0.8); + }); + + test('a spring settles on its target', () => { + const g = new Glide(); + g.retarget(10, { duration: 100, omega: 28 }, 0); + for (let now = FRAME_MS; now < 1000 && g.active; now += FRAME_MS) g.step(now, 0.01); + expect([g.value, g.active]).toEqual([10, false]); + }); + + test('jump stops where it puts the value', () => { + const g = new Glide(); + g.retarget(10, { duration: 100 }, 0); + g.jump(3); + expect([g.value, g.target, g.active]).toEqual([3, 3, false]); + }); +}); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 34fda35..4389a36 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -5,11 +5,6 @@ import { deviceCellPixels, mouseCursorFor, scanDecscusr, - TickPacer, - type WheelAccumulator, - wheelArrows, - wheelSgr, - wheelTicks, } from './ghostty-web/terminal'; import type { ILink } from './ghostty-web/types'; @@ -110,194 +105,6 @@ describe('scanDecscusr', () => { }); }); -describe('wheelTicks', () => { - const fresh = (): WheelAccumulator => ({ accum: 0, dir: 0 }); - const PIXEL = 0; - const LINE = 1; - const PAGE = 2; - - test('a line delta is one tick per event, down positive and up negative', () => { - const acc = fresh(); - expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 1)).toBe(1); - expect(wheelTicks(acc, { deltaY: -3, deltaMode: LINE }, 14, 1)).toBe(-1); - }); - - test('a page delta is one tick per event too', () => { - expect(wheelTicks(fresh(), { deltaY: 1, deltaMode: PAGE }, 14, 1)).toBe(1); - }); - - test('speed above 1 sends several ticks per line event', () => { - expect(wheelTicks(fresh(), { deltaY: 3, deltaMode: LINE }, 14, 3)).toBe(3); - }); - - test('speed below 1 accumulates across events', () => { - const acc = fresh(); - expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(0); - expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(1); - expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(0); - }); - - test('pixel deltas count one tick per cell height, ignoring speed', () => { - const acc = fresh(); - expect(wheelTicks(acc, { deltaY: 7, deltaMode: PIXEL }, 14, 3)).toBe(0); - expect(wheelTicks(acc, { deltaY: 7, deltaMode: PIXEL }, 14, 3)).toBe(1); - expect(wheelTicks(acc, { deltaY: -42, deltaMode: PIXEL }, 14, 3)).toBe(-3); - }); - - test('a direction change drops the remainder', () => { - const acc = fresh(); - expect(wheelTicks(acc, { deltaY: 10, deltaMode: PIXEL }, 14, 1)).toBe(0); - expect(wheelTicks(acc, { deltaY: -10, deltaMode: PIXEL }, 14, 1)).toBe(0); - expect(wheelTicks(acc, { deltaY: -4, deltaMode: PIXEL }, 14, 1)).toBe(-1); - expect(wheelTicks(acc, { deltaY: 10, deltaMode: PIXEL }, 14, 1)).toBe(0); - }); -}); - -describe('wheelSgr', () => { - test('scroll up is button 64 at the cell', () => { - expect(wheelSgr(-1, 5, 3)).toBe('\x1b[<64;5;3M'); - }); - - test('scroll down is button 65, one report per tick', () => { - expect(wheelSgr(2, 1, 1)).toBe('\x1b[<65;1;1M\x1b[<65;1;1M'); - }); -}); - -describe('wheelArrows', () => { - const acc = (): WheelAccumulator => ({ accum: 0, dir: 0 }); - - test('slow trackpad deltas add up to one arrow per line', () => { - const a = acc(); - const sent = [3, 3, 3, 3, 3, 3].map((d) => wheelArrows(a, { deltaY: d, deltaMode: 0 }, 17, 40)); - expect(sent).toEqual([0, 0, 0, 0, 0, 1]); - }); - - test('a Firefox notch of 3 lines sends 3 arrows', () => { - expect(wheelArrows(acc(), { deltaY: -3, deltaMode: 1 }, 17, 40)).toBe(-3); - }); - - test('a large delta is not capped', () => { - expect(wheelArrows(acc(), { deltaY: 340, deltaMode: 0 }, 17, 40)).toBe(20); - }); - - test('page deltas scale by rows, and a direction change drops the remainder', () => { - const a = acc(); - expect(wheelArrows(a, { deltaY: 1, deltaMode: 2 }, 17, 40)).toBe(40); - expect(wheelArrows(a, { deltaY: 10, deltaMode: 0 }, 17, 40)).toBe(0); - expect(wheelArrows(a, { deltaY: -10, deltaMode: 0 }, 17, 40)).toBe(0); - }); -}); - -describe('TickPacer', () => { - function frames(run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void) { - const g = globalThis as { - requestAnimationFrame?: unknown; - cancelAnimationFrame?: unknown; - performance?: unknown; - }; - const saved = [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance]; - let now = 0; - let pending: ((t: number) => void) | null = null; - g.requestAnimationFrame = (cb: (t: number) => void) => { - pending = cb; - return 1; - }; - g.cancelAnimationFrame = () => { - pending = null; - }; - g.performance = { now: () => now }; - const sent: string[] = []; - try { - run(new TickPacer((d) => sent.push(d)), sent, (ms) => { - now += ms; - const cb = pending; - pending = null; - cb?.(now); - }); - } finally { - [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance] = saved; - } - } - - test('the first tick goes out on the first frame', () => { - frames((pacer, sent, tick) => { - pacer.push('A', -1, 1, { duration: 125 }); - tick(1000 / 60); - expect(sent).toEqual(['A']); - }); - }); - - test('with a spring, ticks follow it and all arrive', () => { - frames((pacer, sent, tick) => { - pacer.push('A', -1, 20, { duration: 125, omega: 28 }); - const perFrame: number[] = []; - for (let i = 0; i < 30; i++) { - const before = sent.join('').length; - tick(1000 / 60); - perFrame.push(sent.join('').length - before); - } - expect(sent.join('').length).toBe(20); - // Spread over several frames, not sent at once. - expect(Math.max(...perFrame)).toBeLessThan(10); - }); - }); - - test('a zero duration sends the ticks at once', () => { - frames((pacer, sent) => { - pacer.push('A', -1, 3, { duration: 0 }); - expect(sent).toEqual(['AAA']); - }); - }); - - test('spreads ticks evenly over the duration, a few per frame', () => { - frames((pacer, sent, tick) => { - // 12 ticks over 50 ms, starting a frame early: 4 per 16.7 ms frame, so the - // first frame sends 8 and the next the last 4. - pacer.push('A', -1, 12, { duration: 50 }); - expect(sent).toEqual([]); - tick(1000 / 60); - expect(sent).toEqual(['A'.repeat(8)]); - tick(1000 / 60); - expect(sent).toEqual(['A'.repeat(8), 'A'.repeat(4)]); - }); - }); - - test('a new event joins the ticks still queued', () => { - frames((pacer, sent, tick) => { - pacer.push('A', -1, 6, { duration: 50 }); - tick(1000 / 60); - pacer.push('B', -1, 6, { duration: 50 }); - for (let i = 0; i < 6; i++) tick(1000 / 60); - const all = sent.join(''); - expect(all.length).toBe(12); - // Queued ticks go out with the latest event's sequence. - expect(all.endsWith('B')).toBe(true); - }); - }); - - test('a zero-duration event flushes the queued ticks first', () => { - frames((pacer, sent) => { - pacer.push('A', 1, 6, { duration: 50 }); - pacer.push('B', 1, 2, { duration: 0 }); - expect(sent).toEqual(['BBBBBBBB']); - }); - }); - - test('a direction change drops the queued ticks', () => { - frames((pacer, sent, tick) => { - pacer.push('A', -1, 10, { duration: 100 }); - tick(1000 / 60); - const before = sent.join('').length; - pacer.push('B', 1, 1, { duration: 0 }); - expect(sent.join('').length).toBe(before + 1); - expect(sent.at(-1)).toBe('B'); - tick(1000 / 60); - tick(100); - expect(sent.at(-1)).toBe('B'); - }); - }); -}); - describe('cursorCellOffset', () => { const grid = { cols: 80, rows: 24 }; const cell = { width: 9, height: 18 }; diff --git a/src/client/vendor/wheel.test.ts b/src/client/vendor/wheel.test.ts new file mode 100644 index 0000000..04a4f8d --- /dev/null +++ b/src/client/vendor/wheel.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from 'bun:test'; +import { + TickPacer, + type WheelAccumulator, + wheelArrowKey, + wheelArrows, + wheelSgr, + wheelTicks, +} from './ghostty-web/wheel'; + +describe('wheelTicks', () => { + const fresh = (): WheelAccumulator => ({ accum: 0, dir: 0 }); + const PIXEL = 0; + const LINE = 1; + const PAGE = 2; + + test('a line delta is one tick per event, down positive and up negative', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 1)).toBe(1); + expect(wheelTicks(acc, { deltaY: -3, deltaMode: LINE }, 14, 1)).toBe(-1); + }); + + test('a page delta is one tick per event too', () => { + expect(wheelTicks(fresh(), { deltaY: 1, deltaMode: PAGE }, 14, 1)).toBe(1); + }); + + test('speed above 1 sends several ticks per line event', () => { + expect(wheelTicks(fresh(), { deltaY: 3, deltaMode: LINE }, 14, 3)).toBe(3); + }); + + test('speed below 1 accumulates across events', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(0); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(1); + expect(wheelTicks(acc, { deltaY: 3, deltaMode: LINE }, 14, 0.5)).toBe(0); + }); + + test('pixel deltas count one tick per cell height, ignoring speed', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 7, deltaMode: PIXEL }, 14, 3)).toBe(0); + expect(wheelTicks(acc, { deltaY: 7, deltaMode: PIXEL }, 14, 3)).toBe(1); + expect(wheelTicks(acc, { deltaY: -42, deltaMode: PIXEL }, 14, 3)).toBe(-3); + }); + + test('a direction change drops the remainder', () => { + const acc = fresh(); + expect(wheelTicks(acc, { deltaY: 10, deltaMode: PIXEL }, 14, 1)).toBe(0); + expect(wheelTicks(acc, { deltaY: -10, deltaMode: PIXEL }, 14, 1)).toBe(0); + expect(wheelTicks(acc, { deltaY: -4, deltaMode: PIXEL }, 14, 1)).toBe(-1); + expect(wheelTicks(acc, { deltaY: 10, deltaMode: PIXEL }, 14, 1)).toBe(0); + }); +}); + +describe('wheelSgr', () => { + test('scroll up is button 64 at the cell', () => { + expect(wheelSgr(-1, 5, 3)).toBe('\x1b[<64;5;3M'); + }); + + test('scroll down is button 65, one report per tick', () => { + expect(wheelSgr(2, 1, 1)).toBe('\x1b[<65;1;1M\x1b[<65;1;1M'); + }); +}); + +describe('wheelArrows', () => { + const acc = (): WheelAccumulator => ({ accum: 0, dir: 0 }); + + test('slow trackpad deltas add up to one arrow per line', () => { + const a = acc(); + const sent = [3, 3, 3, 3, 3, 3].map((d) => wheelArrows(a, { deltaY: d, deltaMode: 0 }, 17, 40)); + expect(sent).toEqual([0, 0, 0, 0, 0, 1]); + }); + + test('a Firefox notch of 3 lines sends 3 arrows', () => { + expect(wheelArrows(acc(), { deltaY: -3, deltaMode: 1 }, 17, 40)).toBe(-3); + }); + + test('a large delta is not capped', () => { + expect(wheelArrows(acc(), { deltaY: 340, deltaMode: 0 }, 17, 40)).toBe(20); + }); + + test('page deltas scale by rows, and a direction change drops the remainder', () => { + const a = acc(); + expect(wheelArrows(a, { deltaY: 1, deltaMode: 2 }, 17, 40)).toBe(40); + expect(wheelArrows(a, { deltaY: 10, deltaMode: 0 }, 17, 40)).toBe(0); + expect(wheelArrows(a, { deltaY: -10, deltaMode: 0 }, 17, 40)).toBe(0); + }); +}); + +describe('TickPacer', () => { + function frames(run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void) { + const g = globalThis as { + requestAnimationFrame?: unknown; + cancelAnimationFrame?: unknown; + performance?: unknown; + }; + const saved = [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance]; + let now = 0; + let pending: ((t: number) => void) | null = null; + g.requestAnimationFrame = (cb: (t: number) => void) => { + pending = cb; + return 1; + }; + g.cancelAnimationFrame = () => { + pending = null; + }; + g.performance = { now: () => now }; + const sent: string[] = []; + try { + run(new TickPacer((d) => sent.push(d)), sent, (ms) => { + now += ms; + const cb = pending; + pending = null; + cb?.(now); + }); + } finally { + [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance] = saved; + } + } + + test('the first tick goes out on the first frame', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 1, { duration: 125 }); + tick(1000 / 60); + expect(sent).toEqual(['A']); + }); + }); + + test('with a spring, ticks follow it and all arrive', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 20, { duration: 125, omega: 28 }); + const perFrame: number[] = []; + for (let i = 0; i < 30; i++) { + const before = sent.join('').length; + tick(1000 / 60); + perFrame.push(sent.join('').length - before); + } + expect(sent.join('').length).toBe(20); + // Spread over several frames, not sent at once. + expect(Math.max(...perFrame)).toBeLessThan(10); + }); + }); + + test('a zero duration sends the ticks at once', () => { + frames((pacer, sent) => { + pacer.push('A', -1, 3, { duration: 0 }); + expect(sent).toEqual(['AAA']); + }); + }); + + test('spreads ticks evenly over the duration, a few per frame', () => { + frames((pacer, sent, tick) => { + // 12 ticks over 50 ms, starting a frame early: 4 per 16.7 ms frame, so the + // first frame sends 8 and the next the last 4. + pacer.push('A', -1, 12, { duration: 50 }); + expect(sent).toEqual([]); + tick(1000 / 60); + expect(sent).toEqual(['A'.repeat(8)]); + tick(1000 / 60); + expect(sent).toEqual(['A'.repeat(8), 'A'.repeat(4)]); + }); + }); + + test('a new event joins the ticks still queued', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 6, { duration: 50 }); + tick(1000 / 60); + pacer.push('B', -1, 6, { duration: 50 }); + for (let i = 0; i < 6; i++) tick(1000 / 60); + const all = sent.join(''); + expect(all.length).toBe(12); + // Queued ticks go out with the latest event's sequence. + expect(all.endsWith('B')).toBe(true); + }); + }); + + test('a zero-duration event flushes the queued ticks first', () => { + frames((pacer, sent) => { + pacer.push('A', 1, 6, { duration: 50 }); + pacer.push('B', 1, 2, { duration: 0 }); + expect(sent).toEqual(['BBBBBBBB']); + }); + }); + + test('a direction change drops the queued ticks', () => { + frames((pacer, sent, tick) => { + pacer.push('A', -1, 10, { duration: 100 }); + tick(1000 / 60); + const before = sent.join('').length; + pacer.push('B', 1, 1, { duration: 0 }); + expect(sent.join('').length).toBe(before + 1); + expect(sent.at(-1)).toBe('B'); + tick(1000 / 60); + tick(100); + expect(sent.at(-1)).toBe('B'); + }); + }); +}); + +describe('wheelArrowKey', () => { + test('normal cursor keys are ESC [ A/B', () => { + expect(wheelArrowKey(-1, false)).toBe('\x1b[A'); + expect(wheelArrowKey(1, false)).toBe('\x1b[B'); + }); + + test('application cursor keys (DECCKM), as less sets, are ESC O A/B', () => { + expect(wheelArrowKey(-1, true)).toBe('\x1bOA'); + expect(wheelArrowKey(1, true)).toBe('\x1bOB'); + }); +}); diff --git a/src/client/wheel-stream.test.ts b/src/client/wheel-stream.test.ts new file mode 100644 index 0000000..24a5d69 --- /dev/null +++ b/src/client/wheel-stream.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from 'bun:test'; +import { STREAM_OMEGA, WHEEL_GLIDE_MS, WheelClassifier, WheelStream } from './wheel-stream'; + +describe('WheelStream.forApp', () => { + const scroll = () => new WheelStream(); + const at = (deltaY: number, timeStamp: number, deltaMode = 0) => + ({ deltaX: 0, deltaY, deltaMode, timeStamp }) as WheelEvent; + + test('spreads a sparse delta over its gap plus a frame', () => { + const s = scroll(); + s.forApp(at(-4.000244140625, 1000)); + expect(s.forApp(at(-95.0897216796875, 1040)).duration).toBeCloseTo(40 + 1000 / 60); + }); + + test('caps the spread at the wheel glide time', () => { + const s = scroll(); + s.forApp(at(-4.000244140625, 1000)); + expect(s.forApp(at(-30.25, 1200)).duration).toBe(WHEEL_GLIDE_MS); + }); + + test('spreads a wheel notch over the wheel glide time', () => { + expect(scroll().forApp(at(-3, 1000, 1)).duration).toBe(WHEEL_GLIDE_MS); + }); + + test('a sparse stream stays paced through one short gap', () => { + const s = scroll(); + s.forApp(at(-23.5, 1000)); + expect(s.forApp(at(-63.25, 1040)).duration).toBeCloseTo(40 + 1000 / 60); + // The capture's 25 ms gap replays as 24.9 ms. + expect(s.forApp(at(-95.5, 1064.9)).duration).toBeCloseTo(24.9 + 1000 / 60); + expect(s.forApp(at(-120.75, 1100)).duration).toBeCloseTo(35.1 + 1000 / 60); + }); + + test('a wheel notch soon after one in the same direction continues linearly', () => { + const s = scroll(); + expect(s.forApp(at(-3, 1000, 1)).duration).toBe(WHEEL_GLIDE_MS); + expect(s.forApp(at(-3, 1150, 1)).duration).toBeCloseTo(150 + 1000 / 60); + // Reversing, or after a pause, a notch eases out again. + expect(s.forApp(at(3, 1200, 1)).duration).toBe(WHEEL_GLIDE_MS); + expect(s.forApp(at(3, 1500, 1)).duration).toBe(WHEEL_GLIDE_MS); + }); + + test('a fast sparse stream follows a spring', () => { + const s = scroll(); + let t = 1000; + s.forApp(at(-23.5, t)); + const paces = [40, 35, 30, 45, 33, 38, 31].map((gap, i) => { + t += gap; + return s.forApp(at(-(100.5 + i * 1.25), t)); + }); + // The pause before the stream counts as a slow gap, so it starts linear and + // turns into a spring once the average gap drops under 60 ms. + expect(paces[0].omega).toBeUndefined(); + expect(paces.at(-1)?.omega).toBe(STREAM_OMEGA); + }); + + test('sends trackpad deltas at once', () => { + const s = scroll(); + s.forApp(at(-20.5, 1200)); + s.forApp(at(-21.25, 1208)); + // Two short gaps in a row end the sparse stream a gesture starts as. + expect(s.forApp(at(-22.75, 1216)).duration).toBe(0); + }); +}); + +describe('WheelClassifier', () => { + const feed = (deltas: [number, number][]) => { + const c = new WheelClassifier(); + for (const [x, y] of deltas) c.accept(x, y); + return c.isPhysicalMouseWheel(); + }; + + test('steady integer notches are a wheel', () => { + expect( + feed([ + [0, 4], + [0, 4], + [0, 8], + [0, 4], + ]), + ).toBe(true); + }); + + test('fractional, accelerating deltas are a trackpad', () => { + expect( + feed([ + [0, 1.5], + [0, 3.25], + [0, 7.75], + [0, 12.5], + ]), + ).toBe(false); + }); + + test('both axes at once are a trackpad', () => { + expect( + feed([ + [0, 4], + [0, 4], + [1, 4], + [2, 6], + ]), + ).toBe(false); + }); + + test('no events is not a wheel', () => { + expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); + }); +}); + +describe('WheelStream with reduced motion', () => { + function reduced(run: () => void) { + const g = globalThis as { matchMedia?: unknown }; + const saved = g.matchMedia; + g.matchMedia = (q: string) => ({ matches: q === '(prefers-reduced-motion: reduce)' }); + try { + run(); + } finally { + g.matchMedia = saved; + } + } + const at = (deltaY: number, timeStamp: number, deltaMode = 0) => + ({ deltaX: 0, deltaY, deltaMode, timeStamp }) as WheelEvent; + + test('everything moves the scrollback at once', () => { + reduced(() => { + const s = new WheelStream(); + expect(s.forScrollback(at(-3, 1000, 1)).duration).toBe(0); + s.forScrollback(at(-95.5, 2000)); + expect(s.forScrollback(at(-97.25, 2040)).duration).toBe(0); + }); + }); + + test("a wheel notch's ticks go at once, a sparse stream's stay paced", () => { + reduced(() => { + const s = new WheelStream(); + expect(s.forApp(at(-3, 1000, 1)).duration).toBe(0); + s.forApp(at(-95.5, 2000)); + expect(s.forApp(at(-97.25, 2040)).duration).toBeGreaterThan(0); + }); + }); +}); + +describe('WheelStream classifies each event once', () => { + test('asking about the same event again does not count it twice', () => { + const s = new WheelStream(); + const first = { deltaX: 0, deltaY: -95.5, deltaMode: 0, timeStamp: 1000 } as WheelEvent; + const second = { deltaX: 0, deltaY: -97.25, deltaMode: 0, timeStamp: 1040 } as WheelEvent; + s.forApp(first); + const pace = s.forApp(second); + expect(s.forScrollback(second)).toEqual(pace); + expect(s.forApp(second)).toEqual(pace); + }); +}); diff --git a/src/client/wheel-stream.ts b/src/client/wheel-stream.ts new file mode 100644 index 0000000..ca7ff78 --- /dev/null +++ b/src/client/wheel-stream.ts @@ -0,0 +1,187 @@ +/** + * How a wheel event should move: tells a mouse wheel, a trackpad and a sparse + * stream such as a TrackPoint's apart, and picks the `Pace` that the scrollback + * glide (smooth-scroll.ts) and the ticks paced to apps (TickPacer) follow. See + * ADR 045. + * + * - A wheel notch eases out over the wheel glide time, or, soon after one in the + * same direction, continues linearly over its gap plus a frame, so a spinning + * wheel does not pulse. + * - A sparse delta, 25 ms or more after the previous one, glides linearly over + * that gap plus a frame, so a stream scrolls at the speed it is sent at; the + * extra frame keeps the glide going when the next delta is a little late. A + * stream stays sparse until two short gaps in a row, so one delta that comes a + * little early does not snap or flush. + * - While a sparse stream's deltas come every 60 ms or faster on average, it + * follows a spring instead, which evens out its uneven gaps, until it pauses or + * reverses. + * - Trackpad deltas, a frame apart, move at once. + */ +import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from './vendor/ghostty-web/glide'; + +/** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ +export const WHEEL_GLIDE_MS = 125; + +/** + * A pixel delta this long after the previous one is part of a sparse stream: a + * TrackPoint sends one every 30-200 ms, a trackpad one per frame. + */ +export const SPARSE_GAP_MS = 25; + +/** + * A wheel notch this soon after one in the same direction continues the scroll at + * a steady speed instead of easing out, so a spinning wheel or a slow TrackPoint + * (4 px every 150 ms) does not pulse. + */ +export const STREAM_MS = 200; + +/** + * A sparse stream whose deltas come this often or faster on average (a TrackPoint + * pushed hard, every 30-45 ms) follows a spring instead of gliding linearly over + * each gap: its gaps vary, and gliding over each one swung the speed by half + * from frame to frame. + */ +export const FAST_STREAM_MS = 60; + +/** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ +export const STREAM_OMEGA = 28; + +/** + * Tells a physical mouse wheel from a trackpad by its recent pixel deltas. + * Adapted from VS Code's `MouseWheelClassifier` (scrollableElement.ts), + * Copyright (c) Microsoft Corporation, MIT License. + */ +export class WheelClassifier { + private static readonly CAPACITY = 5; + /** Scores of the latest events, newest last: 0 looks like a wheel, 1 like a trackpad. */ + private scores: number[] = []; + private previous: { deltaX: number; deltaY: number } | null = null; + + accept(deltaX: number, deltaY: number): void { + this.scores.push(this.score(deltaX, deltaY)); + if (this.scores.length > WheelClassifier.CAPACITY) this.scores.shift(); + this.previous = { deltaX, deltaY }; + } + + /** The newest score weighs 1/2, the next 1/4, and so on; the oldest takes the rest. */ + isPhysicalMouseWheel(): boolean { + if (this.scores.length === 0) return false; + let remaining = 1; + let total = 0; + for (let i = this.scores.length - 1; i >= 0; i--) { + const influence = i === 0 ? remaining : 2 ** -(this.scores.length - i); + remaining -= influence; + total += this.scores[i] * influence; + } + return total <= 0.5; + } + + private score(deltaX: number, deltaY: number): number { + // Both axes at once: a trackpad. + if (deltaX !== 0 && deltaY !== 0) return 1; + let score = 0.5; + if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; + // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. + const prev = this.previous; + if (prev) { + const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; + const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; + const minX = Math.max(Math.min(ax, px), 1); + const minY = Math.max(Math.min(ay, py), 1); + if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; + } + return Math.min(Math.max(score, 0), 1); + } +} + +function isAlmostInt(value: number): boolean { + return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; +} + +/** The OS asks for less motion. */ +function reducedMotion(): boolean { + return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; +} + +/** A classified event: its pace, and whether it is a wheel notch. */ +interface Classified { + pace: Pace; + wheel: boolean; +} + +/** + * The stream of wheel events. Each event is classified once, however many + * times it is asked about, as the classification tracks the stream (gaps, + * direction, recent deltas). + */ +export class WheelStream { + private readonly classifier = new WheelClassifier(); + /** `timeStamp` and direction of the previous event. */ + private lastWheel: number | null = null; + private lastDir = 0; + /** In a sparse stream, how many short gaps in a row it has had, and its average gap. */ + private sparse = false; + private shortGaps = 0; + private averageGap = 0; + /** Following the spring: until the stream pauses, every delta keeps doing so. */ + private springing = false; + private last: { event: WheelEvent; classified: Classified } | null = null; + + /** How `e` moves the scrollback. With reduced motion, everything moves at once. */ + forScrollback(e: WheelEvent): Pace { + const { pace } = this.classify(e); + return reducedMotion() ? AT_ONCE : pace; + } + + /** + * How `e`'s ticks to an app are paced. With reduced motion a wheel notch's go at + * once, but a sparse stream's stay paced: that times input, it does not animate, + * and sending them at once brought back bursts of 20 ticks every other frame. + */ + forApp(e: WheelEvent): Pace { + const { pace, wheel } = this.classify(e); + return wheel && reducedMotion() ? AT_ONCE : pace; + } + + private classify(e: WheelEvent): Classified { + if (this.last?.event !== e) this.last = { event: e, classified: this.next(e) }; + return this.last.classified; + } + + private next(e: WheelEvent): Classified { + const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; + this.lastWheel = e.timeStamp; + const dir = Math.sign(e.deltaY); + const sameDir = dir === this.lastDir; + this.lastDir = dir; + if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); + const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); + if (gap >= SPARSE_GAP_MS) { + // A stream's first gap is the pause before it; count it as the slowest gap. + const capped = Math.min(gap, WHEEL_GLIDE_MS); + this.averageGap = this.sparse ? 0.4 * capped + 0.6 * this.averageGap : WHEEL_GLIDE_MS; + this.sparse = true; + this.shortGaps = 0; + } else if (this.sparse && ++this.shortGaps >= 2) { + this.sparse = false; + } + const spring: Pace = { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA }; + // A fast stream stays on the spring until it pauses: leaving it on a short gap + // applied all the spring's pending distance at once (985 px in one frame), and + // leaving it as the gaps grew at the end of a push sped up, then stopped hard. + if (!wheel && this.springing && gap < WHEEL_GLIDE_MS && sameDir) return { pace: spring, wheel }; + this.springing = false; + if (wheel && gap > 0 && gap <= STREAM_MS && sameDir) { + return { pace: { duration: Math.min(gap + FRAME_MS, STREAM_MS), ease: linear }, wheel }; + } + if (wheel) return { pace: { duration: WHEEL_GLIDE_MS, ease: easeOutCubic }, wheel }; + if (this.sparse && this.averageGap < FAST_STREAM_MS) { + this.springing = true; + return { pace: spring, wheel }; + } + if (this.sparse) { + return { pace: { duration: Math.min(gap + FRAME_MS, WHEEL_GLIDE_MS), ease: linear }, wheel }; + } + return { pace: AT_ONCE, wheel }; + } +} From 4279c76f1ac14ff2e6156a3bc1bc99073271ae91 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:33:33 -0400 Subject: [PATCH 033/295] docs(client): ADR 046, scrollback in lines --- docs/adrs/046.client.scrollback-lines.md | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/adrs/046.client.scrollback-lines.md diff --git a/docs/adrs/046.client.scrollback-lines.md b/docs/adrs/046.client.scrollback-lines.md new file mode 100644 index 0000000..f68b5ef --- /dev/null +++ b/docs/adrs/046.client.scrollback-lines.md @@ -0,0 +1,54 @@ +# ADR 046: client — Scrollback in lines + +**SPEC:** [config](../specs/config.md), [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-13 + +--- + +## Context + +The client passes `Math.ceil(config.scrollback / 80)` to the terminal as its scrollback limit (ADR 041). `scrollback` is the server's replay buffer in characters (default 262,144), so the terminal gets 3,277, which both engines read as bytes: + +- The Coder wasm keeps about 500–650 rows at 120 columns. +- The official engine (ADR 042) passes it to `SCROLLBACK_MAX_BYTES`. Ghostty prunes whole pages, so it keeps what fits in the pages it allocates, again far below what the setting suggests. + +A user who wants more history has to raise `scrollback` about 80 times, which also grows the server's replay buffer. + +The official C API limits scrollback in lines (`GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES`) and in bytes (`…_MAX_BYTES`), both `size_t*`, and whichever limit is reached first applies. A NULL pointer removes that limit; 0 bytes disables scrollback and erases it. Both are page-granular: the header says the line limit keeps somewhat more than asked, but on the pinned wasm it prunes to a little under (below), and the byte limit is within about a page (about 400 KB). The wasm's default byte limit is 10,000 bytes, so a line limit alone keeps almost nothing. + +## Decision + +A new client config key, `scrollbackLines`, sets the terminal's history in lines. ADR 041 step 4. + +- **`scrollbackLines`:** number, default `10000`. `0` means no line limit. Other values are floored to whole lines; negative or non-finite values are ignored (default). Re-read on tab reload, like the other terminal appearance keys, and served by `/api/config`. +- **Official engine:** `SCROLLBACK_MAX_LINES` is `scrollbackLines` (a NULL pointer for `0`), and `SCROLLBACK_MAX_BYTES` is always 32 MiB, so memory stays bounded with no line limit. +- **Coder engine:** unchanged. It keeps the derived byte limit until it is deleted (ADR 041 step 5). +- **`scrollback`** keeps its meaning, the server's replay buffer in characters; only the Coder engine still derives its limit from it. + +## Consequences + +- With the official engine, the terminal holds about `scrollbackLines` lines of history, unless 32 MiB fills first: wide terminals with many styles or graphemes hold fewer. Page granularity lands a little under the limit. Measured on the pinned wasm with plain lines: + + | Setting | Columns | Lines kept | + |---|---|---| + | derived limit today (3,277 bytes) | 120 | 584 | + | `scrollbackLines: 10000` | 120 | 9,682 | + | `scrollbackLines: 10000` | 300 | 9,886 | + | `scrollbackLines: 500` | 80 | 266 | + | `scrollbackLines: 0` (40,000 written) | 80 | 39,977 | +- Memory per tab grows with history instead of staying near one page. ADR 041's Node figures were about 11 MB at 10,000 lines of plain text at 120 columns and 25 MB of heavy truecolor; this ADR's measurements in headless Chrome are below. +- **Config migration:** none required. A user who raised `scrollback` to get more history keeps a larger replay buffer and gets 10,000 lines of history on the official engine; to change that, they set `scrollbackLines`. +- On the Coder engine (still the default) nothing changes, and `scrollbackLines` has no effect. + +## Considered Options + +- **Keep deriving the limit from `scrollback`.** One key, but it couples the server's replay buffer to the terminal's history and stays in bytes, which nobody can reason about. +- **A byte limit key.** Matches the C API, but page granularity and styles make bytes a poor proxy for "how far back can I scroll". +- **No byte ceiling with `scrollbackLines: 0`.** Truly unlimited, but one long `cat` could take a tab's memory without bound. + +## Related Decisions + +- [ADR 041 — Own the terminal layer on official libghostty-vt](041.client.libghostty-vt.md) +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md) +- [ADR 008 — Terminal config](008.webtty.config.md) From d2df6de6dd53345dc8b21b67dcfc0e036db39761 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:33:33 -0400 Subject: [PATCH 034/295] feat(client): set the official engine's history in lines with scrollbackLines The client passed Math.ceil(scrollback / 80) as the terminal's history, which both engines read as bytes: about 580 rows at 120 columns, and raising it meant growing the server's replay buffer 80 times over. A new config key, scrollbackLines (default 10000, 0 = no line limit), sets the official engine's SCROLLBACK_MAX_LINES, with SCROLLBACK_MAX_BYTES always at 32 MiB so memory stays bounded. It is served by /api/config and re-read on tab reload. scrollback keeps its meaning, the server's replay buffer; the Coder engine still derives its limit from it. On the pinned wasm, 10000 lines keeps 9682 rows at 120 columns, page pruning landing a little under (ADR 046). --- docs/adrs/041.client.libghostty-vt.md | 2 +- docs/specs/client.md | 3 +- docs/specs/config.md | 9 +++--- src/client/index.ts | 4 +++ src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 28 ++++++++++++----- src/client/vendor/ghostty-web/interfaces.ts | 1 + src/client/vendor/ghostty-web/terminal.ts | 4 ++- src/client/vendor/ghostty-web/types.ts | 5 +++ src/client/vendor/official-terminal.test.ts | 34 +++++++++++++++++++++ src/config.test.ts | 18 +++++++++++ src/config.ts | 6 ++++ src/server/routes.test.ts | 1 + src/server/routes.ts | 1 + 14 files changed, 103 insertions(+), 14 deletions(-) diff --git a/docs/adrs/041.client.libghostty-vt.md b/docs/adrs/041.client.libghostty-vt.md index 0f79c4a..f30e064 100644 --- a/docs/adrs/041.client.libghostty-vt.md +++ b/docs/adrs/041.client.libghostty-vt.md @@ -53,7 +53,7 @@ Take ownership of the terminal layer: vendor ghostty-web's TypeScript into webtt | DECSCUSR applied inside the terminal, with a cursor-cell repaint | `cursor.ts` stream interceptor and ghost-cursor repaint | 013, 015 | 3. **Official wasm and binding, alongside the Coder engine** ([ADR 042](042.client.official-vt-engine.md)). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. -4. **Scrollback in lines** (own ADR, client and config). A new client key `scrollbackLines` (default 10,000) sets `SCROLLBACK_MAX_LINES`, with `SCROLLBACK_MAX_BYTES` at 32 MiB. `scrollbackLines: 0` means unlimited lines: `SCROLLBACK_MAX_LINES` NULL, bytes still 32 MiB. `scrollback` keeps its current meaning, the server's replay buffer in characters (`src/server/websocket.ts`), and the client stops deriving its limit from it. Official engine only. +4. **Scrollback in lines** (own ADR, client and config). A new client key `scrollbackLines` (default 10,000) sets `SCROLLBACK_MAX_LINES`, with `SCROLLBACK_MAX_BYTES` at 32 MiB. `scrollbackLines: 0` means unlimited lines: `SCROLLBACK_MAX_LINES` NULL, bytes still 32 MiB. `scrollback` keeps its current meaning, the server's replay buffer in characters (`src/server/websocket.ts`), and the client stops deriving its limit from it. Official engine only. (Done in [ADR 046](046.client.scrollback-lines.md).) 5. **Delete the Coder engine and the workarounds the official API makes unnecessary**, after one release with `official` as the default: the Coder wasm and its sidecar, cursor style from the render-state cursor, size reports answered by Ghostty through `OPT_SIZE` (deletes the `kitty.ts` size replies; depends on step 3 installing `OPT_SIZE`), the official mouse/key encoders and `terminal_paste`, theme colours from render state. 6. **Pixel-smooth scroll through `OVERSCAN`** (own ADR, supersedes ADR 040 in part). Draw the extra rows the render state provides at a fractional offset, replacing the strip canvas and `renderLine` swap of ADR 040. When it ships, ADR 040 gets a "Superseded in part by" note and the client SPEC's ADR 040 row is updated. (Done in [ADR 043](043.client.scroll-copy-and-overscan.md) step 2, with the extra rows read through the scrollback provider on both engines instead of `OVERSCAN`.) diff --git a/docs/specs/client.md b/docs/specs/client.md index 3095f22..00d6c54 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -79,6 +79,7 @@ The Coder wasm and sidecar stay until the Coder engine is removed ([ADR 041](../ ```ts { cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, + scrollbackLines, // Official engine: history in lines, 0 = no line limit, 32 MiB ceiling (ADR 046) theme, copyOnSelect, rightClickBehavior, mouseScrollSpeed, // Terminal option: SGR wheel ticks per line/page event when the app tracks the mouse padding, // px around the canvas, used by fit(); coloured by theme.padding or theme.background @@ -196,7 +197,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | | Official engine by default | The default flips to `official` once ADR 042's acceptance matrix (Chrome, Firefox, Safari; resize/reflow; query round trips; #180; parity) is recorded; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | -| Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Scrollback in lines | On the official engine, terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index ab51762..25dade3 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -62,7 +62,7 @@ loadConfig() — re-read file from disk │ ▼ render HTML with fresh appearance settings injected: -cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, theme +cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, scrollbackLines, theme ``` ### New PTY spawn (first WebSocket connection to a session) @@ -91,7 +91,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -108,6 +108,7 @@ All keys are optional — omit any key to use the default value. | `term` | string | `"xterm-256color"` | `$TERM` env var passed to the shell. Fixed to `xterm-256color` — the PTY child talks to webtty, not to the parent terminal. See [ADR 016](../adrs/016.config.term-default.md). | | `colorTerm` | string | `"truecolor"` | `$COLORTERM` env var passed to the shell | | `scrollback` | number | `262144` | PTY history buffer in bytes; used for server-side replay on reload/reconnect | +| `scrollbackLines` | number | `10000` | Terminal history in lines on the official engine (`?engine=official`); `0` = no line limit. History is also capped at 32 MiB, so wide terminals with many styles can keep fewer lines, and Ghostty prunes whole pages, so it keeps a little under the limit (about 9,700 of 10,000). Fractions are floored; negative values are ignored. The Coder engine ignores it and keeps a few hundred rows derived from `scrollback`. See [ADR 046](../adrs/046.client.scrollback-lines.md). | | `cols` | number | `80` | Initial terminal width in columns | | `rows` | number | `24` | Initial terminal height in rows | | `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR — this is the startup default only. | @@ -122,7 +123,6 @@ All keys are optional — omit any key to use the default value. | `theme` | object | Campbell | Terminal color palette — see theme keys below | | `keyboardBindings` | array | `[]` | Custom key-to-sequence bindings sent to the PTY. See [key-bindings spec](key-bindings.md) for schema and examples. | -Planned ([ADR 041](../adrs/041.client.libghostty-vt.md)): a client key `scrollbackLines` (number, default `10000`; `0` = unlimited lines) sets the terminal's history in lines, under a 32 MiB byte ceiling. `scrollback` keeps its meaning as the server's replay buffer, and the client stops deriving its history limit from it (today it passes `Math.ceil(scrollback / 80)`, which the terminal reads as bytes). ### Theme keys @@ -164,6 +164,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "term": "xterm-256color", "scrollback": 262144, + "scrollbackLines": 10000, "cols": 80, "rows": 24, "cursorStyle": "bar", @@ -215,6 +216,6 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Scrollback in lines | `scrollbackLines` (default `10000`, `0` = unlimited) sets terminal history in lines under a 32 MiB ceiling; `scrollback` stays the server replay buffer, so existing configs keep their meaning | [ADR 041](../adrs/041.client.libghostty-vt.md) | ⬜ | +| Scrollback in lines | `scrollbackLines` (default `10000`, `0` = no line limit) sets terminal history in lines under a 32 MiB ceiling on the official engine; `scrollback` stays the server replay buffer, so existing configs keep their meaning | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | | Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | | Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index e85596d..94cdf36 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -49,6 +49,7 @@ interface ClientConfig { cursorStyle: 'block' | 'bar' | 'underline'; cursorStyleBlink: boolean; scrollback: number; + scrollbackLines: number; theme: Theme; copyOnSelect: boolean; rightClickBehavior: 'default' | 'copyPaste'; @@ -83,7 +84,10 @@ const term = new Terminal({ cursorBlink: config.cursorStyleBlink, fontSize: config.fontSize, fontFamily: config.fontFamily, + // The Coder engine's history in bytes, derived from the server's replay buffer; + // the official engine uses scrollbackLines (ADR 046). scrollback: Math.ceil(config.scrollback / 80), + scrollbackLines: config.scrollbackLines, mouseScrollSpeed: config.mouseScrollSpeed, theme: terminalTheme, }); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 2845430..3a6bf9e 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -26,4 +26,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The official engine reports its real dirty state while scrolled. - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), a hyperlink hover change repaints the rows out of view too, and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. +- `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the official engine then sets `SCROLLBACK_MAX_LINES` (NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB instead of the derived byte limit. The Coder engine ignores it. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index e0dd468..3baa523 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -1275,6 +1275,7 @@ function resolveOfficialLayout(layout: VtLayout) { COLOR_CURSOR: opt('COLOR_CURSOR'), COLOR_PALETTE: opt('COLOR_PALETTE'), SCROLLBACK_MAX_BYTES: opt('SCROLLBACK_MAX_BYTES'), + SCROLLBACK_MAX_LINES: opt('SCROLLBACK_MAX_LINES'), MODE_DEFAULT: opt('MODE_DEFAULT'), }, tdata: { @@ -1575,8 +1576,11 @@ interface DecodedStyle { const I32 = WasmValType.I32; /** At most this many pages of scrollback rows stay cached per generation. */ const PAGE_CACHE_SIZE = 16; -/** `scrollback: 0` (unlimited on the Coder wasm) becomes this byte ceiling. */ -const UNLIMITED_SCROLLBACK_BYTES = 32 * 1024 * 1024; +/** + * The scrollback byte ceiling: with `scrollbackLines` always (ADR 046), and for + * `scrollback: 0`, which is unlimited on the Coder wasm. + */ +const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; /** The Coder engine's default scrollback limit when no config is given. */ const DEFAULT_SCROLLBACK_BYTES = 10000; /** The Coder engine's default colours, used when the theme leaves one unset. */ @@ -2255,11 +2259,21 @@ export class OfficialTerminal implements GhosttyTerminal { const L = this.L; const at = this.at; - // Scrollback keeps today's derived byte budget (ADR 042). 0 bytes would disable - // scrollback and NULL would remove the limit, so neither is ever passed. - const limit = config?.scrollbackLimit ?? DEFAULT_SCROLLBACK_BYTES; - this.dv().setUint32(at.u32, limit > 0 ? limit : UNLIMITED_SCROLLBACK_BYTES, true); - this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + // Scrollback in lines under a 32 MiB ceiling (ADR 046); without scrollbackLines, + // the derived byte budget (ADR 042). Whichever limit is reached first applies. 0 + // bytes would disable scrollback and a NULL byte limit would remove the ceiling, + // so neither is ever passed; NULL lines removes the line limit. + const lines = config?.scrollbackLines; + if (lines !== undefined) { + this.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); + this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + if (lines > 0) this.dv().setUint32(at.u32, lines, true); + this.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); + } else { + const limit = config?.scrollbackLimit ?? DEFAULT_SCROLLBACK_BYTES; + this.dv().setUint32(at.u32, limit > 0 ? limit : SCROLLBACK_CEILING_BYTES, true); + this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + } // Grapheme clustering (mode 2027) on by default, so RIS keeps it. const dv = this.dv(); diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index dced290..d9e9020 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -11,6 +11,7 @@ export interface ITerminalOptions { cursorStyle?: 'block' | 'underline' | 'bar'; theme?: ITheme; scrollback?: number; // Default: 10000 + scrollbackLines?: number; // Official engine: history in lines, 0 = no line limit (webtty, ADR 046) fontSize?: number; // Default: 15 fontFamily?: string; // Default: 'monospace' allowTransparency?: boolean; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 4bbbc4d..f9e5524 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -397,9 +397,10 @@ export class Terminal implements ITerminalCore { private buildWasmConfig(): GhosttyTerminalConfig | undefined { const theme = this.options.theme; const scrollback = this.options.scrollback; + const scrollbackLines = this.options.scrollbackLines; // If no theme and default scrollback, use defaults - if (!theme && scrollback === 10000) { + if (!theme && scrollback === 10000 && scrollbackLines === undefined) { return undefined; } @@ -430,6 +431,7 @@ export class Terminal implements ITerminalCore { return { scrollbackLimit: scrollback, + scrollbackLines, fgColor: fg ?? 0, bgColor: bg ?? 0, cursorColor: cursor ?? 0, diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index e1a1e86..a69ecff 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -533,6 +533,11 @@ export const COLORS_STRUCT_SIZE = 12; */ export interface GhosttyTerminalConfig { scrollbackLimit?: number; + /** + * History in lines, with a 32 MiB ceiling; 0 = no line limit. Official engine + * only; when set it replaces `scrollbackLimit` there. (webtty, ADR 046) + */ + scrollbackLines?: number; fgColor?: number; bgColor?: number; cursorColor?: number; diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 49889d7..1ce3e35 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -531,6 +531,40 @@ describe('official engine: scrollback, resize and the viewport', () => { }); }); +describe('official engine: scrollback in lines (ADR 046)', () => { + /** Writes `n` numbered lines and returns how many scrollback rows the terminal kept. */ + function keep(config: object, n: number, cols = 80): number { + const t = vt.createTerminal(cols, 24, config) as OfficialTerminal; + let chunk = ''; + for (let i = 0; i < n; i++) { + chunk += `line ${i}\r\n`; + if (chunk.length > 1 << 20) { + t.write(chunk); + chunk = ''; + } + } + t.write(chunk); + t.update(); + const kept = t.getScrollbackLength(); + t.free(); + return kept; + } + + test('keeps about scrollbackLines lines, a page at most under', () => { + const kept = keep({ scrollbackLines: 10000 }, 15000); + expect(kept).toBeGreaterThan(9000); + expect(kept).toBeLessThanOrEqual(10000 + 200); + }); + + test('scrollbackLines 0 removes the line limit', () => { + expect(keep({ scrollbackLines: 0 }, 20000)).toBe(20000 - 24 + 1); + }); + + test('without scrollbackLines the byte limit applies, as before', () => { + expect(keep({ scrollbackLimit: 3277 }, 15000)).toBeLessThan(2000); + }); +}); + describe('official engine: effects and replies', () => { test('reports the title and the bell once', () => { const t = term(); diff --git a/src/config.test.ts b/src/config.test.ts index 89b16a8..c7341c7 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -183,6 +183,24 @@ describe('loadConfig — reads and merges', () => { expect(loadConfig().padding).toBe(0); }); + test('defaults scrollbackLines to 10000 and accepts 0 or a whole number of lines', () => { + writeConfig(JSON.stringify({})); + expect(loadConfig().scrollbackLines).toBe(10000); + writeConfig(JSON.stringify({ scrollbackLines: 0 })); + expect(loadConfig().scrollbackLines).toBe(0); + writeConfig(JSON.stringify({ scrollbackLines: 2500.7 })); + expect(loadConfig().scrollbackLines).toBe(2500); + }); + + test('ignores a negative, infinite or non-numeric scrollbackLines', () => { + for (const scrollbackLines of [-1, '5000', null]) { + writeConfig(JSON.stringify({ scrollbackLines })); + expect(loadConfig().scrollbackLines).toBe(10000); + } + writeConfig('{"scrollbackLines": 1e999}'); + expect(loadConfig().scrollbackLines).toBe(10000); + }); + test('passes theme.padding through and leaves it unset by default', () => { writeConfig(JSON.stringify({ theme: { padding: '#0D0D0D' } })); expect(loadConfig().theme.padding).toBe('#0D0D0D'); diff --git a/src/config.ts b/src/config.ts index 211f311..ef315e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,6 +77,8 @@ export interface Config { colorTerm: string; /** PTY history buffer in bytes; used for server-side replay on reload/reconnect. */ scrollback: number; + /** Terminal history in lines on the official engine; `0` = no line limit. History is also capped at 32 MiB. See ADR 046. */ + scrollbackLines: number; /** Initial terminal width in columns. */ cols: number; /** Initial terminal height in rows. */ @@ -160,6 +162,7 @@ export const DEFAULT_CONFIG: Config = { term: 'xterm-256color', colorTerm: 'truecolor', scrollback: 256 * 1024, + scrollbackLines: 10000, cols: 80, rows: 24, fontSize: 13, @@ -268,6 +271,9 @@ export function loadConfig(): Config { ...(typeof p.term === 'string' && { term: p.term }), ...(typeof p.colorTerm === 'string' && { colorTerm: p.colorTerm }), ...(typeof p.scrollback === 'number' && { scrollback: p.scrollback }), + ...(typeof p.scrollbackLines === 'number' && + Number.isFinite(p.scrollbackLines) && + p.scrollbackLines >= 0 && { scrollbackLines: Math.floor(p.scrollbackLines) }), ...(typeof p.cols === 'number' && { cols: p.cols }), ...(typeof p.rows === 'number' && { rows: p.rows }), ...(typeof p.fontSize === 'number' && { fontSize: p.fontSize }), diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 4834963..f78cfc4 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -128,6 +128,7 @@ describe('server — routes', () => { expect(typeof body.copyOnSelect).toBe('boolean'); expect(typeof body.rightClickBehavior).toBe('string'); expect(typeof body.mouseScrollSpeed).toBe('number'); + expect(body.scrollbackLines).toBe(10000); expect(typeof body.padding).toBe('number'); expect(body.port).toBeUndefined(); expect(body.host).toBeUndefined(); diff --git a/src/server/routes.ts b/src/server/routes.ts index ae8a112..a076614 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -118,6 +118,7 @@ export async function handleRequest( cursorStyle: config.cursorStyle, cursorStyleBlink: config.cursorStyleBlink, scrollback: config.scrollback, + scrollbackLines: config.scrollbackLines, theme: config.theme, copyOnSelect: config.copyOnSelect, rightClickBehavior: config.rightClickBehavior, From 56a290ced690c553e67f7f0213fc3e99bc095942 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:37:31 -0400 Subject: [PATCH 035/295] fix(client): let the spring take over a fast stream without a speed dip The spring trails a steady stream by about 70 ms, further than the linear glide it takes over from, and opening that gap at once slowed the view by half for a few frames (160 -> 89 px per frame). It now takes over at the input's speed and at the stiffness whose lag matches where the view is, relaxing to its own over 150 ms: the slowest frame after the switch is 107 px, and frame-to-frame change drops 6-11% on the replayed streams with exact totals (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 +- src/client/vendor/ghostty-web/glide.ts | 35 +++++++++++++++++++++- src/client/vendor/glide.test.ts | 22 ++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 1604c97..ca09f53 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -28,7 +28,7 @@ In detail: - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. - **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. A notch within 200 ms of one in the same direction continues linearly over its gap plus a frame instead of easing out again, so a spinning wheel, or a TrackPoint's slow 4 px steps every 150 ms, scroll steadily instead of pulsing. - **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. -- **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. Once on the spring, a stream stays on it until it pauses for 125 ms or reverses: leaving it on two short gaps in a row applied the spring's pending distance at once (985 px in one frame in a replay with 22 ms gaps), and leaving it as the gaps grew at the end of a push sped the view up and then stopped it dead. Once no event has come for 60 ms, the spring's stiffness doubles, so the view stops in about 200 ms instead of coasting for 350 ms; it is settled within half a device pixel. End to end, a hard push's frames went from a frame-to-frame standard deviation of 24–43 px to 10.5–15 px, with 13 → 55 ms of lag. +- **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. Once on the spring, a stream stays on it until it pauses for 125 ms or reverses: leaving it on two short gaps in a row applied the spring's pending distance at once (985 px in one frame in a replay with 22 ms gaps), and leaving it as the gaps grew at the end of a push sped the view up and then stopped it dead. The spring trails a steady stream by 2/ω, about 70 ms, further than the linear glide it takes over from, and opening that gap at once slowed the view by half for a few frames (160 → 89 px per frame); so it takes over at the input's speed and at the stiffness whose lag matches where the view is, relaxing to ω over 150 ms (the slowest frame after the switch is now 107 px). Once no event has come for 60 ms, the spring's stiffness doubles, so the view stops in about 200 ms instead of coasting for 350 ms; it is settled within half a device pixel. End to end, a hard push's frames went from a frame-to-frame standard deviation of 24–43 px to 10.5–15 px, with 13 → 55 ms of lag. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). diff --git a/src/client/vendor/ghostty-web/glide.ts b/src/client/vendor/ghostty-web/glide.ts index db33783..5bbdfd0 100644 --- a/src/client/vendor/ghostty-web/glide.ts +++ b/src/client/vendor/ghostty-web/glide.ts @@ -53,6 +53,14 @@ export function springStep( return [target + (e + c * dt) * k, (v - omega * c * dt) * k]; } +/** + * How long a spring that takes over from a timed glide takes to relax from the + * stiffness matching the glide's lag to its own (time constant, ms). The spring + * follows further behind than a glide over each gap does, and opening that gap at + * once slowed the view by half for a few frames; this opens it over a few deltas. + */ +export const SPRING_TAKEOVER_MS = 150; + /** The stiffness of a spring with stiffness `omega` `idle` ms after its target last moved. */ export function springOmega(omega: number, idle: number): number { return idle > SPRING_RELEASE_MS ? omega * 2 : omega; @@ -81,6 +89,9 @@ export class Glide { private ease: Ease = linear; /** The spring's stiffness while following one, else 0. */ private omega = 0; + /** Stiffness the spring took over at, and when; it relaxes to `omega` from there. */ + private takeoverOmega = 0; + private takeoverAt = 0; private lastFrame: number | null = null; private lastRetarget = 0; private moving = false; @@ -92,9 +103,12 @@ export class Glide { /** Heads for `target` as `pace` says; at once when its duration is 0. `now` is `performance.now()`. */ retarget(target: number, pace: Pace, now: number): void { + const gap = now - this.lastRetarget; + const step = target - this.target; this.lastRetarget = now; if (pace.omega) { if (!this.moving) this.lastFrame = now - FRAME_MS; + if (!this.omega) this.takeOver(target, step, gap, pace.omega, now); this.omega = pace.omega; this.target = target; this.moving = true; @@ -111,6 +125,22 @@ export class Glide { } } + /** + * Starts a spring at the input's speed (`step` over `gap` ms) and at the stiffness + * whose lag matches the distance still to go, so the view neither slows down nor + * jumps when the spring takes over; it relaxes to `omega` over a few deltas. + */ + private takeOver(target: number, step: number, gap: number, omega: number, now: number): void { + const rate = gap > 0 ? step / (gap / 1000) : 0; + if (Math.sign(rate) === Math.sign(target - this.value) && Math.abs(rate) > Math.abs(this.velocity)) { + this.velocity = rate; + } + // A critically damped spring following a steady ramp trails it by 2 / omega. + const lag = rate ? Math.abs(target - this.value) / Math.abs(rate) : 0; + this.takeoverOmega = lag > 0 ? Math.min(Math.max(2 / lag, omega), omega * 3) : omega; + this.takeoverAt = now; + } + /** Puts the value at `value` and stops. */ jump(value: number): void { this.value = value; @@ -122,6 +152,7 @@ export class Glide { stop(): void { this.moving = false; this.omega = 0; + this.takeoverOmega = 0; this.velocity = 0; this.lastFrame = null; } @@ -136,7 +167,9 @@ export class Glide { const dt = Math.min(Math.max(now - (this.lastFrame ?? now - FRAME_MS), 0), 50) / 1000; let done: boolean; if (this.omega) { - const omega = springOmega(this.omega, now - this.lastRetarget); + const relax = Math.exp(-(now - this.takeoverAt) / SPRING_TAKEOVER_MS); + const base = this.omega + (this.takeoverOmega - this.omega) * relax; + const omega = springOmega(base, now - this.lastRetarget); const [x, v] = springStep(this.value, this.velocity, this.target, omega, dt); this.velocity = v; done = Math.abs(this.target - x) < settle && Math.abs(v) * (FRAME_MS / 1000) < settle; diff --git a/src/client/vendor/glide.test.ts b/src/client/vendor/glide.test.ts index f112e0b..27da142 100644 --- a/src/client/vendor/glide.test.ts +++ b/src/client/vendor/glide.test.ts @@ -81,6 +81,28 @@ describe('Glide', () => { expect(g.value - from).toBeGreaterThan(linearStep * 0.8); }); + test('a spring taking over a steady stream does not slow the value down much', () => { + const g = new Glide(); + // 300 units every 30 ms, gliding linearly over each gap plus a frame for the first + // four, then following a spring; frames at 60 Hz, independent of the events. + const moved: number[] = []; + let target = 0; + let event = 0; + for (let frame = 1; frame <= 24; frame++) { + const now = frame * FRAME_MS; + for (; event * 30 <= now && event < 12; event++) { + target += 300; + const pace = event < 4 ? { duration: 30 + FRAME_MS } : { duration: 125, omega: 28 }; + g.retarget(target, pace, event * 30); + } + const before = g.value; + g.step(now, 1e-3); + if (event > 4) moved.push(g.value - before); + } + // The input moves 167 per frame. Opening the spring's lag at once dipped to 89. + expect(Math.min(...moved.slice(0, 12))).toBeGreaterThan(0.55 * 167); + }); + test('a spring settles on its target', () => { const g = new Glide(); g.retarget(10, { duration: 100, omega: 28 }, 0); From 1fb11a57f36ddee928184adfffb879a4b43f4083 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 18:40:09 -0400 Subject: [PATCH 036/295] fix(client): pass scrollbackLines through the terminal's options The vendored Terminal constructor copies a fixed list of options, and scrollbackLines was not on it, so the browser never handed it to the engine: the official engine kept the old derived byte limit, about 440 rows (seq 20000 reached only 19562). The engine-level tests passed because they configure the engine directly; a new test checks the config the Terminal builds from its options (ADR 046). --- src/client/vendor/ghostty-web/terminal.ts | 1 + src/client/vendor/terminal.test.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index f9e5524..0bec96b 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -255,6 +255,7 @@ export class Terminal implements ITerminalCore { cursorStyle: options.cursorStyle ?? 'block', theme: options.theme ?? {}, scrollback: options.scrollback ?? 10000, + scrollbackLines: options.scrollbackLines, fontSize: options.fontSize ?? 15, fontFamily: options.fontFamily ?? 'monospace', allowTransparency: options.allowTransparency ?? false, diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 4389a36..c0d1dc5 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -5,6 +5,7 @@ import { deviceCellPixels, mouseCursorFor, scanDecscusr, + Terminal, } from './ghostty-web/terminal'; import type { ILink } from './ghostty-web/types'; @@ -149,3 +150,22 @@ describe('deviceCellPixels', () => { expect(deviceCellPixels(0.2, 1)).toBe(1); }); }); + +describe('Terminal scrollback options', () => { + // The engine config the terminal builds from its options (ADR 046). + const wasmConfig = (options: object) => + ( + new Terminal({ ghostty: {} as never, ...options }) as unknown as { + buildWasmConfig(): { scrollbackLimit?: number; scrollbackLines?: number } | undefined; + } + ).buildWasmConfig(); + + test('passes scrollbackLines through to the engine', () => { + expect(wasmConfig({ scrollbackLines: 10000 })?.scrollbackLines).toBe(10000); + expect(wasmConfig({ scrollbackLines: 0 })?.scrollbackLines).toBe(0); + }); + + test('leaves it unset when not given', () => { + expect(wasmConfig({ scrollback: 3277 })?.scrollbackLines).toBeUndefined(); + }); +}); From ac4f767807955a1c477f9cd101e45d5ec431a3b5 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:16:35 -0400 Subject: [PATCH 037/295] docs(client): ADR 047, official engine only Make the official libghostty-vt wasm the only engine and delete the Coder engine now, instead of after a release on an official default (ADR 041 step 5). Trades the per-tab ?engine=coder fallback for less code; rollback is git revert. Records ADR 042's acceptance record, with the rows measured against Coder (selection copy timing, screenshot A/B) waived, and notes the change in ADR 041, 042 and 046 and the client, config and kitty-graphics SPECs. --- docs/adrs/041.client.libghostty-vt.md | 11 +-- docs/adrs/042.client.official-vt-engine.md | 21 ++++- docs/adrs/046.client.scrollback-lines.md | 2 +- docs/adrs/047.client.official-engine-only.md | 86 ++++++++++++++++++++ docs/specs/client.md | 6 +- docs/specs/config.md | 8 +- docs/specs/kitty-graphics.md | 4 +- 7 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 docs/adrs/047.client.official-engine-only.md diff --git a/docs/adrs/041.client.libghostty-vt.md b/docs/adrs/041.client.libghostty-vt.md index f30e064..9b8be3b 100644 --- a/docs/adrs/041.client.libghostty-vt.md +++ b/docs/adrs/041.client.libghostty-vt.md @@ -52,9 +52,9 @@ Take ownership of the terminal layer: vendor ghostty-web's TypeScript into webtt | Textarea keeps focus and follows the cursor cell | focus and positioning half of `compose.ts` | 038 | | DECSCUSR applied inside the terminal, with a cursor-cell repaint | `cursor.ts` stream interceptor and ghost-cursor repaint | 013, 015 | -3. **Official wasm and binding, alongside the Coder engine** ([ADR 042](042.client.official-vt-engine.md)). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. +3. **Official wasm and binding, alongside the Coder engine** ([ADR 042](042.client.official-vt-engine.md)). Add an official binding (`ghostty.ts`) behind the same `GhosttyTerminal` surface, so the renderer, selection and buffer code stay as they are. The binding reads rows through `next_dirty` and `ROW_DATA_CELLS_RAW`; wires `WRITE_PTY`, `BELL`, `TITLE_CHANGED`, `SIZE` and `DEVICE_ATTRIBUTES` through a function-table trampoline; and turns on mode 2027 with `MODE_DEFAULT`. `OPT_SIZE` answers with device-pixel cell sizes as `kitty.ts` does today, and `kitty.ts` stops sending its own size replies on this engine so apps don't get two. `DEVICE_ATTRIBUTES` returns `ESC[?62;22c` and `ESC[>1;10;0c`, as the Coder wasm does (without the callback the official secondary DA is `ESC[>1;0;0c`). Scrollback keeps today's budget: the same derived value goes to `SCROLLBACK_MAX_BYTES`. The engine is chosen at build time with a URL override (`?engine=coder|official`); the default stays `coder` until a parity and browser matrix is recorded (Chrome, Firefox, Safari; resize/reflow; DA/DSR/XTWINOPS/OSC round-trips; the #180 payload), then flips to `official`. (The default did not flip on its own: [ADR 047](047.client.official-engine-only.md) made `official` the only engine and removed `?engine`.) 4. **Scrollback in lines** (own ADR, client and config). A new client key `scrollbackLines` (default 10,000) sets `SCROLLBACK_MAX_LINES`, with `SCROLLBACK_MAX_BYTES` at 32 MiB. `scrollbackLines: 0` means unlimited lines: `SCROLLBACK_MAX_LINES` NULL, bytes still 32 MiB. `scrollback` keeps its current meaning, the server's replay buffer in characters (`src/server/websocket.ts`), and the client stops deriving its limit from it. Official engine only. (Done in [ADR 046](046.client.scrollback-lines.md).) -5. **Delete the Coder engine and the workarounds the official API makes unnecessary**, after one release with `official` as the default: the Coder wasm and its sidecar, cursor style from the render-state cursor, size reports answered by Ghostty through `OPT_SIZE` (deletes the `kitty.ts` size replies; depends on step 3 installing `OPT_SIZE`), the official mouse/key encoders and `terminal_paste`, theme colours from render state. +5. **Delete the Coder engine and the workarounds the official API makes unnecessary**, after one release with `official` as the default: the Coder wasm and its sidecar, cursor style from the render-state cursor, size reports answered by Ghostty through `OPT_SIZE` (deletes the `kitty.ts` size replies; depends on step 3 installing `OPT_SIZE`), the official mouse/key encoders and `terminal_paste`, theme colours from render state. (Done in part in [ADR 047](047.client.official-engine-only.md), without waiting a release: the Coder engine, its wasm and sidecar, and the `kitty.ts` size replies are deleted, and the official key encoder was already in use. Cursor style, the mouse encoder, `terminal_paste` and theme colours from render state are open follow-ups.) 6. **Pixel-smooth scroll through `OVERSCAN`** (own ADR, supersedes ADR 040 in part). Draw the extra rows the render state provides at a fractional offset, replacing the strip canvas and `renderLine` swap of ADR 040. When it ships, ADR 040 gets a "Superseded in part by" note and the client SPEC's ADR 040 row is updated. (Done in [ADR 043](043.client.scroll-copy-and-overscan.md) step 2, with the extra rows read through the scrollback provider on both engines instead of `OVERSCAN`.) Future work, not decided here: official selection, search, synchronized-output hold (mode 2026), OSC 52 and notifications through effects. @@ -63,9 +63,9 @@ Future work, not decided here: official selection, search, synchronized-output h - Step 1: revert the change; the npm dependency and `dist/ghostty-web.js` come back. - Step 2: each fix is its own change; reverting one restores its workaround. -- Step 3: `?engine=coder` falls back per tab; flipping the build-time default back is a one-line change. The Coder engine is kept for at least one release after the default flips. +- Step 3: `?engine=coder` falls back per tab; flipping the build-time default back is a one-line change. The Coder engine is kept for at least one release after the default flips. (Changed by [ADR 047](047.client.official-engine-only.md): the Coder engine was deleted without that release, and the fallback went with it.) - Step 4: revert restores the derived byte limit; `scrollback` and the server are untouched, so no config needs migrating back. -- Step 5: git revert only; this is why it waits for a release on the official default. +- Step 5: git revert only; this is why it waits for a release on the official default. (ADR 047 did not wait; revert is the only rollback.) - Step 6: the ADR 040 path stays until `OVERSCAN` frames are verified in Chrome, Firefox and Safari; reverting restores it. Rules for the binding and the vendored official wasm (inputs to the step 3 and step 4 ADRs): @@ -98,7 +98,7 @@ Code borrowed from other projects keeps its licence notice and a header naming s - Wasm memory, measured in Node/V8 (not yet in a browser): about 11 MB per tab at 10,000 lines and 120 columns of plain text, about 25 MB for heavy truecolor, about 56 MB at 300 columns with heavy truecolor. The byte ceiling bounds scrollback pages only; total memory is roughly the ceiling plus the active screen plus allocator and page-pool overhead, which is how a tab can exceed 32 MiB. Step 4 confirms the figures in headless Chrome. - **Config migration.** `scrollback` keeps its meaning (server replay buffer, default 262144), so existing configs replay as before. Client history moves to `scrollbackLines`: a user who raised `scrollback` to get more terminal history gets the 10,000-line default instead and must set `scrollbackLines`. The config SPEC documents both keys when step 4 ships. - webtty owns the terminal code: fixes are normal edits with tests, and the `as unknown as` casts into the renderer go away. -- About 780 KB of binary in git per official pin (plus the Coder wasm until step 5), and webtty carries the vendored ghostty-web code (MIT, Coder) from here on. +- About 780 KB of binary in git per official pin (plus the Coder wasm until step 5, deleted in ADR 047), and webtty carries the vendored ghostty-web code (MIT, Coder) from here on. - **API instability.** libghostty-vt is unversioned and its headers say the API will change (allocator exports, the mode API and colour getters all changed between Ghostty `09557e91` and `05dcf887`). A pin bump is a deliberate change: re-run parity and the binding's tests. The wasm does not record its commit (`ghostty_build_info` reports `0.1.0-dev`); the sidecar is the only record. - **Memory never shrinks.** Wasm linear memory has no maximum and stays at its high-water mark after `reset` and `free`; compression is unsupported on wasm32. A tab gives memory back only when it closes. - **Tip-build availability.** Only some main commits get a tip wasm (8 of the last 25 checked), and the `.minisig` is downloadable only while the commit is the current `tip`. Committing the wasm and its signature removes the runtime dependence; pinning an arbitrary commit means a local Zig build. @@ -119,3 +119,4 @@ Code borrowed from other projects keeps its licence notice and a header naming s - [ADR 039 — Follow devicePixelRatio changes](039.client.device-pixel-ratio-change.md) - [ADR 040 — Pixel-smooth scrollback scrolling](040.client.pixel-smooth-scroll.md) - [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md) +- [ADR 047 — Official libghostty-vt as the only engine](047.client.official-engine-only.md) diff --git a/docs/adrs/042.client.official-vt-engine.md b/docs/adrs/042.client.official-vt-engine.md index d5f6d58..9b5def5 100644 --- a/docs/adrs/042.client.official-vt-engine.md +++ b/docs/adrs/042.client.official-vt-engine.md @@ -49,6 +49,8 @@ The official wasm keeps more rows in three cases and 7% fewer in one. ADR 041's ## Decision +**Update (ADR 047):** the official engine is now the only engine. [ADR 047](047.client.official-engine-only.md) deleted the Coder engine, `engine.ts` and `?engine` instead of flipping `DEFAULT_ENGINE` and keeping Coder for a release. The two-engine parts below (`answersSizeQueries`, the Coder stream scan, `colorsSet`, the derived scrollback budget, parity against Coder) describe this ADR as decided; ADR 047 lists what replaced each one. The acceptance record is below. + Add the official engine as a second engine behind one interface, selectable per tab with `?engine=official`, with `coder` as the default until the acceptance matrix below is recorded. **Files.** @@ -108,7 +110,7 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the **Served wasm.** `scripts/build.ts` copies `ghostty-vt-small.wasm` to `dist/`. The server serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`, resolved from `dist/` or `src/client/vendor/ghostty-vt/` like `/ghostty-vt.wasm` (ADR 041 step 1); like that wasm, the server does not start without it. The client fetches only the selected engine's wasm. -**Selection.** `src/client/engine.ts` exports `DEFAULT_ENGINE = 'coder'`, compiled into `client-browser.js`. `?engine=coder` or `?engine=official` in the page URL overrides it for that tab; any other value uses the default. Nothing is stored, and a reload keeps whatever the URL says. +**Selection.** `src/client/engine.ts` exports `DEFAULT_ENGINE = 'coder'`, compiled into `client-browser.js`. `?engine=coder` or `?engine=official` in the page URL overrides it for that tab; any other value uses the default. Nothing is stored, and a reload keeps whatever the URL says. (Removed by ADR 047: the client always loads the official engine.) **Verification, offline.** `ghostty-vt.test.ts` (part of `bun test`) checks the committed wasm against its sidecar (size and sha256) and verifies the `.minisig` with `node:crypto`: the Ed25519 signature over the wasm's BLAKE2b-512 hash and the global signature over the trusted comment, both against Ghostty's key from `PACKAGING.md` (`RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV`, key id `F5787DDB42320225`). The key is a constant in the test, and the test also asserts that the sidecar records the same key. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar. Neither step uses the network. `scripts/update-ghostty-vt.ts --commit ` is the only networked step: it downloads from `tip.files.ghostty.org//` and the GitHub `tip` release, checks that both give the same sha256 and that `tip` points at `` before and after the download, verifies the signature, and writes the wasm, `.minisig`, sidecar and Ghostty's `LICENSE` at that commit. `--verify` re-runs the offline check. The minisign verifier is exported from the script and imported by the test, so the script and the test check the same way; the test holds its own copy of the key. @@ -130,7 +132,7 @@ Every existing read keeps the Coder engine's coordinates on both engines, so the The captures are re-recorded from a clean `HOME` before they are committed as fixtures, so no local paths are committed. -**Rollback.** Before the flip, the official engine is reached only through `?engine=official`, and reverting the commit removes it. After the flip, `?engine=coder` falls back per tab, and setting `DEFAULT_ENGINE` back is a one-line change. The Coder engine stays for at least one release after the flip (ADR 041 step 5). +**Rollback.** Before the flip, the official engine is reached only through `?engine=official`, and reverting the commit removes it. After the flip, `?engine=coder` falls back per tab, and setting `DEFAULT_ENGINE` back is a one-line change. The Coder engine stays for at least one release after the flip (ADR 041 step 5). (Changed by ADR 047: the Coder engine was deleted without that release; rollback is git revert.) ### Considered Options @@ -154,10 +156,23 @@ The captures are re-recorded from a clean `HOME` before they are committed as fi ## Acceptance record -Not yet run. Filled in by the change that flips `DEFAULT_ENGINE` to `official`. +Recorded 2026-10-13 for Ghostty `05dcf887` (ReleaseSmall, 780,049 B), in the change that makes the official engine the only engine ([ADR 047](047.client.official-engine-only.md)). That change deletes the Coder engine instead of flipping `DEFAULT_ENGINE`. + +| Area | Result | +|---|---| +| Browsers | The user checked the official engine by hand in Chrome, Firefox and Safari on 2026-10-13 and found nothing wrong. It was a quick check, and the browser versions were not recorded. Headless Chrome 155 ran the official engine through ADR 043's browser checks (45 states, including typing, selection, output while scrolled, alt screen and resize) and ADR 045's end-to-end wheel runs. No separate headless Firefox run beyond this ADR's throughput measurement is recorded. | +| Resize/reflow | Bun, on the wasm: `official-terminal.test.ts` reflows wrapped lines, sends one mode 2048 report per real size change and makes one `ghostty_terminal_resize` per zoom or DPR change, none for repeated fits. `engine-parity.test.ts` matched Coder on a resize while scrolled. In a browser, only ADR 043's resize state and the user's check. | +| Round trips | Bun, on the wasm: DA1 `ESC[?62;22c`, DA2 `ESC[>1;10;0c`, DSR `5n`/`6n`, XTWINOPS `14/16/18 t` in device pixels, DECRQM 2027 set, title and bell once each, and no reply to kitty `CSI ? u` before or after `CSI > 31 u` (`official-terminal.test.ts`). DA3, `19 t` and OSC 4/10/11 replies were probed in Bun when this ADR was written (Context), not asserted by a test. No end-to-end round-trip run through a browser and PTY is recorded. | +| #180 | 0 stale rows over 300 repetitions in Bun (`engine-parity.test.ts`). Not repeated in Chrome. | +| Parity | ls, vim, nvim, less, top, SGR/256/truecolor, Unicode and `seq 5000` captures matched the Coder engine cell for cell with 512-byte and 1-byte writes, the Hindi conjunct being the listed difference (`engine-parity.test.ts`, until ADR 047 retired the Coder reference). | +| Scrollback | Passed by a wide margin after ADR 046: at the default config the official engine keeps about 9,682 lines at 120 columns, against the Coder engine's few hundred rows. | +| Scrolled view | `engine-parity.test.ts` matched the Coder engine on scrolled reads at several offsets, writes and resizes while scrolled, page reads after in-place writes, and a grapheme walk. In the browser, ADR 043's checks compared each scrolled frame with a full redraw on the official engine. | +| Selection copy | Waived. The 10,000-row timing against the Coder engine was not run. Page reads make a copy cost about N / rows page reads, and the user's check copied without problems. It cannot be measured once the Coder engine is gone. | +| Screenshots | Waived. No A/B pixel diff between engines was made. ADR 043 compared official-engine screenshots with full redraws only. | ## Related Decisions - [ADR 012 — Static asset build](012.client.static-assets.md) - [ADR 032 — Kitty graphics via overlay canvas](032.client.kitty-graphics-overlay.md) - [ADR 041 — Own the terminal layer on official libghostty-vt](041.client.libghostty-vt.md) +- [ADR 047 — Official libghostty-vt as the only engine](047.client.official-engine-only.md) diff --git a/docs/adrs/046.client.scrollback-lines.md b/docs/adrs/046.client.scrollback-lines.md index f68b5ef..71caff0 100644 --- a/docs/adrs/046.client.scrollback-lines.md +++ b/docs/adrs/046.client.scrollback-lines.md @@ -23,7 +23,7 @@ A new client config key, `scrollbackLines`, sets the terminal's history in lines - **`scrollbackLines`:** number, default `10000`. `0` means no line limit. Other values are floored to whole lines; negative or non-finite values are ignored (default). Re-read on tab reload, like the other terminal appearance keys, and served by `/api/config`. - **Official engine:** `SCROLLBACK_MAX_LINES` is `scrollbackLines` (a NULL pointer for `0`), and `SCROLLBACK_MAX_BYTES` is always 32 MiB, so memory stays bounded with no line limit. -- **Coder engine:** unchanged. It keeps the derived byte limit until it is deleted (ADR 041 step 5). +- **Coder engine:** unchanged. It keeps the derived byte limit until it is deleted (ADR 041 step 5). (Deleted in [ADR 047](047.client.official-engine-only.md); the client no longer derives any limit from `scrollback`.) - **`scrollback`** keeps its meaning, the server's replay buffer in characters; only the Coder engine still derives its limit from it. ## Consequences diff --git a/docs/adrs/047.client.official-engine-only.md b/docs/adrs/047.client.official-engine-only.md new file mode 100644 index 0000000..c4b6b83 --- /dev/null +++ b/docs/adrs/047.client.official-engine-only.md @@ -0,0 +1,86 @@ +# ADR 047: client — Official libghostty-vt as the only engine + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-13 + +--- + +## Context + +ADR 042 added Ghostty's official libghostty-vt wasm as a second engine behind `?engine=official`, with the Coder engine (`ghostty-web`'s patched December 2025 wasm) as the default until its acceptance matrix was recorded. ADR 041 step 5 then deletes the Coder engine and the workarounds the official API makes unnecessary, "after one release with `official` as the default", and its rollback for step 3 keeps `?engine=coder` as a per-tab fallback for that release. + +Since ADR 042, every client change has been built and checked on the official engine first: page reads and the scrolled view (ADR 042), scroll copy and overscan (ADR 043, 45 browser states on the official engine), smooth wheel scrolling (ADR 045) and scrollback in lines (ADR 046, official only). The Coder engine is now the one that gets less testing, and it keeps a few hundred rows of history where the official engine keeps 10,000 lines. + +On 2026-10-13 the user ran the official engine by hand in Chrome, Firefox and Safari and found nothing wrong. The checks on record are in ADR 042's acceptance record. + +Keeping the Coder engine costs: + +- about 780 lines in `ghostty.ts` (`CoderGhostty`, `CoderKeyEncoder`, `CoderTerminal`), a 423,939-byte wasm with its sidecar, licence and test, a served route, and an engine switch; +- interface members and branches that exist only because there are two engines: `answersSizeQueries`, the stream scan for BEL and OSC 0/2, `colorsSet`, `scrollbackLimit` and the client's `scrollback / 80` derivation, and the `kitty.ts` size replies; +- parity tests that use the Coder engine as their reference. + +## Decision + +Make the official libghostty-vt wasm the only engine now, and delete the Coder engine, instead of first shipping a release with `official` as the default (ADR 041 step 5's rule). The per-tab `?engine=coder` fallback goes with it: a regression found later is fixed forward on the official engine, or the deletion is reverted with git. In return, every path the client runs is the one that gets tested. + +**Deleted now.** + +- **Engine selection.** `src/client/engine.ts` and its test. `index.ts` loads `/ghostty-vt-small.wasm` directly. `?engine=` in old URLs is ignored. +- **The Coder engine in `ghostty.ts`.** `CoderGhostty`, `CoderKeyEncoder`, `CoderTerminal`, the `EngineName` type, the engine parameter of `Ghostty.load`, and the abstract base with two subclasses. `Ghostty.load(path)` returns the official engine. Its default path, used by the vendored `init()`, points at the official wasm. The vendored `index.ts` stops exporting `CoderTerminal` and `EngineName`. The `GhosttyTerminal` and `KeyEncoder` interfaces stay: the renderer, selection, buffer, input handler and test fakes are typed against them. +- **The Coder wasm.** `ghostty-web/ghostty-vt.wasm`, its sidecar `ghostty-vt.wasm.json`, `LICENSE.ghostty` (Ghostty's licence for that wasm only) and `ghostty-web.test.ts`. `scripts/build.ts` stops copying the wasm and drops its `THIRD_PARTY_LICENSES` block; the ghostty-web entry now covers only the TypeScript bundled into `client-browser.js`. `ghostty-web/LICENSE` (Coder, MIT) stays, because the vendored TS is still Coder's code. +- **Serving `/ghostty-vt.wasm`.** `src/server/static.ts` resolves only `ghostty-vt-small.wasm`, so `GET /ghostty-vt.wasm` returns 404. The route test serves `/ghostty-vt-small.wasm` byte for byte against its sidecar, and the packed-dist test stops copying the Coder wasm. A `dist/ghostty-vt.wasm` left by an older build does no harm, because nothing serves it. +- **Coder-only types** in the vendored `types.ts`: `GhosttyWasmExports`, `GHOSTTY_CONFIG_SIZE`, `CURSOR_STRUCT_SIZE`, `COLORS_STRUCT_SIZE` and `TerminalHandle`. Upstream leftovers that were unused before this change (`SgrAttribute*`, `RGBColor`, `TerminalConfig`, `TerminalMode`) stay. They are not engine code, and removing them would only churn the vendored file. +- **Two-engine interface members.** `answersSizeQueries` goes. `setViewportOffset` becomes required on `GhosttyTerminal`. `readEffects`, `setCellPixelSize` and the pixel arguments of `resize` stay, because the official engine uses them; their "no-op on Coder" comments go. Comments that say rows are "in the Coder engine's coordinates" now say active-screen rows, with `getScrollbackLine(i)` counted from the oldest row. +- **Stream-scanned bell and title.** `writeInternal` no longer scans output for BEL and OSC 0/2, and `checkForTitleChange` goes. Bell and title always come from `readEffects` (Ghostty's `BELL` and `TITLE_CHANGED`). +- **Scrollback derived from `scrollback`.** The client stops passing `Math.ceil(config.scrollback / 80)`, and `GhosttyTerminalConfig.scrollbackLimit` and the derived byte limit go. The binding always sets `SCROLLBACK_MAX_LINES` from `scrollbackLines` (default 10,000; NULL for 0) and `SCROLLBACK_MAX_BYTES` to 32 MiB. A `Terminal` built without config now keeps 10,000 lines instead of the wasm's 10,000-byte default. `/api/config` still serves `scrollback`, the server's replay buffer (ADR 046), and the client no longer reads it. +- **`colorsSet` and 0 as "unset".** The mask was needed because the Coder config struct encoded an unset colour as 0, which is also `#000000`. Theme colours become `number | undefined`, and undefined means unset. The 0xcccccc / 0x000000 fallback for a missing foreground or background stays, because Ghostty applies the default colours only when both are set (ADR 042). It is now described as webtty's default. +- **`kitty.ts` size replies** (ADR 041 step 5, "size reports answered by Ghostty through `OPT_SIZE`"). On the official engine they were already suppressed (`answersSizeQueries`), and Ghostty answers XTWINOPS 14/16/18/19t and mode 2048 from `OPT_SIZE` with the same device-pixel cell size. `SIZE_QUERY`, `sizeQueries`, `TerminalSize`, `sizeReply`, `ControlScan.queries` and `graphics.ts`'s `terminalSize()` go. `ControlScanner` (clear detection), `deviceCellSize` and `axisScale` (frame scaling) stay. + +**Tests rewritten, not dropped.** + +- `engine-parity.test.ts` used the Coder engine as the reference for the official engine's scrolled view, page cache and generation counter. Those tests now compare against a fresh official terminal that was fed the same bytes and is read at offset 0, so stale pages and lost dirty rows still fail them. The #180 stress payload test stays. The PTY captures in `fixtures/` stay as a chunking check: each capture written in 512-byte and in 1-byte writes must give the same screen. +- `renderer.test.ts` runs its engine-parameterised tests on the official wasm only, and its single Coder-wasm test moves to the official wasm. +- `official-terminal.test.ts` loads through the new `Ghostty.load(path)`. The `scrollbackLimit` cases move to `scrollbackLines`, and the test that contrasts the old byte limit goes. +- A `terminal.test.ts` case checks that a title and a bell reported by `readEffects` reach `onTitleChange` and `onBell`. + +**Workaround replacements (ADR 041 step 5).** + +| Item | Now or later | Why | +|---|---|---| +| Coder wasm and sidecar | Now | Above. | +| Size reports through `OPT_SIZE` | Now | Already what runs on the official engine; deleting the `kitty.ts` replies removes dead code. | +| Official key encoder | Done | `OfficialKeyEncoder` already runs on `ghostty_key_encoder_*`. The name translation from ghostty-web's `Key` enum stays (ADR 041: layouts and enums come from `ghostty_type_json`). | +| Cursor style from the render state | Follow-up | Possible with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, but the renderer would take shape and blink from `getCursor()` each frame. Hollow block needs mapping, and Ghostty's blink also follows mode 12, which changes behaviour for apps. The DECSCUSR scan in `terminal.ts` works and stays until an ADR 013 update with wasm tests and a vim/nvim check. | +| Official mouse encoder | Follow-up | The TS encoders carry webtty's fixes (hover Cb=35, per-cell motion, release outside) and ADR 045's wheel pacing. Replacing them changes behaviour that ADR 017 and 045 settled, and needs its own ADR and a vim/htop/tmux/less check in three browsers. | +| `terminal_paste` / paste encoding | Follow-up | Ghostty brackets every paste while mode 2004 is on and filters unsafe bytes. ADR 014 (Ctrl+V for images) and ADR 037 (bracket multi-line only) decided otherwise. A change is a product decision for its own ADR. | +| Theme colours from the render state | Follow-up | The renderer, edge strips, padding and IME composer paint from the static theme, so OSC 10/11/12 and DECSCNM change cell colours but not the canvas clear or cursor colour. Fixing that touches several components and ADR 036. | + +Kitty keyboard flags (ADR 042, Consequences) remain future work as before. + +**Rollback.** Git revert of the deletion commits. There is no per-tab fallback and no build-time switch. + +## Consequences + +- One engine: about 780 lines of `ghostty.ts`, the Coder wasm (423,939 B), its sidecar, licence and test, and `engine.ts` are gone. The repo carries only the official wasm pin. +- `/ghostty-vt.wasm` returns 404, and `?engine=coder` no longer selects anything. Every tab runs the official engine with 10,000 lines of history by default, and gets the replies ADR 042 lists (DA3, XTWINOPS 19t, mode 2048, OSC 4/10/11, DECRQM, XTVERSION). +- A regression found in a browser the user did not check cannot be avoided per tab; it is fixed forward or reverted. +- The client config type drops `scrollback`; the server and `/api/config` are unchanged, so no config migrates. +- The `kitty.ts` size replies are gone, and Ghostty is the only source of size reports. +- The cursor-style scan, the TS mouse and wheel encoders, `paste.ts` and the static theme stay. Each is now a single-engine workaround with its own follow-up (above), not a parity requirement. +- `GhosttyTerminal` and `KeyEncoder` stay interfaces with one implementation, so test fakes keep working, and a later engine change would not touch the renderer. + +## Considered Options + +- **Delete the Coder engine now (chosen).** Less code and one tested path, in exchange for the per-tab fallback. +- **Flip the default and keep the Coder engine for a release (ADR 041 step 5's plan).** Keeps `?engine=coder` as a fallback, but every change in that release would have to keep both engines working and their parity tests passing. The official engine was already the only one carrying new work (ADR 043–046). +- **Flip the default and delete the Coder engine, but keep the `kitty.ts` size replies and the stream scan "just in case".** Both are dead code on the official engine. Keeping them only hides what the code depends on. +- **Do every ADR 041 step 5 replacement in the same change.** Cursor style, mouse, paste and theme colours each change what apps or users see, so each needs its own decision and browser checks. Deleting the Coder engine changes none of them. + +## Related Decisions + +- [ADR 041 — Own the terminal layer on official libghostty-vt](041.client.libghostty-vt.md) +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md) +- [ADR 046 — Scrollback in lines](046.client.scrollback-lines.md) +- [ADR 013 — DECSCUSR cursor style](013.client.cursor-style.md) +- [ADR 032 — Kitty graphics via overlay canvas](032.client.kitty-graphics-overlay.md) diff --git a/docs/specs/client.md b/docs/specs/client.md index 00d6c54..dfa03c7 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -195,9 +195,9 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (the renderer's overscan canvas moved by the offset, ADR 043; the one-row strip is gone); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | -| Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | -| Official engine by default | The default flips to `official` once ADR 042's acceptance matrix (Chrome, Firefox, Safari; resize/reflow; query round trips; #180; parity) is recorded; `?engine=coder` falls back to the old engine until it is removed | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ⬜ | -| Scrollback in lines | On the official engine, terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | +| Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default (superseded by the row below) | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | +| Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders and theme colours from render state remain follow-ups | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ⬜ | +| Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 25dade3..7fd7891 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -1,6 +1,6 @@ # SPEC: Config -**Last Updated:** 2026-10-12 +**Last Updated:** 2026-10-13 --- @@ -107,8 +107,8 @@ All keys are optional — omit any key to use the default value. | `shell` | string | `$SHELL` / `%COMSPEC%` | Shell for new sessions | | `term` | string | `"xterm-256color"` | `$TERM` env var passed to the shell. Fixed to `xterm-256color` — the PTY child talks to webtty, not to the parent terminal. See [ADR 016](../adrs/016.config.term-default.md). | | `colorTerm` | string | `"truecolor"` | `$COLORTERM` env var passed to the shell | -| `scrollback` | number | `262144` | PTY history buffer in bytes; used for server-side replay on reload/reconnect | -| `scrollbackLines` | number | `10000` | Terminal history in lines on the official engine (`?engine=official`); `0` = no line limit. History is also capped at 32 MiB, so wide terminals with many styles can keep fewer lines, and Ghostty prunes whole pages, so it keeps a little under the limit (about 9,700 of 10,000). Fractions are floored; negative values are ignored. The Coder engine ignores it and keeps a few hundred rows derived from `scrollback`. See [ADR 046](../adrs/046.client.scrollback-lines.md). | +| `scrollback` | number | `262144` | PTY history buffer in bytes; used for server-side replay on reload/reconnect. It does not set the terminal's history; that is `scrollbackLines` | +| `scrollbackLines` | number | `10000` | Terminal history in lines; `0` = no line limit. History is also capped at 32 MiB, so wide terminals with many styles can keep fewer lines, and Ghostty prunes whole pages, so it keeps a little under the limit (about 9,700 of 10,000). Fractions are floored; negative values are ignored. The client derives nothing from `scrollback`. See [ADR 046](../adrs/046.client.scrollback-lines.md) and [ADR 047](../adrs/047.client.official-engine-only.md). | | `cols` | number | `80` | Initial terminal width in columns | | `rows` | number | `24` | Initial terminal height in rows | | `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR — this is the startup default only. | @@ -216,6 +216,6 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | -| Scrollback in lines | `scrollbackLines` (default `10000`, `0` = no line limit) sets terminal history in lines under a 32 MiB ceiling on the official engine; `scrollback` stays the server replay buffer, so existing configs keep their meaning | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | +| Scrollback in lines | `scrollbackLines` (default `10000`, `0` = no line limit) sets terminal history in lines under a 32 MiB ceiling; `scrollback` stays the server replay buffer, so existing configs keep their meaning | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | | Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | | Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ✅ | diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index 96f3418..6b811e8 100644 --- a/docs/specs/kitty-graphics.md +++ b/docs/specs/kitty-graphics.md @@ -129,8 +129,8 @@ src/client/ - **Placements stop following the text once the scrollback is full.** A placement is anchored to the number of scrollback lines plus its row when it was made. - That number stops growing when the scrollback reaches its limit (3277 lines - with the default `scrollback`), while output keeps pushing rows off the top, + That number stops growing when the scrollback reaches its limit (about 10,000 + lines with the default `scrollbackLines`, ADR 046), while output keeps pushing rows off the top, so from then on a main-screen placement stays at its screen row instead of scrolling away with its text. The emulator exposes no count of rows it has evicted, so the client cannot correct for it. The alternate screen has no From 66f49f85d996234a08f9bb768f5e51f114673560 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:25:31 -0400 Subject: [PATCH 038/295] refactor(client): delete the Coder engine Make Ghostty's official libghostty-vt wasm the only engine (ADR 047). - Delete engine.ts and ?engine; index.ts loads /ghostty-vt-small.wasm. - Delete CoderGhostty, CoderKeyEncoder and CoderTerminal from ghostty.ts. Ghostty is now one concrete class; load(path) takes no engine and its default path is the official wasm. - Delete the Coder wasm, its sidecar, LICENSE.ghostty and their test. The build no longer copies the wasm or adds its licence block, and the server no longer serves /ghostty-vt.wasm. - Delete the Coder-only types (GhosttyWasmExports, struct sizes, TerminalHandle). - Bell and title always come from readEffects; the stream scan goes. - setViewportOffset is required on GhosttyTerminal. - engine-parity.test.ts becomes official-replay.test.ts. Its scrolled-view tests now compare against a fresh terminal fed the same input, and the PTY captures check 1-byte against 512-byte writes. renderer.test.ts runs on the official wasm only. --- scripts/build.ts | 26 +- src/client/engine.test.ts | 28 - src/client/engine.ts | 23 - src/client/index.ts | 9 +- src/client/vendor/engine-parity.test.ts | 522 --------- src/client/vendor/ghostty-web.test.ts | 22 - src/client/vendor/ghostty-web/LICENSE.ghostty | 21 - src/client/vendor/ghostty-web/ghostty-vt.wasm | Bin 423939 -> 0 bytes .../vendor/ghostty-web/ghostty-vt.wasm.json | 21 - src/client/vendor/ghostty-web/ghostty.ts | 1011 ++--------------- src/client/vendor/ghostty-web/index.ts | 6 +- src/client/vendor/ghostty-web/renderer.ts | 6 +- src/client/vendor/ghostty-web/terminal.ts | 61 +- src/client/vendor/ghostty-web/types.ts | 162 +-- src/client/vendor/official-replay.test.ts | 530 +++++++++ src/client/vendor/official-terminal.test.ts | 19 +- src/client/vendor/renderer.test.ts | 993 ++++++++-------- src/client/vendor/terminal.test.ts | 33 + src/server/routes.test.ts | 22 +- src/server/routes.ts | 2 +- src/server/static.test.ts | 47 +- src/server/static.ts | 9 +- src/version.test.ts | 6 +- 23 files changed, 1213 insertions(+), 2366 deletions(-) delete mode 100644 src/client/engine.test.ts delete mode 100644 src/client/engine.ts delete mode 100644 src/client/vendor/engine-parity.test.ts delete mode 100644 src/client/vendor/ghostty-web.test.ts delete mode 100644 src/client/vendor/ghostty-web/LICENSE.ghostty delete mode 100755 src/client/vendor/ghostty-web/ghostty-vt.wasm delete mode 100644 src/client/vendor/ghostty-web/ghostty-vt.wasm.json create mode 100644 src/client/vendor/official-replay.test.ts diff --git a/scripts/build.ts b/scripts/build.ts index ffa4bee..ea7cc30 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -36,15 +36,10 @@ if (!clientResult.success) { fs.copyFileSync(path.resolve('./src/client/client.html'), path.resolve('./dist/client.html')); fs.copyFileSync(path.resolve('./src/client/index.css'), path.resolve('./dist/client.css')); -// The vendored ghostty-web TypeScript is bundled into client-browser.js; its wasm -// ships next to it in dist/ so the published package needs nothing outside dist/. -fs.copyFileSync( - path.resolve('./src/client/vendor/ghostty-web/ghostty-vt.wasm'), - path.resolve('./dist/ghostty-vt.wasm'), -); - -// The official libghostty-vt wasm (ADR 042) ships the same way. Nothing is fetched: the -// committed wasm is copied only if it matches its sidecar's size and sha256. +// The vendored ghostty-web TypeScript is bundled into client-browser.js; the official +// libghostty-vt wasm it runs (ADR 042, ADR 047) ships next to it in dist/ so the +// published package needs nothing outside dist/. Nothing is fetched: the committed +// wasm is copied only if it matches its sidecar's size and sha256. const officialDir = './src/client/vendor/ghostty-vt'; const officialSidecar = JSON.parse( fs.readFileSync(path.resolve(officialDir, 'ghostty-vt-small.wasm.json'), 'utf8'), @@ -62,25 +57,18 @@ if (officialWasm.byteLength !== officialSidecar.size || officialSha256 !== offic fs.writeFileSync(path.resolve('./dist', officialSidecar.file), officialWasm); // The vendored TS has no licence headers, so nothing survives minification; the -// MIT notices for the code in client-browser.js and both wasm files ship here instead. +// MIT notices for the code in client-browser.js and the wasm ship here instead. const vendorDir = './src/client/vendor/ghostty-web'; fs.writeFileSync( path.resolve('./dist/THIRD_PARTY_LICENSES'), [ - 'ghostty-web (bundled in client-browser.js; ghostty-vt.wasm build and patch)', + 'ghostty-web (TypeScript bundled in client-browser.js)', 'https://github.com/coder/ghostty-web', '', fs.readFileSync(path.resolve(vendorDir, 'LICENSE'), 'utf8').trim(), '', '---', '', - 'Ghostty (compiled into ghostty-vt.wasm)', - 'https://github.com/ghostty-org/ghostty', - '', - fs.readFileSync(path.resolve(vendorDir, 'LICENSE.ghostty'), 'utf8').trim(), - '', - '---', - '', `Ghostty libghostty-vt (${officialSidecar.file}, unmodified official build)`, 'https://github.com/ghostty-org/ghostty', '', @@ -96,5 +84,5 @@ fs.writeFileSync( ].join('\n'), ); -const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 5; +const totalFiles = serverResult.outputs.length + clientResult.outputs.length + 4; console.log(`✓ Build complete (${totalFiles} files)`); diff --git a/src/client/engine.test.ts b/src/client/engine.test.ts deleted file mode 100644 index 3104f13..0000000 --- a/src/client/engine.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { DEFAULT_ENGINE, engineWasmPath, selectEngine } from './engine'; - -describe('selectEngine', () => { - test('defaults to the Coder engine until ADR 042 flips it', () => { - expect(DEFAULT_ENGINE).toBe('coder'); - expect(selectEngine('')).toBe(DEFAULT_ENGINE); - }); - - test('?engine=official and ?engine=coder override the default', () => { - expect(selectEngine('?engine=official')).toBe('official'); - expect(selectEngine('?engine=coder')).toBe('coder'); - expect(selectEngine('?foo=1&engine=official')).toBe('official'); - }); - - test('any other value uses the default', () => { - expect(selectEngine('?engine=')).toBe(DEFAULT_ENGINE); - expect(selectEngine('?engine=Official')).toBe(DEFAULT_ENGINE); - expect(selectEngine('?engine=ghostty')).toBe(DEFAULT_ENGINE); - }); -}); - -describe('engineWasmPath', () => { - test('each engine loads the wasm the server serves for it', () => { - expect(engineWasmPath('coder')).toBe('/ghostty-vt.wasm'); - expect(engineWasmPath('official')).toBe('/ghostty-vt-small.wasm'); - }); -}); diff --git a/src/client/engine.ts b/src/client/engine.ts deleted file mode 100644 index 4172c96..0000000 --- a/src/client/engine.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Which terminal engine a tab runs (ADR 042). - * - * The default is compiled into `client-browser.js`; `?engine=coder` or - * `?engine=official` in the page URL overrides it for that tab. Any other value - * uses the default. Nothing is stored, so a reload keeps whatever the URL says. - */ - -import type { EngineName } from './vendor/ghostty-web'; - -/** The engine a tab runs without `?engine`. Flips to `official` after ADR 042's matrix. */ -export const DEFAULT_ENGINE: EngineName = 'coder'; - -/** The engine for a page whose URL query string is `search` (e.g. `?engine=official`). */ -export function selectEngine(search: string): EngineName { - const engine = new URLSearchParams(search).get('engine'); - return engine === 'coder' || engine === 'official' ? engine : DEFAULT_ENGINE; -} - -/** Where the server serves each engine's wasm; the client fetches only the selected one. */ -export function engineWasmPath(engine: EngineName): string { - return engine === 'official' ? '/ghostty-vt-small.wasm' : '/ghostty-vt.wasm'; -} diff --git a/src/client/index.ts b/src/client/index.ts index 94cdf36..ceeb7b8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,5 @@ import { Composer } from './compose'; import { EdgeExtender } from './edge'; -import { engineWasmPath, selectEngine } from './engine'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { encodePaste } from './paste'; @@ -66,11 +65,9 @@ document.title = `${sessionId} | webtty`; // The vendored ghostty-web is bundled into /dist/client-browser.js, so its default // wasm lookup (relative to its own module URL) would depend on where the bundle sits. -// Load the wasm from the path the server serves it at instead (ADR 041). The engine -// is the build-time default or ?engine=coder|official, and only its wasm is fetched -// (ADR 042). -const engine = selectEngine(window.location.search); -const ghostty = await Ghostty.load(engineWasmPath(engine), engine); +// Load the wasm from the path the server serves it at instead (ADR 041). It is +// Ghostty's official libghostty-vt wasm, the only engine (ADR 042, ADR 047). +const ghostty = await Ghostty.load('/ghostty-vt-small.wasm'); // Translate webtty's theme keys to ghostty-web's names and keep `padding`, a webtty // setting and not a terminal colour, out of the theme it receives (ADR 036). diff --git a/src/client/vendor/engine-parity.test.ts b/src/client/vendor/engine-parity.test.ts deleted file mode 100644 index 0f577ca..0000000 --- a/src/client/vendor/engine-parity.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; - -// Both engines (ADR 042) on the same input, cell for cell. -// -// fixtures/*.bin are PTY captures at 120x40 (TERM=xterm-256color, LANG=en_US.UTF-8), -// recorded from an empty HOME in a scratch directory holding only a four-line -// sample.c: vim and nvim editing sample.c (`vim -u NONE`, `nvim --clean`), `man ls` -// in less, `ls -laG /usr/bin | head -80`, `top` showing one `sleep` process, -// `seq 5000`, an SGR/256-colour/truecolor test and a Unicode test. - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const CODER_WASM = path.join(HERE, 'ghostty-web/ghostty-vt.wasm'); -const OFFICIAL_WASM = path.join(HERE, 'ghostty-vt/ghostty-vt-small.wasm'); -const FIXTURES = path.join(HERE, 'fixtures'); -const COLS = 120; -const ROWS = 40; -const THEME = { scrollbackLimit: 10000, fgColor: 0xd4d4d4, bgColor: 0x1e1e1e }; - -interface Cell { - text: string; - fg: number; - bg: number; - flags: number; - width: number; -} - -type Row = Cell[]; - -interface Engines { - coder: GhosttyTerminal; - official: GhosttyTerminal; -} - -/** - * A fresh instance of each engine. The Coder wasm can crash on a terminal created - * after another one that held multi-codepoint graphemes was freed (ADR 041), so - * every Coder terminal gets its own instance and is never freed. - */ -async function engines(cols = COLS, rows = ROWS, config: object = THEME): Promise { - const coder = (await Ghostty.load(CODER_WASM)).createTerminal(cols, rows, config); - const official = (await Ghostty.load(OFFICIAL_WASM, 'official')).createTerminal( - cols, - rows, - config, - ); - return { coder, official }; -} - -function toRow(line: ReturnType, grapheme: (x: number) => string): Row { - return (line ?? []).map((c, x) => ({ - text: c.grapheme_len > 0 ? grapheme(x) : c.codepoint ? String.fromCodePoint(c.codepoint) : '', - fg: (c.fg_r << 16) | (c.fg_g << 8) | c.fg_b, - bg: (c.bg_r << 16) | (c.bg_g << 8) | c.bg_b, - flags: c.flags, - width: c.width, - })); -} - -/** The active screen as the renderer reads it: one getViewport per frame. */ -function screen(t: GhosttyTerminal): { grid: Row[]; cursor: { x: number; y: number } } { - t.update(); - const { cols, rows } = t.getDimensions(); - const cells = t.getViewport(); - const grid: Row[] = []; - for (let y = 0; y < rows; y++) { - grid.push(toRow(cells.slice(y * cols, (y + 1) * cols), (x) => t.getGraphemeString(y, x))); - } - const { x, y } = t.getCursor(); - t.markClean(); - return { grid, cursor: { x, y } }; -} - -const activeRow = (t: GhosttyTerminal, y: number) => - toRow(t.getLine(y), (x) => t.getGraphemeString(y, x)); -const scrollbackRow = (t: GhosttyTerminal, i: number) => - toRow(t.getScrollbackLine(i), (x) => t.getScrollbackGraphemeString(i, x)); - -/** The rows a renderer shows at viewportY = k, the way renderer.ts picks them. */ -function shownRows(t: GhosttyTerminal, k: number): Row[] { - t.setViewportOffset?.(k); - const sb = t.getScrollbackLength(); - const rows: Row[] = []; - for (let y = 0; y < t.rows; y++) { - rows.push(y < k ? scrollbackRow(t, sb - k + y) : activeRow(t, y - k)); - } - return rows; -} - -const text = (row: Row) => - row - .map((c) => (c.width === 0 ? '' : c.text || ' ')) - .join('') - .trimEnd(); - -interface Difference { - y: number; - x: number; - field: keyof Cell; - coder: string | number; - official: string | number; -} - -function diffRows(coder: Row[], official: Row[]): Difference[] { - const out: Difference[] = []; - const rows = Math.max(coder.length, official.length); - for (let y = 0; y < rows; y++) { - const a = coder[y] ?? []; - const b = official[y] ?? []; - for (let x = 0; x < Math.max(a.length, b.length); x++) { - for (const field of ['text', 'fg', 'bg', 'flags', 'width'] as const) { - if (a[x]?.[field] !== b[x]?.[field]) { - out.push({ y, x, field, coder: a[x]?.[field] ?? '-', official: b[x]?.[field] ?? '-' }); - } - } - } - } - return out; -} - -/** - * The one listed difference (ADR 041, ADR 042): Ghostty at 05dcf887 clusters the - * Hindi conjunct स्ते (U+0938 U+094D U+0924 U+0947) as one width-2 cell, where the - * Coder wasm keeps two width-1 cells, स् and ते. Both take two columns, so nothing - * after it moves. - */ -function isHindiConjunct(d: Difference, coder: Row[], official: Row[]): boolean { - const a = coder[d.y]; - const b = official[d.y]; - const lead = (x: number) => - a[x]?.text === 'स्' && a[x]?.width === 1 && b[x]?.text === 'स्ते' && b[x]?.width === 2; - const tail = (x: number) => - a[x]?.text === 'ते' && a[x]?.width === 1 && b[x]?.text === '' && b[x]?.width === 0; - return (d.field === 'text' || d.field === 'width') && (lead(d.x) || (lead(d.x - 1) && tail(d.x))); -} - -interface ParityResult { - checkpoints: number; - cells: number; - unexpected: Difference[]; - known: number; - cursorDiffs: number; -} - -/** Feeds a capture to both engines in `chunk`-byte writes, comparing every 512 bytes and at the end. */ -async function replay(name: string, chunk: number): Promise { - const data = new Uint8Array(fs.readFileSync(path.join(FIXTURES, `${name}.bin`))); - const { coder, official } = await engines(); - const result: ParityResult = { - checkpoints: 0, - cells: 0, - unexpected: [], - known: 0, - cursorDiffs: 0, - }; - const compare = () => { - const a = screen(coder); - const b = screen(official); - result.checkpoints++; - result.cells += COLS * ROWS; - for (const d of diffRows(a.grid, b.grid)) { - if (isHindiConjunct(d, a.grid, b.grid)) result.known++; - else if (result.unexpected.length < 10) result.unexpected.push(d); - } - if (a.cursor.x !== b.cursor.x || a.cursor.y !== b.cursor.y) result.cursorDiffs++; - }; - for (let i = 0; i < data.length; i += chunk) { - const piece = data.subarray(i, Math.min(i + chunk, data.length)); - coder.write(piece); - official.write(piece); - if ((i + chunk) % 512 === 0) compare(); - } - compare(); - official.free(); - return result; -} - -const CAPTURES = ['ls', 'vim', 'nvim', 'less', 'top', 'sgr', 'uni', 'seq']; - -describe('engine parity on PTY captures at 120x40', () => { - for (const name of CAPTURES) { - for (const chunk of [512, 1]) { - test(`${name}, ${chunk}-byte writes`, async () => { - const result = await replay(name, chunk); - expect(result.checkpoints).toBeGreaterThan(0); - expect(result.unexpected).toEqual([]); - expect(result.cursorDiffs).toBe(0); - // The Hindi conjunct is the one listed difference, and only uni.bin has it. - if (name === 'uni') expect(result.known).toBeGreaterThan(0); - else expect(result.known).toBe(0); - }); - } - } -}); - -describe('engine parity on soft wraps', () => { - test('isRowWrapped marks the rows that continue the row above', async () => { - const { coder, official } = await engines(10, 6); - const write = (data: string) => { - coder.write(data); - official.write(data); - }; - const wrapped = (t: GhosttyTerminal) => Array.from({ length: 6 }, (_, y) => t.isRowWrapped(y)); - write(`${'W'.repeat(25)}\r\nshort`); - expect(wrapped(coder)).toEqual([false, true, true, false, false, false]); - expect(wrapped(official)).toEqual(wrapped(coder)); - // A line filling the row exactly, with the cursor parked at the margin, does not wrap yet. - write(`\r\n${'X'.repeat(10)}`); - expect(wrapped(official)).toEqual(wrapped(coder)); - write('Y'); - expect(wrapped(official)).toEqual(wrapped(coder)); - official.free(); - }); -}); - -describe('engine parity on scrolled views', () => { - const lines = (from: number, to: number) => { - let s = ''; - for (let i = from; i <= to; i++) { - s += `\x1b[38;5;${i % 256}mline ${i}\x1b[0m ${'x'.repeat(i % 50)}\r\n`; - } - return s; - }; - // Unlimited scrollback on both, so both keep the same rows. - const config = { ...THEME, scrollbackLimit: 0 }; - - test('getLine, getScrollbackLine and the shown rows match at several offsets', async () => { - const { coder, official } = await engines(80, 24, config); - coder.write(lines(1, 300)); - official.write(lines(1, 300)); - expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); - const sb = coder.getScrollbackLength(); - for (const k of [0, 1, 5, 23, 24, 25, 100, sb]) { - expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); - for (const y of [0, 10, 23]) { - expect(diffRows([activeRow(coder, y)], [activeRow(official, y)])).toEqual([]); - } - } - official.setViewportOffset?.(40); - for (let i = 0; i < sb; i += 7) { - expect(diffRows([scrollbackRow(coder, i)], [scrollbackRow(official, i)])).toEqual([]); - } - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - official.free(); - }); - - test('a write while scrolled snaps to the bottom, and a stale offset moves back', async () => { - const { coder, official } = await engines(80, 24, config); - coder.write(lines(1, 200)); - official.write(lines(1, 200)); - shownRows(official, 30); - coder.write(lines(201, 210)); - official.write(lines(201, 210)); - expect(official.isRowDirty(0)).toBe(true); - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - // The awaitingEcho render passes the stale viewportY. - expect(diffRows(shownRows(coder, 30), shownRows(official, 30))).toEqual([]); - expect(diffRows(shownRows(coder, 0), shownRows(official, 0))).toEqual([]); - official.free(); - }); - - test('a resize while scrolled reflows the same rows', async () => { - const { coder, official } = await engines(80, 24, config); - coder.write(lines(1, 200)); - official.write(lines(1, 200)); - shownRows(official, 30); - // Shrinking rewraps the long lines on both engines. - coder.resize(60, 20); - official.resize(60, 20); - expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); - expect(diffRows(shownRows(coder, 30), shownRows(official, 30))).toEqual([]); - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - // Growing unwraps them again. Known difference: after growing, the Coder wasm's - // getScrollbackLine returns stale cells past the old width (e.g. "line 1 x" then - // garbage codepoints, or "line 142" with 48 x's instead of 42), so the official - // engine is checked against the text that was written instead. - shownRows(official, 30); - coder.resize(100, 30); - official.resize(100, 30); - expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); - const sb = official.getScrollbackLength(); - const written = (i: number) => `line ${i} ${'x'.repeat(i % 50)}`.trimEnd(); - const expected = Array.from({ length: 30 }, (_, y) => written(sb - 30 + y + 1)); - expect(shownRows(official, 30).map(text)).toEqual(expected); - expect(shownRows(coder, 30).map(text)).not.toEqual(expected); - for (let i = 0; i < sb; i++) expect(text(scrollbackRow(official, i))).toBe(written(i + 1)); - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - official.free(); - }); - - test('a page read between a write and the next render loses no dirty rows', async () => { - const { coder, official } = await engines(80, 24, config); - coder.write(lines(1, 100)); - official.write(lines(1, 100)); - screen(official); - coder.write('\x1b[5;3HCHANGED'); - official.write('\x1b[5;3HCHANGED'); - // A selection copy or buffer read above the viewport before the frame. - expect(diffRows([scrollbackRow(coder, 3)], [scrollbackRow(official, 3)])).toEqual([]); - expect(official.isRowDirty(4)).toBe(true); - expect(official.isRowDirty(10)).toBe(false); - expect(text(activeRow(official, 4))).toContain('CHANGED'); - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - official.free(); - }); - - test('stepping the offset through the screen keeps pages across moves, not writes', async () => { - const { coder, official } = await engines(80, 24, config); - coder.write(lines(1, 300)); - official.write(lines(1, 300)); - const sb = coder.getScrollbackLength(); - const walk = (ks: number[]) => { - for (const k of ks) { - expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); - // The renderer's active rows come from one getViewport per frame. - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - } - }; - const steps = [0, 1, 2, 12, 22, 23, 24, 25, 47, 48, 49, 100, sb, 30, 23, 1, 0]; - walk(steps); - // An in-place write below the view, then the same walk: no page from before it. - for (const t of [coder, official]) { - t.setViewportOffset?.(10); - t.write('\x1b[24;1HAFTER\x1b[1;1HTOP'); - } - walk(steps); - official.free(); - }); - - // Graphemes in scrollback and on screen: VS16 emoji, a skin-tone modifier and a - // combining mark, so the scrolled rows' grapheme lookups are compared too. - const graphemeLines = (from: number, to: number) => { - let s = ''; - for (let i = from; i <= to; i++) { - s += - i % 3 === 0 - ? `\x1b[38;2;200;120;80m⏺️\x1b[0m ${i} café 👍🏽 ✔︎\r\n` - : `\x1b[48;5;${i % 256}mrow ${i}\x1b[0m ${'─'.repeat(i % 40)}\r\n`; - } - return s; - }; - - test('a scroll walk past the screen and back matches rows, graphemes and cursor', async () => { - const { coder, official } = await engines(80, 24, config); - for (const t of [coder, official]) t.write(`${graphemeLines(1, 200)}\x1b[3;7H`); - const sb = coder.getScrollbackLength(); - expect(official.getScrollbackLength()).toBe(sb); - const ks = [ - ...Array.from({ length: 60 }, (_, k) => k), - sb - 1, - sb, - sb + 5, - ...Array.from({ length: 60 }, (_, k) => 59 - k), - ]; - for (const k of ks) { - expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); - expect(official.getCursor()).toMatchObject({ - x: coder.getCursor().x, - y: coder.getCursor().y, - }); - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - } - official.free(); - }); - - test('output while scrolled shows the new rows at the bottom and in later scrolls', async () => { - const { coder, official } = await engines(80, 24, config); - for (const t of [coder, official]) t.write(graphemeLines(1, 120)); - for (const k of [5, 30]) shownRows(official, k); - for (const t of [coder, official]) t.write(graphemeLines(121, 130)); - expect(official.needsFullRedraw()).toBe(true); - expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); - for (const k of [0, 3, 24, 30, 0]) { - expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); - } - official.free(); - }); - - test('a resize while scrolled keeps the walk matching', async () => { - const { coder, official } = await engines(80, 24, config); - for (const t of [coder, official]) t.write(graphemeLines(1, 150)); - for (const k of [10, 40]) shownRows(official, k); - coder.resize(70, 20); - official.resize(70, 20); - expect(official.getScrollbackLength()).toBe(coder.getScrollbackLength()); - for (const k of [40, 39, 20, 19, 1, 0, 25]) { - expect(diffRows(shownRows(coder, k), shownRows(official, k))).toEqual([]); - expect(diffRows(screen(coder).grid, screen(official).grid)).toEqual([]); - } - official.free(); - }); - - test('a page read after an in-place write does not return stale cells', async () => { - const { coder, official } = await engines(80, 24, config); - coder.write(lines(1, 100)); - official.write(lines(1, 100)); - // Scrolled by 10: the bottom 10 active rows are below the view, read as a page. - shownRows(official, 10); - expect(text(activeRow(official, 23))).toBe(text(activeRow(coder, 23))); - coder.write('\x1b[24;1HIN PLACE'); - official.write('\x1b[24;1HIN PLACE'); - expect(diffRows(shownRows(coder, 10), shownRows(official, 10))).toEqual([]); - expect(text(activeRow(official, 23))).toStartWith('IN PLACE'); - expect(diffRows([activeRow(coder, 23)], [activeRow(official, 23)])).toEqual([]); - official.free(); - }); -}); - -describe('coder/ghostty-web#180 stress payload on the official engine', () => { - // Port of buildStressPayload() from coder/ghostty-web lib/viewport-corruption.test.ts - // (PR #180; MIT, Copyright (c) 2025 Coder, see ghostty-web/LICENSE). - const ESC = '\x1b'; - function buildStressPayload(): string { - const lines: string[] = []; - let n = 0; - const marked = (content: string) => { - const marker = `R00L${n.toString().padStart(3, '0')}`; - lines.push(`${ESC}[38;5;${(n * 17) % 256}m${marker}${ESC}[0m ${content}`); - n++; - }; - const blank = () => { - lines.push(''); - n++; - }; - marked(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`); - marked(`${ESC}[1mTerminal rendering scroll-growth regression${ESC}[0m`); - blank(); - marked(`${ESC}[1m256-color palette${ESC}[0m`); - for (let row = 0; row < 8; row++) { - let c = ''; - for (let i = 0; i < 32; i++) c += `${ESC}[48;5;${row * 32 + i}m ${ESC}[0m`; - marked(c); - } - blank(); - marked(`${ESC}[1mTruecolor gradients${ESC}[0m`); - for (let row = 0; row < 8; row++) { - let c = ''; - for (let i = 0; i < 80; i++) { - const r = Math.floor(Math.sin(i * 0.08 + row) * 127 + 128); - const g = Math.floor(Math.sin(i * 0.08 + row + 2) * 127 + 128); - const b = Math.floor(Math.sin(i * 0.08 + row + 4) * 127 + 128); - c += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`; - } - marked(c); - } - blank(); - marked(`${ESC}[1mCombined styles${ESC}[0m`); - marked( - `${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ` + - `${ESC}[7mReverse${ESC}[0m ${ESC}[9mStrike${ESC}[0m`, - ); - marked( - `${ESC}[1;31mBold Red${ESC}[0m ${ESC}[3;32mItalic Green${ESC}[0m ` + - `${ESC}[4;34mUnderline Blue${ESC}[0m ${ESC}[38;5;201mPalette Pink${ESC}[0m`, - ); - blank(); - marked(`${ESC}[1mSoft-wrap metadata seed${ESC}[0m`); - marked('wrap '.repeat(48)); - blank(); - marked(`${ESC}[1mUnicode box drawing${ESC}[0m`); - for (const line of [ - '┌──────────┬──────────┬──────────┐', - '│ Cell A │ Cell B │ Cell C │', - '├──────────┼──────────┼──────────┤', - '│ Cell D │ Cell E │ Cell F │', - '└──────────┴──────────┴──────────┘', - ]) { - marked(line); - } - blank(); - for (let section = 0; section < 8; section++) { - marked(`${ESC}[1mColor grid ${String.fromCharCode(65 + section)}${ESC}[0m`); - for (let row = 0; row < 8; row++) { - let c = ''; - for (let i = 0; i < 70; i++) { - const idx = (section * 64 + row * 8 + i) % 256; - c += - (i + row) % 3 === 0 - ? `${ESC}[38;2;${(idx * 7) % 256};${(idx * 13) % 256};${(idx * 23) % 256}m*${ESC}[0m` - : `${ESC}[38;5;${idx}m*${ESC}[0m`; - } - marked(c); - } - blank(); - } - marked(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`); - marked(`${ESC}[32m✓${ESC}[0m Test complete`); - blank(); - return `${lines.join('\r\n')}\r\n`; - } - - test('0 stale rows over 300 repetitions', async () => { - const cols = 160; - const rows = 39; - const t = (await Ghostty.load(OFFICIAL_WASM, 'official')).createTerminal(cols, rows, { - scrollbackLimit: 10_000_000, - }); - const payload = new TextEncoder().encode(buildStressPayload()); - const failures: string[] = []; - let baseline: string[] | null = null; - for (let rep = 0; rep < 300; rep++) { - t.write(payload); - const { grid } = screen(t); - const shown = grid.map(text); - shown.forEach((row, y) => { - const markers = new Set(row.match(/R\d{2}L\d{3}/g) ?? []); - if (markers.size > 1) failures.push(`rep ${rep} row ${y}: merged ${[...markers]}`); - // isRowWrapped(y + 1): the next row continues this one, so this one wraps. - if (row.length === 0 && y + 1 < rows && t.isRowWrapped(y + 1)) - failures.push(`rep ${rep} row ${y}: blank, wrapped`); - }); - baseline ??= shown; - const changed = shown.findIndex((row, y) => row !== baseline?.[y]); - if (changed !== -1) failures.push(`rep ${rep} row ${changed} differs from rep 0`); - } - expect(failures.slice(0, 5)).toEqual([]); - t.free(); - }); -}); diff --git a/src/client/vendor/ghostty-web.test.ts b/src/client/vendor/ghostty-web.test.ts deleted file mode 100644 index e3db54f..0000000 --- a/src/client/vendor/ghostty-web.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-web'); - -describe('vendored ghostty-vt.wasm', () => { - const sidecar = JSON.parse( - fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt.wasm.json'), 'utf8'), - ) as { file: string; size: number; sha256: string }; - const wasm = fs.readFileSync(path.join(VENDOR_DIR, sidecar.file)); - - test('matches the size recorded in its sidecar', () => { - expect(wasm.byteLength).toBe(sidecar.size); - }); - - test('matches the sha256 recorded in its sidecar', () => { - expect(createHash('sha256').update(wasm).digest('hex')).toBe(sidecar.sha256); - }); -}); diff --git a/src/client/vendor/ghostty-web/LICENSE.ghostty b/src/client/vendor/ghostty-web/LICENSE.ghostty deleted file mode 100644 index 0a07a66..0000000 --- a/src/client/vendor/ghostty-web/LICENSE.ghostty +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/client/vendor/ghostty-web/ghostty-vt.wasm b/src/client/vendor/ghostty-web/ghostty-vt.wasm deleted file mode 100755 index 79e560a54154160a2128480eb41ef7f198e900b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 423939 zcmeFaeSlq6dGEhp&fCm6XJ${{7)XG94k1i5F{qft1T{Ms0u-$H(q3kw!~fuFbtoYqVISMN2JOs%TS7TeMu8_S!bLm&)(+eb(Ch z>@%4_AZq>Nmw~hQdVAKhp7pF}J?mM|T02;~`KB-kg7DTES5~(2FWh=%RnM)#l{I@R zCQ^_eUPInOy~LKs<@G@x2BRoyS?{uW*uGGm^tMUl=f1qB=|5fWGRw}i@ zYPB{{2?s0HYEZ8Lr%Gh4G7!{5a>Ow|{*9MGg{swoTHI_lYrI-|RnIuC)hmO8`VS~^ zHL4HB^*Tic>h*9C?Bfa%q|{sWfdS{xpSd4y(k|bC@mkUIS6zVL>x);mS|5LA3^1dMZz+eN!YeA#ZXw(LV zs_NLG>QFVT4Gc5}D?F5afOZCIo5(sC)(3}yNh3x3xK)dTgtrzA31c>U z+1Fiv^X7?(w_I`Ky0=`hZp*q26IX0lcUut7IX3>0Hhp@q}ee(Q$K z*WYmM1Wmd6=4;m7a`O!vCW7H<@*1FN@+PPB!QCJzn~VuYMyE?LHki?msV_uBZoXwA zm^p3YI?EKy8qR^w-7+ zmF%T$S-bw$b-_=}%Hd9|+jP?n8`iG34*JP-y67uzyJ6ybjgV_^xGuP&pj1)Pi13SH z!CgfHq`2Xy)&*bg$(SPPwoNxotP4IK7g%nhQ{l$V6KiR}t+&uy>w<&v={Y`q$!?&n z-yHnWGi2Cw^KF}h2jkB1A>6uYGhO}W;8V|@=`Fz@$LBm7q%AjWzTv9%>w-@|1H7xQ zgOG4oaOfE_U7Kh6%yBZowlwdGYi`&y@s{ATaUqNPsC3iXO*dX~_4;*dHv|tI2aX82 z<%V^)-E#A$iQse3km(_0#ZsPjj z&!@p^YR(%rtP38A$DR#h(b6yW$XcN0>3$p3xMkfn!I$FhwBW8s3T;|{!-gBLxOLMF z!C%CSr_EJrMt^}vdt6=AEZQ-gZecQ87yM;>#xW2#ty_CdFUOaAbiECwm;PCQ^VL_} ze9PL64F0dgg}|{yjo&=&vEGO*6})x%6>B$bTKksZt8pRmdqs=Xul1CIa^V**^`-x6 z3UI6V)*|_ET*#Jk@#Vo^$A!2m*BX31F633IK%XoO{-)QBy|VpT9*+yr)ys0LiLO7c z^pncb(WQO5GVr1>$in9AEB~PKhn1`THh!@3ck$oHPsaZ{{#N|)%E8JfDxa+Es=TlA z{>tvkA5}h88GQ7;!-pzg3Zsa%!C9013$owKK7NnKc>5e^PQ7~cD0e89IA&A3$a=t25r|`bsiVCYbk^mF~W^2f(m3Lu3C<( zy0}U?uIA$6a$McT!S6kI4Hp;Yac5G;0C8l2+iB}?v-$BbjJEnwb?%Mbs%Ok`4iGH{x|QtAjmES~!a1F48Zs;Z8j7LodQ>_MJ%a8)JEVD0TFD{`=-JrV z4%0?<>3DZAYhFrnm<~>4zn+jBw!p|nLh^1kH*ezB&DqwpJ~ypx>^8IEjh$Au_stqO z`Cq&Wg0{xHRY36e;A7Bvyfc_qsdx}9(#Az?q(n?J|IpH4j}^h7MI5k*C1J2KY*@r6 zdO7M8IG6`JgU~x-uxh}Cj79Mn1Fj2y1?sgGK^*{*gM&n3gbrr^5?DE-f#spr%+=*e zgh-2aB?~)Mz>^JP5(7v@rSamR4kL?lu>ylDf*0v+N!VB!E;4F5B0=m%r|r9_PH`}8 zWG`C-YK?5gMbHH}7XhXX+TAEiFCi+*o(|R+)eS$(QFS?nlp{!o$Z=7-3ZKw#-KZU^ zhO}vXsAdmb*p5@Gi_=zHlHDUv)O=9z9R`e}tew2a_iKZW26ho<>;~CD5v(-@H4=PS zBBpcGc3Mw%sX_Sf@3!~PuXf$3$)ckRNM#qz1aXA+UW8w))%oq6_ZrPZ{q`xGsWzj^oY9r5qjhpx-wX$&&jLYj522F<% z-WhOoh4d9t#T-GWnYQR2G2pP96S^(et1Z{7&|x{l9=<3OZj~=<#JkK9Z~3z50&+hb zh;|PKo_M}1Y497=(~$6Twf=QhWs>?z;Vf`zY2e1$6F_JrznYVNy|L|*@OqUSw{k;y zIS5bXLo35K>0yKAC)FqcBNr!}4Eqj`d^%!1LV0f)z77Mecp{JiAW|*|P$qm+*bQ*4+@6@cbB-JzmW4c>o zv6>A@e%olRrS;B$=d`6Of=yHdktKl z*`s?7O$J%yxR>lUM=+CRyZ*;#87(0^1D7Pb)Gb9a*fjy#rjbGR)q6g3EChje5Qh57 z(y6VA&~9a;h`25G*4VN#oJi@W?A!3quk}?$C19(*ezP9de?Mh;k50*}8Gy0Js2W$( zorpK;bsLTekgj(LSYt&n8G_(n8RnJ#w)ZfHRCk5R`^xAj@i8kE9=FtDfG6)Sl@ekV z4f?9^Dx>l|P;kINWBQJhAaryeOm+(g1bozl(ZeZ(0iA}#${Dw2TYreYw7KUF5ywQ! z?ZEl6vBvQmx+J{g`B3NfKH{`1P7yK6sB`F~JC0X*k1y$)DV5v6vN;y9FJ77q$J1Im z&=dPRLK9z1)RO)8s4|5AWVkH4D>U)HlM-=uS6G?=cA;I9aY?w7-^IauLlZ>kM4I2| zPYtd@yf%mny&jUSaMMvfW@@ILWGnk)nHz(taSV^V537A+hk3FAb9WkU(mAG|O#?7d zx;q^AdSleQza1vIK6WH1^f6>l1s;%^m2Dl*qSpv_X%jq9%QCd4VWqaZ)w!mjbVo`5W z1ANw`lxeWYq_~60donM$ zH>fBZmQq0v89@+Q9Miel`iefkiDF+?dYb5pJBzb2$4ASP@B({cU*3T$RU+ z@b=G4T2H6Lpp15+1#EYOT$j!zYD7Q7*~fjk(PcJHW3Pwi(yvmTkz zOlK^Cpu^z7IHsX>rx-Et=K28&rLo5fm3#o4TqaG7s{YH`qo&!JP=nL&oJsks&zhN6ztwUO+gE#ajbt6NMXfE*T? zYKqVHYthkx{+(gO+Hf|477f}1G&;0WB#l$ou!M<*n*`3kiOyNo&uV$=NpQ2taPq`9 zX#k~!{^_BV;7W{m*;Pt-%IfeFN~hu`MPsknctAhWS~q2tO)G+zLX7jKQ1<+svKKqb zp0|vK#o0rqz#Yt$Px0I3p0+>CAcD4+iNx6>$L4#ZATADG+R?a_xN$o1$Dt>zXjDB- zY?Rml7dxe!#9bZM?dX%l*EZ&CNurflGdHUNd8%X&N z%3-}kl=#~%a7UVzq&CZ0tGr;5ezFr|8-CyGOTj5h>x=2acm(xN5?@#hQ3TDgSceJ zOQR*>yl$N9Rm@>&OxrM!Q38Oh5`bZA6s$B_TbuTd$cz}#ewvM&mx!1lg#F^%AREjA zV$Oy&7CqstpNhajc~~J#md^0Al=_%*W#}T6<$v>$dRWOP?4Dhf*#MD}&)JPGD}7Lf zGc~d49d%^98!tkvRkE#@(Gv%yz(Ziui7MHZ8?#jss1No>-?A~gQqhNs= z0s-!Q^TzC!HFG-%LR84KvN(v0xuHn|VMyJO&X7=mAhnMVRRWA_GMX$7f=&pfux|{h zqgIA3g;oZr56tWxsqBKWCg-!=Ea@RT4cyom}5*&UKB*#staT4M_ABJ-tTy~XzbUHk27PYLqH{s6x+526>G}zGpw*3Q$c|7dxY5O!0L8k$bW6oC$~wIe|(mzSbjN5!9r41!l@tt@+ME^Q&Q0 z?b|Pjo#=35I@ZX+z6lkGsA{bMqT{jykRU14tt%aa@VJ5vGLj%oM8Qg0*IK9g)F5Vk z5LN-ify9=u@q$qX#;W8Ev#sQfDK2_QB-YC!FKZYe|-^Ukiu-R%44QE-FjLeuhYxbN|Mo&F$?!42_ zI5X{Z&zgVsf^)_ezTkz67Qcub#lI!z7O(E*#V>j3c`sYKZ28MqtW?DLugG3$39rhs zRW8R#{$dVV81K9fa|~@u8Pl=%^2!XQ9d8bXdRCLOXouB^J8dhhA)PAE%c}ljauli51nG6Cwyp*g^u{pYzrOrp;;E1{D^Dc zObhMsp&1ss+lNLhw9|(Y3*~$^Y@wXbhAfoxS=&N6pS3KM^O-cMg>lYjgBHs9Y`{V} zpEWF$^I6?OIiJ-mwA*7Q)9=DKZ*9fp^l7pY(PO@Enc@lU^=Y!6fQzGxglL40)*MBc zBF5c90Bt)Bou=LVFYLi0fsbPjq?BJcY3|4;6Rj=Wsz{q>QFD$5jyg~VJ=;Z3i9MeR z&%&ZNX}j%y_CS*GDy!$}rFlK8mW^?|0JNRy->#mi#t{G*CTESZxJs4dYd zpx7*CDFoac7TVrCbD{0eA1P*lxt~d8D5Slb&X=bYv^C9T?v2U*b0Q4OPKQGC6|7s7 z-OtKFhgL8-(-}c>uUeZDkyDMuM87-1*TX)&KXgC^Z*g!LS`$#+bZXOkwq<-M3h~Ak zOxHZv3`+zxvfIx}CHaJ^Gz9=w0EJH~3}=ffU~bRLX!v=CFM1C&InQW9@l|eTG6xvI8{;4FT-?8TdQ>-cWy9%bz{xIWv~9>9~r*+mW`W|i!S~EzV#mnU;l=YE%jBcpBVYcE815MU3dKr z5_Hmw(a~2=Cd;COHe{TS!G1}Fh*k8`=zya4!=@2d-eiyXLx^L7yRiMf(4{#UmAxw2 zYmCRb{o$m&TZy|_*IQX2@M6P3!(8!=@orweQKY2N!f1c8EZ!}vkCO9BeU-j&@?q7~ z`gN0Hk5~2cTqFW`+bo4Br042^_(_TPlt-PpxmO8T0K07@jdM3YFB4vBN@HzlqdOirA zt(`ee)bY|a?S-z67q~hWonRgN%5}W(m^$87s^c|CEqEwz@rC0<@zVE(%idec@{%>} z6%LJ+4h``p40&1WSMprhCF4U?Y2B~N%8)L2Pk7;biacwm>cTbeF2Yn@yX1#1g=b%* zJ^`W$L7t-oN1US-lPlbK0n+j#*7f%k#p$!^>t6qcANkS$npcU@d8qPZKmMkl_{ltm zJguvXf7e`fO;J(nZUC`Ep=SKDy-87x^%TASyuF^L z*PpZ3xq97UuSTAq<<;@m$~A56p;_#mmf;2DB?c$47Ned4uj$M;w)HcXr7CdS?X|Ag zZT8Au>1D0A+3SE_-)gT-y-wO|ORrnixVGNz(A$vSep+wCdb?e3iQfLZ-VCjuD&F2A z6+r{nRFLcwLEreEIQil2#=x{A`&QNc>;oGlri|;&LLb%ZJRXA(HCmOKHD4sn_S+8e z{9K2aPs_>1tot$^v|fB?ebBm1U}~1mpuVuZCC|{>(v)N}X+dwQuZ(P4C}26)69ef_ zvi@7CMSta*Ld28RJNTORO>P7tr8Ol$tP_;s&GF=;WuXurBNT4T8*&2;VOX4GL!e8U zi5~1{B26nNKS$(^nwFDj&xvpnm`vfM8^i))A$TH`0da`!g7c#1$4?W}bIn9f>~++I z;5kWR8}kopJZ}>6#+V!XJ7CHv{w{PtVzbQmfcD6}lcdMZCAoV=PLK`Lcg2RhD}I8E zjQo?V`ljh?xhb#Z$EgKrev-AUpT3s$c`g5qT9_zKvX&dCuceR_|CL&pgHE!R@#$+B z&)f1N)Bu3G3TaXJN1>#AbpUDRGn zF|5AF1G~1!x~8pG8Evkdo;FwJv{^$&+I*7TbcI?pN1cPF=xhE7|8|m~-&AT zQsk?MJj;lw8GN+$W2LlLaC>xT=zxTyVV{6ZLn5*4n8ru2Q$fruLk2)NfvnxysW0?wND z3jkpJb(t#V&DebjmA5V}$GliEKU9u+iDE7($DF5_ixtBnKt?n(ETWQ+XHQB3Vq6@w zy*O7->!NbuCdD5 z>Qec+&!c=+CYmS@42YJnU&b4L(2AJEg;Zq3sUm5;s;`8GpUc;JWjVpdrR&SqE6NG* zWKrV$a>9vHd}XP{Gli6AyAv(HLgjl4vXLBwt=f=O$xxMz>6HQXpjphem7)>A!n3;a z<+-%|a->O|4VoD+dtCHs0a$i$pbsX$>%}C<&87n6%i>^;j9Rnis@Qs44+83O84Mo} zF2V9JtM&3y%Z78bc%Ep>mW!Ma9{$cwcRiOA2gPFN!6}NxZbe|#Z!If#1O1biZ!Ik) z$YQUKGfC^!qcpHC&;~S}(~XUvW|U#YCQII8Tv9 z$EqkV&WmBYJC`Zx0Z;2QtazdIAVG`OzP!vK#bvD}nt|x#R#uavlf}XT>gfyEQvqK; z8)t&nGGXdzsH1%s&}Cccv1H;Ss|WAs*7u}!ZPm=h)gh%0P{&hEHr9g0@E}qIW%#{h z?#l3j*5ZOHi{Kc)*D&hv(iFvZPMG`;o8+9i*5i3ui~NSMnL9M8;u(acx%EQDcdYr6 z>rUyQD&Lyc3#PVfp&|;)TsF(~S4;nr2Y7g?>Y zAsyHutt60jQe)0j)|tpG;GaPOk7_n1 z3{Dq>Q}s5lmqb*bIh3%`q%~K$|Ax+^77ymQc~4W`uC*cL4d!l5wpfN0cTw|nmD^Ajm&+jk6thyg8g5h!h zT!!TT`u-Bks{6qr*vNAu*zbRT31-#(U=a*u=(%v%f$uNDthyg8g30|no2OZL=(&;Z z55B*ov+91ZNY{RD1pCA9FTt$3A1s2ko|`^0SSWo1&yCU_e+Ei_vVTkPyP?Wibw5~? zwwn?1mC-Y5$=sRnTvizPem|G&(q;Q?lqXz1UlMAC0F>--;XQWWKp2;NAfx$o_hb;Q zLXf@vy*jgUjz7NUlzC?hNsc)rRZ87&#b!<|7A_7rIYZr0P>*kY^Pt%tvT7&HKKu>> z?%pD;BEElrcGP6(<;#O9j_uFl)xPB1+;zQ~)xrOl?>GFhqge1`?{-1FyuYSqc5n&9 zaS1o?KP?6E^NuL{RTqVUB#R0Dq7UNft<=x^Ag8UAh{v~rZ}mZ3z7_nt-*=hu`Tjiz)mntpw^Bdu zE5hqr!96~R+xIV*GUNCC&khAEi{rO4-|I8u`TZ_er={Zht<<0KXyE&;25$F3oWI{z z%8d8--#Y}YEbiaKWd76lI-K$Uo=xyhAH)HCCc*D$FM-Ri@Bmlpr+(7~aRDDCxX)+C z2Yd#>cYf5R;sicK@OOX91@Qt`>Q{UaH}C<1zxqCxiXXTtddEF3h$FZv`iidzPw+Ou zpWo|JaRnFTCqL+d_<{@a{*SmI&fqnI+du4rc!T3`mi@agi#xb*{*bQ-fAG@?{*$i= zhww*y@8J#JBIVn=9T?ofRmt!8jQE2q_=oRusW^lW68!z&c0oMCh2U3rx*#s$s`GdD zxgb8_YTTdrAWq>*-R2?V6|U4>K8Rbm`e*V3Ru*}rJsjMabz~0{1PA`^+diqzk*LZg z759j33(FqY9Ak`~v_e*4|B=}wlovI3U1^uJBG+gg4(10R-YD6@MPo=dWE+Q9=XLFl zFN#po9C~$e!7OLo!RjMp_ub$8j6@XjKQ4|XHfLd{GW(%^lEm4JJ{pDfT#ELNSLz7i zEXKa%9=oUlN=%{~g|I*BdZzr6D0Z$4ck||^(csI`qcl!FC_*@KDB&M;b(4_ZxX;Pm zSkL8%Wbx^;<*R@_-kY*b*~BciTJ%l^bB3cOnE5UgFD zmc9MgH0Km?H^*|Z54UD7=!78Y_W3ERZjSbNAL(csUwIv%w0^h!^A8=esfGxSBOF%9 zuD28BtFjmBXc~>w)OdM*0^yf;36EKR67yHM2x)h&Sd*E%3#400`=AwC>@9$-P&k}yWdqlVAXyRu?`AmgZuZzd47f7lG{`3C6cAdvjym}7{ zhl;DeP|vJf&(aoW_lyWOm3V*NQU(pWU53P1v=dx~^F1SWmR2L_0x@U)$EO4LS9J!|I4&E(SGmijCW0feqx(k&*LNFPV++@> z1sAOd==nk2k)RYZ1CHNua;W40H>X(^y|fvARgeTTHHZGU*61#tA$KDX=5cOGNEv>x z)3flH>w13ay(1+y)5Q~Irdry^RJtDTB$=vI<+Pl|r5q>1Szt1SvurdluDPZF0_`>? zQis;L=WEEuO8@ZD<$E>1Kj6-2ayNjS^M6dPVR$y)6k>t<(i=+Lh5YX%3?wac^Ms zh8{-T^EL$~ohJsRpQJd~OL&}&bV#b@1+*6z(9Z2aJ9jED-@aD@?3FnzB!?Yb)`fEj z39kNl7U^ItY1{>vL*VqUeKds0Y~pbK=J|0F$55J7PF_S5bxzAxETvC`t!$>8I{Qo+ z9w#aVrV$q5F~S0#JXWUolYy4v^hc22Ju>dJ+2-3rJM#u_OU5_}!|@_X7=LH2zhROA zq4PzNCMxI6!(Ot)5_5sQozLjlx>;xM9Wb_o(Kxc5TCH+FUCz-OJ|=tG)Z*^GmZ{Mk=-0_p?h-({O1qSbeLKElQaJU_tv9WW zU8(N`t7(7}ldi(5Myr^-ndEd0CX+nXNUuPY??AU?BQ=NbTVMrN|(n{0m)b_l@PbhDuEThMT!G z$MXX>0qC4+1UqDp?vq-&;25=3Dmvt2I&lhWeqt59zj?bWERUBiW*Ux44ZK>%9zmdY z#gmUHobl8(m}Sr{Voeu8cJ|Uxx2uF46GH2!w1J|70qCYEp94v-B7pm}Bnz6qFo^z3 zKzte+D|=2gjyoUiS>CJXv-~l}jc?Rai9VTX3sUk0Zo0h~CGb7Q!SS&i3@=iRM4CL- z(C?FBAu9!Gm)6o*y(nW%R>EKZh7y`ZLNOew?#3|Pry0eo#O;7$GRSX;!7&9L5^hBy zKih^5S6P50b9UImKFcxA@f25h8y)Ua7U^j!Q1kvr-MaH2vimL3@zen|(X(NlQ@ym^ zG-9JlxO#H8rAwql!dF*pEPBy0bL|%0%QCERtgKyf8leHHho)~LE|^4u78c=JR++GS zF&(VD_f?{*5|XNFgBF6r8@WFU=ORU_*N`xBJ;toX0P!ULwCrAe{+T}E<1$T5M`HR* zBN*`(yBUU7LYko{2&o4qS`nOPyDH40ym)Z$+9hzUqQ^uD8uL4Q6de}Pn%YgSm1@C* z12e$nBF)D^_G^+iA6fl$xNUWK=Z-z-X>Np!aOatBgB?}f(#n9KtCGPpnu@0sH{m%q z$J1VXA-KhkhE6TS{A-0y%?sI!3c+*%%@kF>Bhwgi`rkCwIyDDYZ16vVD~m7Tmf}1} z_T*@ht&;sTE0xH}EJ1qOAcZAJQBjl?)+OyYLx2}i{m|m<4&xnA4dI4jvZ>jvnL>Swsvhb4P&#pCh=_Px0F(V~ zz-@rpT;^1aSc5!hOPdEqs{xy6v!>Sas!kUJXwbp-@LR$CDbtk`IG8&SH~6np}E453P0`jx<|~~NP~+@+8L*`ecG0PI5eYCapQaM47@k8 zb-6Twj#1%xx^~E|5M@M?C50<_Y(@`B(tM6#P8^@-tE$*2anXbnhpeU7@d?9bN+;Mn zZ`EL$q^{)O)!Fn}KC?H^^9;e|LtX1uo5snjTbNY}O#Fn83}3XUl~9O9#&P zx&!C(Y1=0oQIze~q3-05Rd=_zARn{iz64E(=L0&#&4{v`-cq%o=Usm=LDWXs-EIl$ zG8L987muY1NUEr3siMrnXfKwkyUkjbV^FLXtYptt#ijIi_MB>pv*&vMu+Wz2^dUoI zEIQOX+TFWPJtx-mPmxf!OE8Ytjt`v{k3=IQ10y4qk&$LttB(v;2M0&^Z)A``%|&Tz z+Vz%PE%G}j)CXhHDvWUv&Exb<&l8kxfyrbuAP26fb`7ff#mqpJ79u)&<{olnOFnJA z!(?Z-0rdApzIuZQ^aa<-K3iW1(P!w3imPqfo{*o!3G@U=SWobs27kyN^$HT06EEy8 z<{wFs2hNFC?ib!GxP@bZQ$LMn=jZAtW*bI3AeQH!OTjWP^19{BP$y=q#0)voEn>KP zRB1O7KhJD6On%ZY11Wlj$?!$&!#u7P3BmK7uDZNiS;JMc8h>DiMN#G;6vP`b5gy7p zZH9924FlNa?$1jD;rEicDBc*p5CSMPcB)GX8-XIoKK}DNu59>mWQX#}XM}{l(_thu zKhP8OHCO4+T4^b2X^ii;Y>sZ6!x1*lNs^U>Y?G#18MpP8zg53|B8=o%?N7zgXptE1 zM*0Xfu8)hIY*LrITUvDcceG0PD~E1rEbUfy`K-RCu;|Y-MdMksz06EvY3LDkLW^0) zFXvHUJfa}RtR8XS%=moYsNUl2JGMAG>p3mXX27%ACm+z%_$ogOesJ2gHZ@nX$;o?A zo?LTsLv`p>GBw;;u1$mO)Kx$e8Mz}Pkah=hl4$|ra_in_*^c~qg$m!X=+>l@^Jxoz zRh~12cB=+OD^&GqT3^qqC)vW9MMmpZ+OYeX5kwLxX^nM|y> zRa-l7QLb;<;lM3WTrufa_}F(e1XOer%TM&!ob1i_&J7EL#O~o6yP@xn&=krGk&)hgP8jOas zX3iK%hKJg%Q|2@W2O4$0zRF$Nbb`CrAj}>g>c7~)D<1SnsGWr5|4CsG4pIbqDnbkD zgyk%@{FnK`X$-VC%)T_lEX>$@%9IYS%zAW43k$(o5`JkYJ7pp})amAk21v z1XXw|EbY2$7?rI=V6MP*wPgP0h)}oUB3g0C2h`6Gi&RU(9mB?vI{`NxPP?pe*rSKL zs0K7i_ZcJbG>7|L|P66{Z!H!hP>hdrps|_E|o1_ZmzT4K^qbw0%`Grb2nN-Jacp@|fi# zcMl3tK;ggujo{lahVaN#C_CEiSv@Fs85DB&pb!NV4inJq9E!f(qh=Q^+TUu=>_ItT z`N-XaLKIN&un)5bn;sYOWFM3#nr-f~&qvymmXF*$C`17Tw{sjK20avwfIcWYo9&Sv zlwAge+&w5n0R>-dJXvxm?Ws@>4YpBAb6g&>eB|yyAqprsD2Lf210ENxEc$RcI?!g5 zGKcb%Ot9O`N-XaLKIN6*v}p?NmOFZqK_VJ@S&0d zm&Yt0xqDEE0t$K<{K7@5#xFVV77V_l(Qfsi++|S6-Gf3DP*CCEmmEsYy9Jc}^>%5< zAFzDn?m;06DClYMOU>hw^KJpdL;(e3F#Likz*>~^ZUJRyt<6Sn z-bcF(3b}hwhyn_#9Q=|)$$7Vca;VyF^q@Rs`N-XaLKINY0O1$(P{SqX-2%$dN}E0P z9G9moAGv!_hyn`kE$~YYCFk7&%HB%5)`POo@{zj-g(#rN^)Y)uw|q;K<-A)!c{FaL zjpVpIX8Fk7gF+NgnC{`1Tp%hS-YuZ)h})GOl)DTHxqDEE0*bx{l~;AZfFWahnTR5j z^v68YapvJMwMGc^&BJ5GM7v0)38^xg3^flGbL~CD7SH^p$#O~f4rY$5p)Y*d4ErVR zK1ZuG&ut&p^lLL&X?iH8!@Gvj#mKfp0C0QRr)!YRdBv87DA|f&8B%J^6MS;TVD*|z zl&5a0?e8wL)|bRDnuUYjEdCfO0uwzm&}S@Wc|iP+u#^lst-WMj)P|poSp4Zkv+W2rM*{Vl)|}cR!6e#z0ftX4z=?Oh zo3g)ZV_c>)tYU!3Laoo^=B?8?wX3YMdLwc$7YiG5Amt^w@pM4suLwY zFvSvYdofh6EZKS52drdOw>?tV!dutXcLr20Ud8V9z@?IYx~QIoEIUB?hCWLuJ{>vE zmWb`mDQ`B}(2h6jb~20De4fW=pbnPgY`Av@V%1zXic(QjR1{sRRT*V*K$}uk6_`Zk z&T`HyPEJ?35*G)ogeJzdN5Yq#gx3JIt4doHX)Q}lwjr0ibvxuws|(|xJGfdFmLq3w zV|T~BPhPo=t)am^6pWf8obZDzmNDt*oCaI$we4yBBil7cVlZjsOi5BGn*c(VUez0d zg-Z2i+ba)*-;0kgk7f}~t4!meD%LK4d|5XqxX&%$-}|~UGydK@1o{wA29y%Zt%cDe z#))PS#=xu&x#VKZE~SkLw|QdYLlTg0S&HKkzkRz!HYyydQ4?9zTWsi91I0H7k@FT0!JJawZ+hs_Uok=xmdzbdU zQVzE66awZU44jhI#53?;5w=J|*vgMan^cAV4rax@>T#BT_^5e<-xbXM*Ec>h8Dz_k zF(~Iw=ow?Pq-FAxbvBZ=iQ>dcoERy}WFlIAM^_Z7tguO45hwwP!*^(|vbP3r zYl~bu3+Z!ts7xYx%rlf@wR-}&Sk30SJaqZa^w*~?ll~ZOF!aZ0ue3kLsRGtI2U)7x zLDylrG>}z#OxPr48rsA`h6kO6SgE>WhOlMGOlgX;-NhF!9i zvD3YoMbOq-4A29*J}A3azbPJm8h-iGlyT(F5SW}b?ca$d`$iWEA3$_RdW)8^sfG?9 zW|5C>IDx|lA9yhFkJ^vOM-oeikkyr`AdM$B;wsOmt!v=~l8x-g#yLJVOQJhWn-kev zcN`4tGtiBU{WmEcmK)6}>@;GDbL>ikltUM3puG&@(Z|QdBy`XUC1LB9n}jcFFp1JpW&!kHgD3>16-X#?bizeqcKl|LyoT1?)j~X4x-%g#u z+srw$=FB;5w%zBFFsUt!9#F63y$_3n!5&Cg$AMl1bv$rv z9gp=gxDt=_A}I0Du_eCU%iu~p*^8jWxbq zy|!k1g?JFM})b zcrStyj~!cL`yWjq$WwhKzI|+oT~kZk(~F>vyL_lb^apww99H{!5tO+1*b)!*GPn{C z_97^8;Mfw6_cFK=kM$xb@yM|yp6X?ACBEH@pv05C5)GZFlCmWuWF5hGRPST*O=x5v z!o4X^L!M>3ituaN1MnfzEs*r{l2M}g-mT*>3Ir(LwK={`> zgIS|9fbNyQk{l+FN`J|I8n4nY0K_2kK!cn$)ccwgp`J*`A5c?Ghox>hXx~F-ftX>% zXRWQEZ9;lVWV4MfD$SSg`OcxkJi?u)*X}d?l#$88p&12NceIkCk+ms}Y&7^ghl`7z#0&ZPG}6u|e|LJ)EeEIR$!f2W ztQ>9fpQa~`5Yvgxc}Bdxk@L}fCMw`R%;*4-8bgbQ3gGQI){7OSv?^a|@V9cQLNpIr5Lrza+;xAym9GVhEF|De46pMbT#6 za1HE?Guywc9heC@w#%x2d!IVr#o&|5lDKmoyCr`F5szQPTB5CU^1?I9g;`3fFvmuwEu8qme7MrJ;U(q5=efc<{We|A=k}He zarPJ*x$H>A8llFembJO);W0*Ce8=%%=9O$_X(X#Vy@O#TaEHfnFCW)IQ+ek!dJW~( zH53x-2;Y9b@tj5zo0js{1TWbfF_Z9+R~)?KPQ$=jE5i@*sqPdz&AmLc$=!dl6McKP z;^6CD?B$Dc*^#K!wX)if_u;erhfa9kHDf&aYxO(1IevChr5J5&mBr-^4O~|*9`2Ku`Kwu4twvLkz*bteJTZA(NzMFl zS@O5NHarwu*o}+`oFDT5AM)!ytMYxSX0ox8v)8O=XN|88&Ih~7>g^9s-W|(oA=A&4 zNX9%p)U8d4rWnWv|1`3U8*MAu>8kIaM$82_ur^n+`HGvIF}^U5fQpME`VAB-Z2pl7 zo_t%}(YgY+N4JxbAIt9!EQ_{lAL6c%H3>K0>#Tkk3i^@Tm2Dcs6PqbEg7Z(?;l;)$ z^=P4n&taaa4rHwi|6PrSrH(2}e%vd=fAvw&pIj@$f3~ER;SoJ6*%OPHs1MBLZU0;j zT{~O3A!tV%yMxQ3`$<(l?HXAbJ}GG566qHQ?;Fu@sHC*viA6*oS;QdoT(lzCCms-^ zJ*76~U+ObsJ0zQ_1}Qaf5%BiSU=UWaFU*A$23ak6Sg0ME%h>a)Z8PQFKX+Mla4tOk zXC~b$*`JdGM?g5}&-~Z_Xv5OrFThK#USAS^Kzl^bFE65kr9xf(a6~-4EIO>5G$ z=jnLcoqIYu4i!ysj%4QnJT1v0F~;a8ULeOgiARjO%Ac9x>XJ#eG?FdJf$a=En-^f? zdiGEmfK@4*gUgje1WkCz!NO33pzB4*mRXqZtIdxNM}xhcqWk$4aqLNDlMrNC5)LAj zc{Gri5=YT)m``lJlc&ElxW#vK@ycP|avXD<#auRqaMAbp zPKW0=g(tLTfElLL_AHpKq0FF9DU~_m z>Je!j?TRu=p^U8Bj(UFV6uti_+$Byp%t4zSyy^WY??)7GYH578rro`$5QBN)>}~^_ z4&?Qy)DBARR93n1w2G_`5Nax)rfB6m!iQz|Dc{4SNJ(*G_Yk2*8Q!DOviKnR)Rns_ zneD}g2ttyNnTdB?jgu#P9r!@(v>nTm{JRohDRJceaR;KYw-I+NyqDcmVC?rW{A~o6eR?>)p626(*zYvExK1451Q6i4GeGMtPj|n_ze8oCocid?s zik*d7J0K2LZemu58Qt?O)s|$5`tK0^e7Bb2sCw7);1dnk@GyISoDMFH-bARmG}1*1 zH1-Xyg^Yo;ZS7qQIvwO8-oe%{*|%~0wVuy8vXIX&^kYnJ3#tJ14R34?VvmKVt;#BO1i|Sy7$m zojkP+2rgU^><1ovL`lM3n0HhhoOIc!Z!8Hp=ZQhwW&><9OTrq{fbDa&fkZd#h#>nR zGwjFNA3$R%PDHqG&T5X=s8gLEvv#ZqelMiN-Ohu8l)FPWlLsQ@-AbE(q4venAj3fg zSo0kyW2q1}Fc$({yu&2-pd!$54(iGDaX?RcVFx}}(;q}V5ruU#ui3wQAxtQI(7zBXw?Cix5 zy4!`21BUBawP?(aAHhXG1xbU$(p59<#iJW zP{)@|%VCXfFx;UoU7Ixd2d3@x1Cn(u*Fc(&g}uXsNBHow*FIXeHUbR@h*RpIr#%BQ6(AjvNnek?Eu zu#U_aZfafG#lk#>oS_7=6!bp}wxtji$Yen=*+u$GHgD@=xyZH6Q=J+)Hm063!on|! zh!G)Pv3r`z7tuFM!dB_JyNaeTBuc}bP}U(f1`ov5UStoqt9qDVWo z#ReX$N`B~#{ZaCSpTVe-8}>AiRTE#AkYz`Ud};c{PWPvdvy-3i)vJ{mjxvtJQ0kBN z_Ua`ByOlkF6WuD)c-Va3Hte5&59P}4^Xs9jpK}jQPR>vM;d|Rf*t~Lgie48b|Jhyi z&35>LTyP&`nN@YeNIMNH0+c?TU*uF9<0d<(>*RPv_4(h9R1Bf2_}LvqS5~|kSl*gA z=9ak@YtDHa(8?+kCX+vM=N=^aJkD^kSFt2K4aJ+sXm@bETck28G}$WQ(Hifzv-+ma zP&!DkGn@h~=_XlaOGgJ3=8boB+JVjLnIMFQ*qOx>wYf8!=e^9DI?XhP_etVT;rS#c zwNajr@;sI20iImuAN1|DVDCXT}V${EwBXG{bHxaZ+WFrZbbj^SlE3 z^Ggz>C3k7I$d&N0GgM4OF1ootT6%FIg23oJEckTk1mm0 ziaPU&)GRWR9LXc!*g2cXCy1Pz{8JuzeP;oYM~SQ_|LkEC67X{*;>Spp!Kjh^A4`QC z3*&RObU007QdsfytBKPn#?0oH77%b@e6ez#>T-f(FKabzW7U*PC5q_-dZM-7@1OfZ z$cs8F#SsOvnhvFBjc0G(*gb1$^oYXq6-E!fePee%rYl8_r3=!t)A{2WXIIF1cAma4 zf1#aYk@RT#X*`>pr0fe!FCBI0L9HHoaKXa(jY9qumkQZ>sqo6e_}LF`w0 zvZMHro;dj&(i8uR2lZsXO&J^8w%f{c1m&F~9q1|Lp9pPkkC|5FLM(&#+i7B7v?<#7&{S{{p_($Z**w-+vr=JP;3NO{29^U{vmI7$e%o5cfyC)9*O z{sG^(siu zRAeCdn3+{Ba7tE-<4KN?0(3h6pP>S0SZx`yIgN=yi6cps#T`B)g>I>vv2@2p%(Qd6 z+IA)qVE%Xq_c~ormK`h>DetP_yX6-tC8b{HkY)gc2Uid~sV}uOlDKyci>M*C@nf2- z7t|=Sa^JMe6HzxC-MSajB#bh_#ghL_eOtVkCVI>)ZX6gOf6EJMqL-V6L~@?^N_S9m zX48voJi_HaOGB$}l9Wd|OH9c^DSh04DrxzYf1NJ2W7oS$@qVKYZ>NLVT<$ruIl5Vz znLCglGZh2U{3O(*f^tUYC?x<|Fb)@lMH`Wr&7(tO-Zyv4E3)ft+^I|$B zmn&(;tJ{o666T%thzdr%Zk|Rcpv;Cg^=tPck|m1a!bS1Y$n&#D_3TaBLk=@!pHl#g znbfQXDj* z6|t`uLFnE*)X1)p8>=lBa*99-Q3S@$95i3I*!HX6j`U4{eqR2tyj{@ z22F(Ptt>2Ib_U;UGik&bUA2czgTD62v&B-oO(%?$JwNdXl(}islx=buP|M|ka`oc0KsBTW z5h!{(iK6LS$L%ENTSgp=$~vyt5{|5d7YC4gBD?n^vOt5uYq+zK@-5B?2_bf&=pjeT z&LHRDXfK^Nh}nd50o;ft9}sj3_vltl_f>at>`Gb%A&g-;{9Hyi`s8!>u4EB)x1<=KW?y-qCJL zh)Ut485$18C2la~mj%yI%z*-=B6E#-WTf~LKx!zvrj7Ad z%T;dWlv6Fu;A%4Hz+ot@DQq`xJ`Xl_Qc+J!K--POyG>fZtqHVlr(=_+twHhPfUmaG zL4X1RYXKeTP%A51_dGX{lW#=?YC8##Q4WsY)hb2P#d8Gr*6F~Z^oiugEmbBK(N#Yn z3z^le>I>+7-xu%u;!kx3RjunJsuv%^aS+Y%oECoS$F|GD#dPY&Fz-FzIoxo?mmF%= zMO@6QE(#*3HYH$8454uMDix_b{OL&lJWn5}B`t07!*+HSr_!93=##(*oN3v5z4d?l(%|}HG#9ysgXSeV@cmZyn?_qEHEu) z?_tWYFoaFUn+Iwc)9_eKpPjh$MYjg~iMUU5C$Ldh7eOItR)*13T;<~qb+M{~iXQ#+ zEfwrx-A`X6DvKR4WSE+uLF@Fa0B`EgGnFI>SFUN;b9Hl%r^Kt>;qnB~Y4&x9=UCNX zY@{`~XQRdvU>bMmGB;x;c8Q^>x}}k}#a8H@Vyjrr}>+W?OJ!x@|eG4}`9LCZ#~uz%WS&Q;%Y^ zK#}8lHWZn2C9e3QiclF=AHiE{l`qL*0a&(bLeVjQSxg#*hx#ZyT*f(<2?ce*xsf&< z+Yg|K^AUNdY@FXC!GNsGoLqBbz;GXhV{`Jxn&#m*H{JDwDk}j{4$;c z=ragEk0zdv@+HWJr|F2`3Gb*IL+Jy0FkvWLF$0g5SU~jkUBun^+Cnuwr`v+UI&|Sd zu;vjrG5a)9-E7xnRBQU_i5Zio&xeV{d5tlKs~d#wN~S)hm6IJzzOlqjPi2=IQLwfXBImpNK7KZ?lHn5zYLY1TuR2yE8F>!}lq zOCvYuVSVBtCv?+A6DF?g$R{(K$rs!q!i}h(3_}@%Iq^uj2;pIhq8CeOO&rPrX;dAZ ziHzZC+hxdSx_eI^MRo0f<_=>RfV*@x$E%zp-7~jjV9FZ$ujH zY5p+mL~lup>YB5@V=ZvQG5||O)AN%n(sCCJ!EsL6ZZ>~A>=`V;BldX6L_x*ae{%;s z0I)V|+0l^{U0yZ^shh$Bql7aYA=)Sx@dG}znL>QOQPyqRz-27X-OhEetq8>7my;2Q z*lne*v5LCf6iU4j@0~E@Kj(03el`qivW*I^?8e>DyXpnmBUsB23E3iXDkM7Bt)~{{ zSMY18YhKP&r&}~?SV-pU(-4euZ2ByiI4l&q%b1-80?8oz=p(&UJ#PZ4?cBE}O&*dC?2v?Yw8_njs!k+o5XWGz$PK<0H$%J8T z6d-U#a_b64z^r_Ol-VaG$EbQz13`?s2nb9U*fumU*ve(8n9V1LD3p&8nQ%ZkA0y<* zXO-qvHn~co5d` z4A*=U)Rbor&R4pY{uY4Qa6 z5=wKPu-3I+d?*wz(6`aK#5l*#=H~{(@QyTi8Js1LvKI#dcZY0O{ol<$+NM?U8c7z6%$PZA_8gyn%BWBLm)pjNhEF|h!GgJ# zGH=1@XPoJi-hIpXP_5G?{wyE!`thN-wE!*7;Qk?%SB4fW7*^Q*QQLQ(=D=+%7LzK+ zQDfaK`N;5^c9-WQ=gAiEylXPjhgIU$|9#tP&hF3|d>s!mwa%djR^PErXE?%biddkC zpWe1QD(5;|k+*GI-6%)SSLDsxRww1ivlMy7w$*7lva86CZ{4=~AgV#H?vA3LzGK_! z$*>&FT83y)nC!#hOp6Bp$-V}hVbS0=*@ql^xc~vX$v*VvSv2@f_8~aeqJ`mPKZ>VW zg5lVYZsb*h;X4Ik zuGv+R!?+)1?#{I&!?_=6F2uDY!@3`Bd4~$1;oXn8Mz4|#^M2I1kJge5_kQHLWY&@# z_EXU3?pRCo`1dzJrZUxMyn(>(V-N13afz-C{f*%I8JFl<(ccX0t1i*CqrV~eU%5or zlK!ShdpCr9Tc$Kdb~sDUThreh785?%^~jV4F){mO-=h8|A^m-_Z&QDxG8ce~lkeV76bZ8>XU9`m#gQa+!9M(dl- zN*eRaC0@^;tChQblvaxyo7l}m*H7`9g1w4_;+eeN>eA6ql*SrqW+~@NJ~MKNu4B=| zjjjyMrV`nrfi>pJ^%--&qNXTE((_OTUAvC|T^sG_1=9m)vRz*EF=|RdBdbk%1{1p! zaMYVch7?{4*K|VLI6x~10qw2|zfaS-Gy_&#PO+xR+0hUC=rmrhR_lPF+dz)|Mzf{> zv=D#CWz^GDmOaxlWYKX+()?T$*79zrMV@QvaNYa~>#*l5E!4CUj@myIIz=y*1C>-N z77_1MN10I?rt`e~O%YnBu|}3p8>sDMqMnepX;eg@XgfYNGidT| z<1H#3qtcKR4Z5+2yLzW$C(K?{Wb!^EtdP|#)}{y|T^Q`gN~QSQ% z4(*VpC_r5nRVgiTX*AceV^Vfn@O`DlU!Ola7uafL)1_Hj#!((^SZggCItru0N{`LM z{u2|>zXO4_QiQbl_lkb-X!0Hrz$^Gx*aSv@ELBK8xV#-yq$tb1;?uy^y*+7H$ths` zO%-vn({-+{O&sdR)>d9y7>7)Rt_*c>xDY>IulEk!9%kX|0UC4IpfWbpQs0)eP?0Kr zrB_9(FNXAv~Mlr1UDE}w4MB3wZ&Qg zG*H0$fWMNETMFwAdoug`6(23#ulR5R3s=CO#k@wkTW6=Hgs35^(hRIm;C>W~+kI(`gF0T8_6rBMO z$o`rDrQ{!%WdO%wSDjOmvx(STMh1)Lt>NB`5HPYfzc3SM7&g^YT;mY>Qzj~l&ojew zW%zl?9>hWNt0nSU(^U416X_qYDh`Ud|OY%CW?Gn_S6X_B# z>F*NH<7NIj(O!b8EF{@+{xwk|rL&Uv>5a}v-fvOJ7j>N*enRkYF~+ZEscUSH?Z$5G z@l>#JQ+toed%82|* zYx^(w+(Y{Jq}&^EHlni0$tMC86r~7wAgWRlj-mBgVdz`djf96B#!}J+6J#*GPyynV z>fDxzd>iCbmNHKYf7c`@Ip;Iv>FIq*cuKA@=r({{^dlK}ay0<}*$p@Kuev(SQd7&I z@O3?3O*SIa8W;L7gekO}Z)v(hIA&07UP!ZIsgQi|>J&ntP-s`w5$e=r_kA|NI3W7jG^J?~B-382AMwAY132HJ0=r1iiTfd;)IRd!4cxpPK&s6b(XU5NhbPSHszTvFryB5k7OcO zaqxz_Wr=La8F|<{BeTt>0|#vEH+v^`5;hDtF)%XTP?2BwtM(0^#e>kOL7A3V)lZiWqf~;fJVR1}7dE5gQmzMLT zGq`ew=}PA+cIY{>)NWJJb5he7!kgPx(wTN9rIK$1Dy?ms@beBKwY@RI=@O^BeigIqxbWdyUmz z=);E7QjSo78=K-sJ-VFaIp|+kEz{W=YY#_Tp75`iFVmqEJgB*02yvY@xnW389p%;& zkEUIEqR4QL02O0tbdR2xt`;qe_oAy9FDBVrC+ABzRI>X)QEII_^dyP&Km?mc2#Wcf z%(4tl)n)jrZk~3N0i4Gnl2iO48qjbG@Vgbg!A#I7DYJEUMkW^XQB3qMK$yQz;XzjXq4`%elR~oL&E;Ba~j~d zB?;23r>E)=4IaK)o7U4&sH7EZJ>`zK`8?pUln3K*p55A@GyWQ-vv@#oX|niFJP5D( zLo{{i!`2DKA)0zRWEGbV(bO{?qA^^HLo{4mXoqO(VssACSeQdJ7UmF5J<}nYVGX&+ z7JBt`*xq1%BmNO`4cJJp16d=HNJY@ z_wgjHs7NWMpcS6I$#Lt z=2f&dSqL(6onbAb3kZCZ~-~c5Uh$S zR+dr~%bois2N3Y0+=B`L+tY>tcfP!A;WICEzBemo9*fR{Dm>10YbUNE7Xz@X3$5I_ zgisA=tqkzLfxgr|loQz)g@vyiV&Dx1i%4c`as*;34<|%^0x1rPv$F-Bb$~YtIV1w< z3o2(tC^~7g*A84*&$KviF&K!Q&DFEpypgW5{==lBGDbqmsfV2*$>`5Ssq{*>-K*C6 z0K!nk{JgHQ+|jt@J&S1pBu-29dBS5p0%oMi{5&4a;pg(8(cr8xay}2vqorLw@VH#l zn;Jp+Kv60eKOSeJ9|SaY&s&dzhI%%c}#b zo@Kz`q--=}tmW6}&?<>7!rc2yNyg_vK|rU{h{O+7SK5e%xp68;-XVN_cR5lrsl5`U zUddNFTwBayWjE>@;u^uuvZ7Y`H}{}9W>uj$3bLbN^PRIR4P>(9a=u5vw-{=k!ny!R z!d0j@tgUkLQfSa=g7UHh-(69c0c}sMUfLW)Xp_QI-wbNgD4iYc4z1Qo^I*Jfb?{oc zW@ryeSa(?8>yf3(rv}?DCru|Yn4YDFLK$u%NLbdi3-=P+9B66w2Uz$z^x2xQ95Yl| z8%dbqW1@u^JTfDld1LbHMlYt@go_MD(wY1bnAM$4b2zLFyCxrSSyW_J@<9u*1ZZLw08Q3n6+ZM#Vwp`%7< z1CRZ(?vQF>qMa_9<(l$#^!dJYhJek;VcG}S^W76i#>vQ&QRaU6#y%4r7YEfWwd-b# ziuV{d2S&zuP%sr48AV3MdmW7y2b?1h(^-y+^EK1|hNm7mA#9p_s7ye$P#dNvK}N9b zHF*XIA4@}aL8qf(5#2{a-yxoq)6p>WG-TY*gaKjb4$hNK*c&I9VS_6Og4L&S{BVX- zeeAGF!6STkS{*jiIFq|7Y*R%s(=bjy+l+-#xxJG|Ieyx9rz1-I!9J>J3P4>T0*)=I zj4ds?7*lO6cnnMVTeWPZV&k>Lt};%?Du8djc!`_PU@p(#Eo+LrksUkTvJDQFtn17) zwTZmlIX0S)#M^wH$pc%rZBOT1l%w zc_ZoY+PUeFXaqSl6n*ULsLFJdF%xK*QM7!jIpYn&e?~g%#$=CJ*ho@a94t4BCX1!c zY|#K7KbK`AsuyqPX$4&tK>~L^;E=Sejc28^lf4dBiVOe6v@`1h216NzS9SROv0g6d zj<9d&W=)pztY-(E^2FNha=_0vCgQ_&mnpwiX*0o+H*}Na!-AhLMyOLln-U{Vs%0Hl z9cPZjuD3%Mkw2F-CYJ+?apFqM<0Z*t!-jzo3(+9)k0nYM;nnPpl$yK&q1VWb$$d8D z|3Bv52H39ay6?N^oO|ELz3&0{5g&vIN;>x%Qb3t7XqypPL6&h)M-nZ!qCHH8lVK;C zi8|$g9F>$git16#XAxErnrbjItYTc&MLnp5*q{u%ti!QU`9)!Dh(T0Hr5(1z)^NwzZ#e&=gJPab}xCLKI_ft0>Syi^$UEna5_Su1b= zchznIqef|@E=??|-vAg;HK8RqcZ-)1rDe1j#XGh(?;?8qiYIPoJ8_?9;<7n>@i%%E z$9(SToiX5m>2Cl_eWq@1LNwH%+!{p`4&3mGz_xlU>ic>TVvCrE3O$)6EQ^OM+i+(N zVsUGTWH&HH5(x|$C<%Q%rO*ta)%ETj$ZS)p9o8b>>MDfTP+l!Wc*R!e4gXoN5>~G@ z8$fLvoydzh1k!G`!(x|SXF+I&glqj+AP@Toyy&>YLe^>myLcHNNEf z7DR3m=z@idQ08fVU-g3zK5>{0<~~7$K@prlaR=&qncp`7@&K(@{o|fDl0NXTn1^Bu zc%Vn1>cnoeoJTYxa0wx{KHiI#jdLRfv$)UFzO?$7G9Y>IzAFC+J}kjUBT_hYI#U2e z@85rfs6YU#^?@pxkm4^LA+C&35tjnW;!-RIbVr%OI~F=24R^5rjlSi%`T2ie7wbk# z6=U;ck;!^}lXHNVr1v`p345H32CU>{QoF_yeXbMt)5g9?vV+{i*WNcJFBW}~Ss9p+ ziO`6&$P$W1%5%trQcbjEGkF^ZJI9|-qCp>Hi&k4tioHk(_FVKErrSbPjJt_y&q1~OCOvp!C@&7h7L7*A6Nw#gGkKTh zMA)lUZHEz*15#(&BI}A%q(xwxTMvYMs9O;5He(xYq53@jU^kL%Y^cO_Hq#YJ(oGYM-fXia;aPn}Sv}8ho7fY{&4@Cnk4YBk%22Fnm9QP80K!ANzB&^bj- z05q*EU$P;!i9}-^`*~UShUMVv$HZfjZv!nLr==nM^okKJN4$~gkSEjX z(E74zWeDDDDuL~kkS8JIAp9RWNKWOvPAH_seTbARJcwE?g_%c+;SjwdEC*g;L<-$! zGyZ(wtG}gq7M83OxP|UFZj6}8`1}-~o9O2toDqxzz;6{V;e%UkW#?o^c&%_*VcenY z$Pk+a>qB2a;ssqj6t<4n61f)07-GPu1|AeUEf~$&9!tQYMa_!&SH(=&_FP&oEX`Oi zo~~3{)+^&fF6RU|(?UW)m^W_Y;>BB_8@4NGB1yo)RCZY0GDS@7&JLM(f_#b(W@N0s z@fS?;>Tj0lGMJBav*4x;3GT++p5FHexlz|hljNyVtM;GhWnnEL{4 zed!%j68A}%%py&25CG=o2Tz6?%M)}VYn}nc`=TLQnQk95a1Dgt)`);DLw{2Thb+rS}oamhOdZp z5}`-Pih9?N5I_Kp&0iO~;eQmiarhs!X(@6K{RA&L08hRkXn%aXh|g$$X~5Y|=LB-rPN9!4Q4I-_MbRARC2%FNjg-m2uxxtRDVC zDs!jL-^)Gr08x)JnKm8t$o0E|!V!)kj}I5_(I`Qf-T4Qmun+8h?0f?|eV?j+0$ovEF zgxp9B2Pvf8AuDmX0Rub4eTm8Cuyta#qTq;Jw1!Iy)1?%@$PWc2=4`kJ4T$(fuH7yx zzpG()imGz`y#$2iHujEE03#Rgo@lxhKuDbXho&njfN_koNkNRmaxJ3f`6yR51!9?LJYIk@3(aAO-xeQ{_JaBSQ=Snw#k*qn;4Lx8v1u*870uwb_ z;D-SsoV%1Ek84d#A6{}_yS@8dicHKMjHb1tV6=3^M4UqoWp~XLx%jk>o8C?o8b`cx zrNb6|di!npSzX;$A|b^9uhVeor~!7U-ktZ3Cda15nI(69g%dO(h-;TlR6SzaoVAavYt`2S8NYTEFpzQ9FYOD zNk8whN)dTwHVgxIha>ZpTmsh6)bABhrMvt}-60-w5ruKG z3}(uH*G~d9(e4?t6xuuTM1(-jD_Bu75bM5n+9~!`@(^$%+%b2UV_u!$we2a)5k$F^ zsmk~~o#h8Xgxlr!a@aLdbdFh!VRQF9Hhk=~dhl{KxjtSYba6-X%JR%BBgH7fC?r;y zM!c9!=@Udr8t{cI)e5J%XS2y0Xl{#UPFg>}-eO>5H=b?cNZx7UaNg7kzw%Wh9W~ND ze!?B|N@0$`mGQ64ggGj7Cc+%&$~Q1X#q7+L>`HqJ2O@az)0Ni)C51W8mFyC{ivvhl zasXlEvu2DEepqLCIRtRgD#X!Fn4=wNo`fdMaSq42KHLd&3}=S>+JrgIX$25lh^W0! z4933+Z$#!YYk^S+s*v<1qH(U$T9wOLl4%ZO#aOSiW3f<>_EX6ZEZ8wWby%JUgXvwm z1l+>$dPG~0Xo-nc<+$}F*;}VK*eF}x5FwGwAL!o39-2{6^R|GZhP~VD$x82>6dIns zi-0m@3h6Zj`D%kNVqpDQ7((DPo`JyUMsQHS^zz39U@u*Bv|ra$CzeKud?1ZP9o1MN z)U#aeTz`hGY5ncZo>4J7j%WMgZ@crS`v`9w*AxZF@eKP8>iAS&VQ|%X@Mt!QUoSt~ zpDr;r9y{L`N35RXv7%*zFZL%xu3qG7zh#6M`{snJ12MNIl%Gac2WEvwxG~qV!XpFo z!sA>;o%qmn+YB!p!VPD5p?xZ^kr&cAHhdenz_|42`8Jw=TbCJ*`YW$%sM2hJjImcC#&@j7EIbP4~M)ushuMM-K(wmtY@j_;L{teUfZ0KP{RLMfgoVwrfyMltd)oB=c!eEdX!Z zyDU~36j*tMfD6)*4GnQiE=Y%M8@a2~h*P92nM!U0nu?LDt(vfQkW*rvuRh>xI(j z0pSPhCZ(bWfIt*8Ym9~R>>tji^LIc1=HIOvzP-L_4)#JmERDH(+@-nxr=MC~MUs(3 zya3s-HmM!i#!vbNbH88FS$zBU=^_XSr(G0=!G{+W<+W5|lHZ{}M3KWICBaXWwzk=F zHF~7v>SYo}aMDc2GKl)w>53xP*n>hHEwOHH0YYO*KwBfiG-62o)V5Olj%=B1F90Kt zZMR4((W#Ccg>=(Fw@hjM2=+!JV>rZjqsI;E*ipvuUNWJCTyU!0`HxI@(4~VVf9u%M zc<1^QJc=7hADRgs-J)Th!WR4H1N zbs}Tfwl-RH159DXb?`*dqDuesoNkK{rxY#P9cvRUs;#%^a$mD=d7hqcVVwn$ysiQ{ zt?;D^$jaeclr`{FZjURb3ubpCa?sPtegn5C*mi@k0I^{w$1@zo2T40rh~4By&X02K zjT~1wzKP>YsM>E%&+z(+iHb;_Ki85KMa0PuH1CabL7c-G!AsUUK9fo2AkC7ax!D#e z@SGEk7IridMdJSlgrPxD_h!@=DAkV1!xkk3m!%?Kx0GByl|zo~0^I_$ls&-FRyOH8 znN23b>dBzZ)6U5{-XW0O@Tuhy9M8^$RkTVQ`$bH*JMz>E%~ zlJmr_T9eZ+;5pqcVHS0rC+&aLLQjj|tg z`PQV#<=UNE?cc2S_wbI)Y=153T8Px_BkeuwRsbM()tg2wS>eS}3tx&q_|5L*e)i4r zEIn&)N8#O~&F$K-fOX z+GcZP-@Y+!1FiNpWPmOHe4Db@0+)c>fNMeEx;cT%dgT$sei?pF==eQ!P{-@`W&c`7 zR_O~mPTrnh&~b|1s^fQ|x9YeHy;a9INGdH}EVRy96nk35>mokRH>8AW5bfmxL<1?% zy7tmH?!q4}0kP#g7hdG=o>uExWk(K4SlW;tiVDmx7n8k+>cSM+u(y}jPZl9%*O%Mh z4bjOpyG^X-fXwUh@TBF&#@^;=HxvLMGEs zBw}}UyT6e-ift}^fHSwgC0Yk8+VOyC0o`9<4a!t^L2L06vMj3?GsQ}46n&iS2mld1 z0ldeG=`Erv?$090z{>sJZf?chmvh#ER`u@ucrp3jc;#X~{l4av!OSb$SQ+S@tSS*n z-g>N<-YkWHA2;QPidIj&luy2&=57}Fwu7V4)0_R(ZP5vXK(SwbKSPvJv|oOojG}M?>52XFdx4USqW$s~8AV-ZpyU7v zp>k2^iG6Llh(sxXC!^>g#FnHd_Crr(5*j_RUoQK@9iu3@>CjR4wb2v%T9Bx7{a~9M z`Hi0F-%z1`B|UMcmbtV;@T}Zf>xocvZ1jEWiL3xEee^_XT1ro(-=S6cM%Xq>`v~tT z_cGjjgm;NuqSB%&u;R#75}PbHC$_oB6)J|KhQQdif&JLn6-oey-JzA>KVrAmJhn;x z-e&nb9CnG9l3b6@!sOGrPEI)W`xzEoC#~nE9FkN+JN#(RWAF;;C%*UXEoKGOfl{w; zG1DLHG7)j8zQh`W`v<~9aqOKjswk9+3tHFKyG5BE0JI$LXRI7#TZXPlQBhBzch06O zx5%1IvIB&%^v*nRA9Hwdc7#-E#V7_$j=jqvMFT@D%%*~JI7=X)d8A`#QZi$!pbwMh zewa|+FSV|=W{cjc$T?dfNqmDa?8``K*sf-~f-b{CK0NFoE`ojb^t3OIV96q|4%{av z1=aFV97T7iyImbvGDok<$NzIuT(nR+44*D&aJekut7g{k=z;r|Ob=;wbNy&K9^)ty zgWOPDk~|Y@iWWoA1*EJBCr8k;U7|Xcp82bxgCChrd2l;CB|835pyLh)AZ7oeCbP?Q8<{E zIEC%YZA>_WQ}zm*VXpgl#>XF@{rGW-lFq;sA5zwG_zI;{fL$LO|GK*AD2qIv(8S>+ zgkwQC*$x#r-WoF^dcD26H3F_uD9W(PO8%$T&=y|bVVYtghpzDa_#Xun_Vgad0z>~O zSb%OID#V{L`xr{zJ(|13i6d1(VTim)jz-58$r^deH0>yX5Kc@WgxD7xU zHwNRUG+b<&^c{1pR(3gL#vm*cN&`1juI}8+Q+nV&;xGjH5MP8LGM(YP*hM_dDjgdA zVoo4R%2`$=iGI3MSMnM=**o{TW~fENUe)9)Gdw_%3$C9LGt*CDTSWb()vYTN(2dkT zW=Vrq4FDxQ?C$m6-UC$>m$@Wf2Cfju&4KzQH^#4$1C&AC(HlV+@WI{G>)qD-A-~(J zZtE@c7=|_KsPqo{7LBSEEgss8@E9$}^bs|?L(9|`ob2thg}A0(HYET?pI&GKr`o!c zHB5BWsxBQDSYPd~b2-*9UMgIOgJUC!->r%VspOUw65kk_#EKvp*q_Kyqj0r4Cpg8K zsh>>YCj59pOqT2u!JA3+trS_Y0`kx3k>t`;{Z(>}S~740=b_kkGwJ|+XDStw_ZXI$6Ccx^3%@K>}9t%4A4g?g;NrSTYtl_^7Rs!YN4*!4X@qF^b| zd-rc+z#HO(bzc?mbuC5F>Oz7X(o43af@mVBOcJ!CYt)ge5>v1X?>l zgQFR>ws4vmoQFA!#|=1DlrF(Z0~FMvwE%CY9o4*hZBK$tS1cPZCGh#`m}33&T2Iib z!};W3Ow3((F^cAe*naug44>Sw(y=d&twL_68|H_f%`9T&76 zUvX1T@wTlzQYvm4I>wrKR67EAGRFU_->8YBV75zRgyru>PtUZtdzF8&lf$0gEjuNK zvf$0=w5sQXQT6+QcifgkC|=g1pUZ;)tb5Ttzg0c&b$d?a;d`U!x84TU6sfMu8X;KO z`1jRcUER~WN4zV@gFARvbd4e0*$m-FI=z51M~C??75#%eH$=IpE;J=K9;C!dTVOX& zY)f%|0!H-JvmnY#)GyLOf9j+U1`C1^xA}gvEQBIB=F*h6-ZRM5f-Vx%knBS8q}V!C?v_OM z(n`ETo*$!5vfCPujQI^aWN^kH=G1+#Gm!7nm?wT69J$8ikkNsFU^_Lx5R3d<0!k6j z>qBNrCPzH4iMb^#I6~3c{t$;a+aDz8x<4EZj%Y!cXr5zxiqP1RFAbI;t)s$<0<#=q zT1UT`!GY=KjHHE_DIKa{0ww=(oArQj%b!w}u(<%(VS>(pPZ$=tKwINOXWKBct3cUku)jL!XO7Doo68#WSiYO@8r1XM( z#4lT&eKeb71>NPEMQ`W{nLeVM)4up*+e?EsmHB01WtPdzn&_R@CdGU*OZ!EO;s0z? zwxgzJYm|*mU=oUNm7b)3Qm1dw6)XaKv!55%Hj9?4s)wDpOkIRkykbdaSmBvtnD=Ja z36+Gt#H2yqL>PO?{4e~#CQ($oeVP4=#}U-bjolMLl|biV8_C7x+b2UV+tJGsOU8e$ zzZWM@5gttScUg{(P00&D2ovs0@?6S6>*L^uOpBZGP}e5*mD;j?MY&e>1ue&J9qP#_ zm@BhGWmCtO5^4`#X38|=7FT{DX{KyN2Bvr=&^fIxN5Cyi@>Wfi{sEcV0m2FFejnf? zp3L*6i9sO`9H}OMYF(YzubzKv909JX((c7BQ zPB$D$?dj}ZR3x>>+P$bqYEN+YB1KQr#df$dl|83U5zkEUbfI{URRlnFJ;Jg;!x0-6vD2)Y<=RD2$PUB$ZOXo~ zI?iA*NL~t22Og;R$6E7P2wx^J{%bMA1X1d70gt<^WkCCMt}+ot^*9$caik(H#*{2X?=LX@mAOO@a1pGsXG3VpX9n_Y#O9@iFop16 zV+!HF^aMW&0FwPAS^DYK3sJh2(evX%HkCnlsyG$enzyAta!I&*Mul6{yWRPV*#y6X z&hX~C%Bx>dMw*-h@yfhnmva$T+_-x7*=#!0TH?n|`MEm$nHy_S<&{m(SvFc1y>e(v z|9rVL+r;H=6ECMG=*g9@8hxHh<0l>#H1<3Z2}?xAV@fO=|9_q>svYr7S^5{K@~Toh zw{qqxuM5gDyqRD4h`GL-GoZSQ1DbNlfz@}TLw8{wZ2c)qKcP+52gO*Tm>@hGxwG zQKSxDElW2FXJ+0rT|mfjIO~!!eu0y0Zn7hao({_e+tyGUU^h@wv0+%ZAc+agq*nli zp9iP56sS~B8iwM+A#!F$JOp^3aWJH_M|bPCKkqK4m|wR|HX9i zP9cGc-Uly5Y*zFc{Dp0V@0%{+-`CCQrbNJ&qp%6hL9?VvgfvTqf+39_c=*Q!3g2`PNCMwsJ$(`8jv`9!(!({g7mRdXL<_SpMd5ReQG ztECg#+{vNG}2#ORkYCOP{_ZN6#rg($n0fO6+}UsYkO+eG%i zY|ZORLNK+?{-2NiJDIeei}|p~-y==wJ=#jvdkRtBMCBsLyo)*jkQS{BR?I<=HFt>! zg02wi_;9jn_W*)wOY{Vr&u6WfnHAX&OciEUJpiVSE=8@}AV<-4gxi6tb-cbp9fQA^ zLLHyWKxXxiF##sTXUio7Qz3G|;pj8zuff!HnX$z}f}7M;Vpch{P=O zNI%~%#T(&3g~yHM?6}0xwJ^Or`*k_xbQ4up3!+Gu&kPDU&QEqXOdG^Phf22Sl#t1n zqRPJ?hMPegL+Ns+e}g)?LKok zdb+_XB@zH+cNq~P{U82V3VOV1Co(}z3_^WOoyh7=aN$_5y)c9;m2b!2-Bu;anNiE z#oNgd+pZ)+WCe-Kz=VVB2wqVZRhK|VEuBEa-bBa*`Ehwxiqbbh%E9_nMut^JEB*1W zN`cXr6SF`WY2p#k&qyok%*va z+77S>{=P5!5C=>ggs-_EhiOV%)`81yDUO-K4kDka4hPGVB@Bru(K6^WY|!gRb6yv| z5Gw08#YdU`YISwp9#6)<4^0=M1_DDt{d(2#Y*bg&M6;q=X58k9P(|(V_ORWENDLTDD*8-Cz-5rX^%?Wgmp7b*UPGX&n$}_ z=#PaT+5JsTv;!F;2t|7uQDwlSzZJSB(d13$_paMglRgURBWI*Df`=$zMB?B$cHI+wOS9_W3fD4WQi4*_#kwPog{wQ#-iAE6uaceK1nk;7 z>s}0^3)zanGXj4?h(4d3Aar=w1jH&>qrqixgCGzId0N3k4oYp~wkyqf+n_-*8hs^m zC9Z}N0d;0vG)c7Le=v(?shY&sm9|*;sLj)-2GWr*N%Q*A( z!k-bYVd#=R_>*KD$-Cl2Vn^LeNrna*A@(5T2xhxEfRPb&8o~}#A4FtzCaevyhhaDk zBmvQ3gdyiZH29|F9ALr(4nxkN9f`lxZ)Nov>zT>vvT)GVd9zdU54VAloLyza*|mU6-5nO=)gR6mb@E}#2fm)2I_R(!%n?8Sn_K(x5?oygxC?MqV9k~Sj=>WnOfE;xw}eHSGJChNG9qwQqsRGd(ur{)N`-)o7!BSKYK{gEq($ zyO#%8YY(|mTD-0JD5p4nDd^(kA&z>--Veoe`fGJeXLNQZs>`zd0#jb>t z*>sMCbp4uZqB`X-(0exe*As>t;L4W%Wp)tD3^-cIKtrh)KZOj;TaIBYs#~?tBZQHu z!IoM8(&{)i65UJTiop1k+|{z59M4@z56BK2n2)=Nd?HR%CDs$a9Ri@B@B);c>Dn!vlU7!jP!jmbt1n#Ykh6~$tT2wNXjl5|Wu^XbB-Y;peVLsv_4^`; zskC&s(D%DeO0orV(PMJZ3Q9v1)e#=q)Vd{)7w-5;u4d!LCwa1mH?iq<8z~P;zwy2uDexSo^OKy*okD;=dTO{10Txrn zIh6Y#GCCEP_{u6zxk`uu-|0&Ag8K*1yB7(v=6SW!8Qx=vot}v9#iPx|cFgj3KId-X zJk$|C%_^v*?mtDB&*~cIXE}d9onPeqVmg10^Fw+b_VPI$;V-9j#HVvoM?4ZIbc9?U z*AW@=aUD^`**fy{K+?7Tv9m#{Fu}0tsPZ#Yt`m5FW℞fM^#9lzyP`B6?7#`Mb5_ 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 z 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 diff --git a/src/client/vendor/ghostty-web/ghostty-vt.wasm.json b/src/client/vendor/ghostty-web/ghostty-vt.wasm.json deleted file mode 100644 index 67f71ba..0000000 --- a/src/client/vendor/ghostty-web/ghostty-vt.wasm.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "file": "ghostty-vt.wasm", - "source": { - "npm": "ghostty-web@0.4.0-next.20.g1858a59", - "path": "ghostty-vt.wasm", - "repo": "https://github.com/coder/ghostty-web", - "commit": "1858a5947767a3e1c9e98dbf53b2ff87fedb2aab" - }, - "ghostty": { - "repo": "https://github.com/ghostty-org/ghostty", - "commit": "5714ed07a1012573261b7b7e3ed2add9c1504496", - "patch": "patches/ghostty-wasm-api.patch", - "patchSha256": "a579f64a371d64d8f139bc76fb76bff77549d406236ca1153a32b3ff0eb87b90" - }, - "license": { - "ghostty": "MIT (Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors; LICENSE.ghostty)", - "patch": "MIT (Copyright (c) 2025 Coder; LICENSE)" - }, - "size": 423939, - "sha256": "794191ccd9f469ed0d49ebcb3e6b326d64279b91752b71ad80a3d89ff1f26416" -} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 3baa523..6232c93 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -5,10 +5,10 @@ * The key optimization is the RenderState API which provides a pre-computed * snapshot of all render data in a single update call. * - * webtty (ADR 042): two engines behind one `GhosttyTerminal` interface. The Coder - * engine (`CoderTerminal`) runs ghostty-web's patched wasm; the official engine - * (`OfficialTerminal`) runs Ghostty's unpatched libghostty-vt wasm. Every wasm - * export call and wasm memory access for both engines is in this file. + * webtty (ADR 041, ADR 042, ADR 047): the binding to Ghostty's official, + * unpatched libghostty-vt wasm. `OfficialTerminal` implements the `GhosttyTerminal` + * interface the rest of the terminal layer uses. Every wasm export call and wasm + * memory access is in this file. */ import { FnTable, type InstalledFn, WasmValType } from './fn-table'; @@ -16,10 +16,8 @@ import { CellFlags, type Cursor, DirtyState, - GHOSTTY_CONFIG_SIZE, type GhosttyCell, type GhosttyTerminalConfig, - type GhosttyWasmExports, Key, KeyAction, KeyEncoderOption, @@ -28,7 +26,6 @@ import { type RGB, type RenderStateColors, type RenderStateCursor, - type TerminalHandle, } from './types'; // Re-export types for convenience @@ -44,9 +41,6 @@ export { type RenderStateCursor, }; -/** The terminal engines (ADR 042). */ -export type EngineName = 'coder' | 'official'; - /** Bell and title changes since the last `readEffects` (ADR 042). */ export interface TerminalEffects { bell: boolean; @@ -55,9 +49,9 @@ export interface TerminalEffects { /** * The terminal surface the renderer, selection, buffer, input handler and - * terminal.ts use. Rows are in the Coder engine's coordinates on both engines: - * `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` use active-screen - * rows, and `getScrollbackLine(i)` counts from the oldest scrollback row. + * terminal.ts use. `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` + * use active-screen rows, and `getScrollbackLine(i)` counts from the oldest + * scrollback row. */ export interface GhosttyTerminal { readonly cols: number; @@ -67,15 +61,15 @@ export interface GhosttyTerminal { // Lifecycle write(data: string | Uint8Array): void; - /** Grid resize; the device-pixel cell size is used by the official engine only. */ + /** Grid resize, with the device-pixel cell size for size reports when known. */ resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void; - /** Device-pixel cell size for size reports. A no-op on the Coder engine. */ + /** Device-pixel cell size for size reports. */ setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void; /** * The view's `viewportY` (lines scrolled up from the bottom). The renderer calls - * it first in every render. Only the official engine implements it. + * it first in every render. */ - setViewportOffset?(linesFromBottom: number): void; + setViewportOffset(linesFromBottom: number): void; free(): void; // Render state @@ -112,7 +106,7 @@ export interface GhosttyTerminal { // Replies and effects hasResponse(): boolean; readResponse(): string | null; - /** Bell and title changes since the last call. The Coder engine reports none. */ + /** Bell and title changes since the last call. */ readEffects(): TerminalEffects; } @@ -124,885 +118,8 @@ export interface KeyEncoder { dispose(): void; } -/** - * A loaded engine: one wasm instance that creates terminals and key encoders. - */ -export abstract class Ghostty { - abstract readonly engine: EngineName; - - abstract createKeyEncoder(): KeyEncoder; - - abstract createTerminal(cols?: number, rows?: number, config?: GhosttyTerminalConfig): GhosttyTerminal; - - static async load(wasmPath?: string, engine: EngineName = 'coder'): Promise { - // If explicit path provided, use it - if (wasmPath) { - return Ghostty.loadFromPath(wasmPath, engine); - } - - // Resolve path relative to this module - const moduleUrl = new URL( - engine === 'official' ? '../ghostty-vt/ghostty-vt-small.wasm' : '../ghostty-vt.wasm', - import.meta.url - ); - - // Build paths to try, prioritizing file system paths for Node/Bun - const defaultPaths: string[] = []; - - // For Node/Bun: try absolute file path first (strip file:// protocol) - if (moduleUrl.protocol === 'file:') { - let filePath = moduleUrl.pathname; - // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) - if (filePath.match(/^\/[A-Za-z]:\//)) { - filePath = filePath.slice(1); - } - defaultPaths.push(filePath); - } - - // Also try other common paths - defaultPaths.push(moduleUrl.href, './ghostty-vt.wasm', '/ghostty-vt.wasm'); - - let lastError: Error | null = null; - for (const path of defaultPaths) { - try { - return await Ghostty.loadFromPath(path, engine); - } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)); - } - } - throw lastError || new Error('Failed to load Ghostty WASM'); - } - - private static async loadFromPath(path: string, engine: EngineName): Promise { - let wasmBytes: ArrayBuffer | undefined; - - // Try Bun.file first (for Bun environments) - if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { - try { - const file = Bun.file(path); - if (await file.exists()) { - wasmBytes = await file.arrayBuffer(); - } - } catch { - // Bun.file failed, try next method - } - } - - // Try Node.js fs module if Bun.file didn't work - if (!wasmBytes) { - try { - const fs = await import('fs/promises'); - const buffer = await fs.readFile(path); - wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); - } catch { - // fs failed, try fetch - } - } - - // Fall back to fetch (for browser environments) - if (!wasmBytes) { - const response = await fetch(path); - if (!response.ok) { - throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); - } - wasmBytes = await response.arrayBuffer(); - if (wasmBytes.byteLength === 0) { - throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); - } - } - - if (!wasmBytes) { - throw new Error(`Could not load WASM from path: ${path}`); - } - - const wasmModule = await WebAssembly.compile(wasmBytes); - if (engine === 'official') { - // The official wasm imports nothing. - const instance = await WebAssembly.instantiate(wasmModule, {}); - return new OfficialGhostty(instance.exports as unknown as OfficialWasmExports); - } - const wasmInstance = await WebAssembly.instantiate(wasmModule, { - env: { - log: (ptr: number, len: number) => { - const bytes = new Uint8Array( - (wasmInstance.exports as GhosttyWasmExports).memory.buffer, - ptr, - len - ); - console.log('[ghostty-vt]', new TextDecoder().decode(bytes)); - }, - }, - }); - return new CoderGhostty(wasmInstance); - } -} - -/** - * The Coder engine: ghostty-web's patched wasm. - */ -class CoderGhostty extends Ghostty { - readonly engine = 'coder' as const; - private exports: GhosttyWasmExports; - private memory: WebAssembly.Memory; - - constructor(wasmInstance: WebAssembly.Instance) { - super(); - this.exports = wasmInstance.exports as GhosttyWasmExports; - this.memory = this.exports.memory; - } - - createKeyEncoder(): KeyEncoder { - return new CoderKeyEncoder(this.exports); - } - - createTerminal( - cols: number = 80, - rows: number = 24, - config?: GhosttyTerminalConfig - ): GhosttyTerminal { - return new CoderTerminal(this.exports, this.memory, cols, rows, config); - } -} - -/** - * Key Encoder - converts keyboard events into terminal escape sequences - */ -class CoderKeyEncoder implements KeyEncoder { - private exports: GhosttyWasmExports; - private encoder: number = 0; - - constructor(exports: GhosttyWasmExports) { - this.exports = exports; - const encoderPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); - const result = this.exports.ghostty_key_encoder_new(0, encoderPtrPtr); - if (result !== 0) throw new Error(`Failed to create key encoder: ${result}`); - const view = new DataView(this.exports.memory.buffer); - this.encoder = view.getUint32(encoderPtrPtr, true); - this.exports.ghostty_wasm_free_opaque(encoderPtrPtr); - } - - setOption(option: KeyEncoderOption, value: boolean | number): void { - const valuePtr = this.exports.ghostty_wasm_alloc_u8(); - const view = new DataView(this.exports.memory.buffer); - view.setUint8(valuePtr, typeof value === 'boolean' ? (value ? 1 : 0) : value); - this.exports.ghostty_key_encoder_setopt(this.encoder, option, valuePtr); - this.exports.ghostty_wasm_free_u8(valuePtr); - } - - setKittyFlags(flags: KittyKeyFlags): void { - this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags); - } - - encode(event: KeyEvent): Uint8Array { - const eventPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); - const createResult = this.exports.ghostty_key_event_new(0, eventPtrPtr); - if (createResult !== 0) throw new Error(`Failed to create key event: ${createResult}`); - - const view = new DataView(this.exports.memory.buffer); - const eventPtr = view.getUint32(eventPtrPtr, true); - this.exports.ghostty_wasm_free_opaque(eventPtrPtr); - - this.exports.ghostty_key_event_set_action(eventPtr, event.action); - this.exports.ghostty_key_event_set_key(eventPtr, event.key); - this.exports.ghostty_key_event_set_mods(eventPtr, event.mods); - - if (event.utf8) { - const encoder = new TextEncoder(); - const utf8Bytes = encoder.encode(event.utf8); - const utf8Ptr = this.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length); - new Uint8Array(this.exports.memory.buffer).set(utf8Bytes, utf8Ptr); - this.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length); - this.exports.ghostty_wasm_free_u8_array(utf8Ptr, utf8Bytes.length); - } - - const bufferSize = 32; - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufferSize); - const writtenPtr = this.exports.ghostty_wasm_alloc_usize(); - - const encodeResult = this.exports.ghostty_key_encoder_encode( - this.encoder, - eventPtr, - bufPtr, - bufferSize, - writtenPtr - ); - - if (encodeResult !== 0) { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); - this.exports.ghostty_wasm_free_usize(writtenPtr); - this.exports.ghostty_key_event_free(eventPtr); - throw new Error(`Failed to encode key: ${encodeResult}`); - } - - const bytesWritten = view.getUint32(writtenPtr, true); - const encoded = new Uint8Array(this.exports.memory.buffer, bufPtr, bytesWritten).slice(); - - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); - this.exports.ghostty_wasm_free_usize(writtenPtr); - this.exports.ghostty_key_event_free(eventPtr); - - return encoded; - } - - dispose(): void { - if (this.encoder) { - this.exports.ghostty_key_encoder_free(this.encoder); - this.encoder = 0; - } - } -} - -/** - * GhosttyTerminal - High-performance terminal emulator - * - * Uses Ghostty's native RenderState for optimal performance: - * - ONE call to update all state (renderStateUpdate) - * - ONE call to get all cells (getViewport) - * - No per-row WASM boundary crossings! - */ -export class CoderTerminal implements GhosttyTerminal { - readonly answersSizeQueries = false; - private exports: GhosttyWasmExports; - private memory: WebAssembly.Memory; - private handle: TerminalHandle; - private _cols: number; - private _rows: number; - - /** Size of GhosttyCell in WASM (16 bytes) */ - private static readonly CELL_SIZE = 16; - - /** Reusable buffer for viewport operations */ - private viewportBufferPtr: number = 0; - private viewportBufferSize: number = 0; - - /** Cell pool for zero-allocation rendering */ - private cellPool: GhosttyCell[] = []; - - constructor( - exports: GhosttyWasmExports, - memory: WebAssembly.Memory, - cols: number = 80, - rows: number = 24, - config?: GhosttyTerminalConfig - ) { - this.exports = exports; - this.memory = memory; - this._cols = cols; - this._rows = rows; - - if (config) { - // Allocate config struct in WASM memory - const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); - if (configPtr === 0) { - throw new Error('Failed to allocate config (out of memory)'); - } - - try { - // Write config to WASM memory - const view = new DataView(this.memory.buffer); - let offset = configPtr; - - // scrollback_limit (u32) - view.setUint32(offset, config.scrollbackLimit ?? 10000, true); - offset += 4; - - // fg_color (u32) - view.setUint32(offset, config.fgColor ?? 0, true); - offset += 4; - - // bg_color (u32) - view.setUint32(offset, config.bgColor ?? 0, true); - offset += 4; - - // cursor_color (u32) - view.setUint32(offset, config.cursorColor ?? 0, true); - offset += 4; - - // palette[16] (u32 * 16) - for (let i = 0; i < 16; i++) { - view.setUint32(offset, config.palette?.[i] ?? 0, true); - offset += 4; - } - - this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); - } finally { - // Free the config memory - this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); - } - } else { - this.handle = this.exports.ghostty_terminal_new(cols, rows); - } - - if (!this.handle) throw new Error('Failed to create terminal'); - - this.initCellPool(); - } - - get cols(): number { - return this._cols; - } - get rows(): number { - return this._rows; - } - - // ========================================================================== - // Lifecycle - // ========================================================================== - - write(data: string | Uint8Array): void { - const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; - const ptr = this.exports.ghostty_wasm_alloc_u8_array(bytes.length); - new Uint8Array(this.memory.buffer).set(bytes, ptr); - this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); - this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); - } - - /** The pixel size is for the official engine; the Coder wasm has no use for it. */ - resize(cols: number, rows: number, _cellWidthPx?: number, _cellHeightPx?: number): void { - if (cols === this._cols && rows === this._rows) return; - this._cols = cols; - this._rows = rows; - this.exports.ghostty_terminal_resize(this.handle, cols, rows); - this.invalidateBuffers(); - this.initCellPool(); - } - - /** The Coder wasm does not answer size queries (kitty.ts does), so it needs no pixels. */ - setCellPixelSize(_cellWidthPx: number, _cellHeightPx: number): void {} - - /** The Coder wasm reports no effects; terminal.ts scans the stream for BEL and OSC 0/2. */ - readEffects(): TerminalEffects { - return { bell: false, title: null }; - } - - free(): void { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - this.viewportBufferPtr = 0; - } - this.exports.ghostty_terminal_free(this.handle); - } - - // ========================================================================== - // RenderState API - The key performance optimization - // ========================================================================== - - /** - * Update render state from terminal. - * - * This syncs the RenderState with the current Terminal state. - * The dirty state (full/partial/none) is stored in the WASM RenderState - * and can be queried via isRowDirty(). When dirty==full, isRowDirty() - * returns true for ALL rows. - * - * The WASM layer automatically detects screen switches (normal <-> alternate) - * and returns FULL dirty state when switching screens (e.g., vim exit). - * - * Safe to call multiple times - dirty state persists until markClean(). - */ - update(): DirtyState { - return this.exports.ghostty_render_state_update(this.handle) as DirtyState; - } - - /** - * Get cursor state from render state. - * Ensures render state is fresh by calling update(). - */ - getCursor(): RenderStateCursor { - // Call update() to ensure render state is fresh. - // This is safe to call multiple times - dirty state persists until markClean(). - this.update(); - return { - x: this.exports.ghostty_render_state_get_cursor_x(this.handle), - y: this.exports.ghostty_render_state_get_cursor_y(this.handle), - viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle), - viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle), - visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle), - blinking: false, // TODO: Add blinking support - style: 'block', // TODO: Add style support - }; - } - - /** - * Get default colors from render state - */ - getColors(): RenderStateColors { - const bg = this.exports.ghostty_render_state_get_bg_color(this.handle); - const fg = this.exports.ghostty_render_state_get_fg_color(this.handle); - return { - background: { - r: (bg >> 16) & 0xff, - g: (bg >> 8) & 0xff, - b: bg & 0xff, - }, - foreground: { - r: (fg >> 16) & 0xff, - g: (fg >> 8) & 0xff, - b: fg & 0xff, - }, - cursor: null, // TODO: Add cursor color support - }; - } - - /** - * Check if a specific row is dirty - */ - isRowDirty(y: number): boolean { - return this.exports.ghostty_render_state_is_row_dirty(this.handle, y); - } - - /** - * Mark render state as clean (call after rendering) - */ - markClean(): void { - this.exports.ghostty_render_state_mark_clean(this.handle); - } - - /** - * Get ALL viewport cells in ONE WASM call - the key performance optimization! - * Returns a reusable cell array (zero allocation after warmup). - */ - getViewport(): GhosttyCell[] { - const totalCells = this._cols * this._rows; - const neededSize = totalCells * CoderTerminal.CELL_SIZE; - - // Ensure buffer is allocated - if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - } - this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); - this.viewportBufferSize = neededSize; - } - - // Get all cells in one call - const count = this.exports.ghostty_render_state_get_viewport( - this.handle, - this.viewportBufferPtr, - totalCells - ); - - if (count < 0) return this.cellPool; - - // Parse cells into pool (reuses existing objects) - this.parseCellsIntoPool(this.viewportBufferPtr, totalCells); - return this.cellPool; - } - - // ========================================================================== - // Compatibility methods (delegate to render state) - // ========================================================================== - - /** - * Get line - for compatibility, extracts from viewport. - * Ensures render state is fresh by calling update(). - * Returns a COPY of the cells to avoid pool reference issues. - */ - getLine(y: number): GhosttyCell[] | null { - if (y < 0 || y >= this._rows) return null; - // Call update() to ensure render state is fresh. - // This is safe to call multiple times - dirty state persists until markClean(). - this.update(); - const viewport = this.getViewport(); - const start = y * this._cols; - // Return deep copies to avoid cell pool reference issues - return viewport.slice(start, start + this._cols).map((cell) => ({ ...cell })); - } - - /** For compatibility with old API */ - isDirty(): boolean { - return this.update() !== DirtyState.NONE; - } - - /** - * Check if a full redraw is needed (screen change, resize, etc.) - * Note: This calls update() to ensure fresh state. Safe to call multiple times. - */ - needsFullRedraw(): boolean { - return this.update() === DirtyState.FULL; - } - - /** Mark render state as clean after rendering */ - clearDirty(): void { - this.markClean(); - } - - // ========================================================================== - // Terminal modes - // ========================================================================== - - isAlternateScreen(): boolean { - return !!this.exports.ghostty_terminal_is_alternate_screen(this.handle); - } - - hasBracketedPaste(): boolean { - // Mode 2004 = bracketed paste (DEC mode) - return this.getMode(2004, false); - } - - hasFocusEvents(): boolean { - // Mode 1004 = focus events (DEC mode) - return this.getMode(1004, false); - } - - hasMouseTracking(): boolean { - return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; - } - - // ========================================================================== - // Extended API (scrollback, modes, etc.) - // ========================================================================== - - /** Get dimensions - for compatibility */ - getDimensions(): { cols: number; rows: number } { - return { cols: this._cols, rows: this._rows }; - } - - /** Get number of scrollback lines (history, not including active screen) */ - getScrollbackLength(): number { - return this.exports.ghostty_terminal_get_scrollback_length(this.handle); - } - - /** - * Get a line from the scrollback buffer. - * Ensures render state is fresh by calling update(). - * @param offset 0 = oldest line, (length-1) = most recent scrollback line - */ - getScrollbackLine(offset: number): GhosttyCell[] | null { - const neededSize = this._cols * CoderTerminal.CELL_SIZE; - - // Ensure buffer is allocated - if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - } - this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); - this.viewportBufferSize = neededSize; - } - - // Call update() to ensure render state is fresh (needed for colors). - // This is safe to call multiple times - dirty state persists until markClean(). - this.update(); - - const count = this.exports.ghostty_terminal_get_scrollback_line( - this.handle, - offset, - this.viewportBufferPtr, - this._cols - ); - - if (count < 0) return null; - - // Parse cells - const cells: GhosttyCell[] = []; - const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * CoderTerminal.CELL_SIZE); - const view = new DataView(buffer, this.viewportBufferPtr, count * CoderTerminal.CELL_SIZE); - - for (let i = 0; i < count; i++) { - const cellOffset = i * CoderTerminal.CELL_SIZE; - cells.push({ - codepoint: view.getUint32(cellOffset, true), - fg_r: u8[cellOffset + 4], - fg_g: u8[cellOffset + 5], - fg_b: u8[cellOffset + 6], - bg_r: u8[cellOffset + 7], - bg_g: u8[cellOffset + 8], - bg_b: u8[cellOffset + 9], - flags: u8[cellOffset + 10], - width: u8[cellOffset + 11], - hyperlink_id: view.getUint16(cellOffset + 12, true), - grapheme_len: u8[cellOffset + 14], - }); - } - - return cells; - } - - /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ - isRowWrapped(row: number): boolean { - return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; - } - - /** - * Get the hyperlink URI for a cell at the given position. - * @param row Row index (0-based, in active viewport) - * @param col Column index (0-based) - * @returns The URI string, or null if no hyperlink at that position - */ - getHyperlinkUri(row: number, col: number): string | null { - // Check if WASM has this function (requires rebuilt WASM with hyperlink support) - if (!this.exports.ghostty_terminal_get_hyperlink_uri) { - return null; - } - - // Try with initial buffer, retry with larger if needed (for very long URLs) - const bufferSizes = [2048, 8192, 32768]; - - for (const bufSize of bufferSizes) { - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - - try { - const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri( - this.handle, - row, - col, - bufPtr, - bufSize - ); - - // 0 means no hyperlink at this position - if (bytesWritten === 0) return null; - - // -1 means buffer too small, try next size - if (bytesWritten === -1) continue; - - // Negative values other than -1 are errors - if (bytesWritten < 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } - } - - // URI too long even for largest buffer - return null; - } - - /** - * Get the hyperlink URI for a cell in the scrollback buffer. - * @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) - * @param col Column index (0-based) - * @returns The URI string, or null if no hyperlink at that position - */ - getScrollbackHyperlinkUri(offset: number, col: number): string | null { - // Check if WASM has this function - if (!this.exports.ghostty_terminal_get_scrollback_hyperlink_uri) { - return null; - } - - // Try with initial buffer, retry with larger if needed (for very long URLs) - const bufferSizes = [2048, 8192, 32768]; - - for (const bufSize of bufferSizes) { - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - - try { - const bytesWritten = this.exports.ghostty_terminal_get_scrollback_hyperlink_uri( - this.handle, - offset, - col, - bufPtr, - bufSize - ); - - // 0 means no hyperlink at this position - if (bytesWritten === 0) return null; - - // -1 means buffer too small, try next size - if (bytesWritten === -1) continue; - - // Negative values other than -1 are errors - if (bytesWritten < 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } - } - - // URI too long even for largest buffer - return null; - } - - /** - * Check if there are pending responses from the terminal. - * Responses are generated by escape sequences like DSR (Device Status Report). - */ - hasResponse(): boolean { - return this.exports.ghostty_terminal_has_response(this.handle); - } - - /** - * Read pending responses from the terminal. - * Returns the response string, or null if no responses pending. - * - * Responses are generated by escape sequences that require replies: - * - DSR 6 (cursor position): Returns \x1b[row;colR - * - DSR 5 (operating status): Returns \x1b[0n - */ - readResponse(): string | null { - if (!this.hasResponse()) return null; - - const bufSize = 256; // Most responses are small - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - - try { - const bytesRead = this.exports.ghostty_terminal_read_response(this.handle, bufPtr, bufSize); - - if (bytesRead <= 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesRead); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } - } - - /** - * Query arbitrary terminal mode by number - * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) - * @param isAnsi True for ANSI modes, false for DEC modes (default: false) - */ - getMode(mode: number, isAnsi: boolean = false): boolean { - return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi) !== 0; - } - - // ========================================================================== - // Private helpers - // ========================================================================== - - private initCellPool(): void { - const total = this._cols * this._rows; - if (this.cellPool.length < total) { - for (let i = this.cellPool.length; i < total; i++) { - this.cellPool.push({ - codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, - bg_r: 0, - bg_g: 0, - bg_b: 0, - flags: 0, - width: 1, - hyperlink_id: 0, - grapheme_len: 0, - }); - } - } - } - - private parseCellsIntoPool(ptr: number, count: number): void { - const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, ptr, count * CoderTerminal.CELL_SIZE); - const view = new DataView(buffer, ptr, count * CoderTerminal.CELL_SIZE); - - for (let i = 0; i < count; i++) { - const offset = i * CoderTerminal.CELL_SIZE; - const cell = this.cellPool[i]; - cell.codepoint = view.getUint32(offset, true); - cell.fg_r = u8[offset + 4]; - cell.fg_g = u8[offset + 5]; - cell.fg_b = u8[offset + 6]; - cell.bg_r = u8[offset + 7]; - cell.bg_g = u8[offset + 8]; - cell.bg_b = u8[offset + 9]; - cell.flags = u8[offset + 10]; - cell.width = u8[offset + 11]; - cell.hyperlink_id = view.getUint16(offset + 12, true); - cell.grapheme_len = u8[offset + 14]; // grapheme_len is at byte 14 - } - } - - /** Small buffer for grapheme lookups (reused to avoid allocation) */ - private graphemeBuffer: Uint32Array | null = null; - private graphemeBufferPtr: number = 0; - - /** - * Get all codepoints for a grapheme cluster at the given position. - * For most cells this returns a single codepoint, but for complex scripts - * (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints. - * @returns Array of codepoints, or null on error - */ - getGrapheme(row: number, col: number): number[] | null { - // Allocate buffer on first use (16 codepoints should be enough for any grapheme) - if (!this.graphemeBuffer) { - this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); - this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); - } - - const count = this.exports.ghostty_render_state_get_grapheme( - this.handle, - row, - col, - this.graphemeBufferPtr, - 16 - ); - - if (count < 0) return null; - - // Re-create view in case memory grew - const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); - return Array.from(view); - } - - /** - * Get a string representation of the grapheme at the given position. - * This properly handles complex scripts like Hindi, emoji with ZWJ, etc. - */ - getGraphemeString(row: number, col: number): string { - const codepoints = this.getGrapheme(row, col); - if (!codepoints || codepoints.length === 0) return ' '; - return String.fromCodePoint(...codepoints); - } - - /** - * Get all codepoints for a grapheme cluster in the scrollback buffer. - * @param offset Scrollback line offset (0 = oldest) - * @param col Column index - * @returns Array of codepoints, or null on error - */ - getScrollbackGrapheme(offset: number, col: number): number[] | null { - // Reuse the same buffer as getGrapheme - if (!this.graphemeBuffer) { - this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); - this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); - } - - const count = this.exports.ghostty_terminal_get_scrollback_grapheme( - this.handle, - offset, - col, - this.graphemeBufferPtr, - 16 - ); - - if (count < 0) return null; - - // Re-create view in case memory grew - const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); - return Array.from(view); - } - - /** - * Get a string representation of a grapheme in the scrollback buffer. - */ - getScrollbackGraphemeString(offset: number, col: number): string { - const codepoints = this.getScrollbackGrapheme(offset, col); - if (!codepoints || codepoints.length === 0) return ' '; - return String.fromCodePoint(...codepoints); - } - - private invalidateBuffers(): void { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - this.viewportBufferPtr = 0; - this.viewportBufferSize = 0; - } - if (this.graphemeBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.graphemeBufferPtr, 16 * 4); - this.graphemeBufferPtr = 0; - } - this.graphemeBuffer = null; - } -} - // ============================================================================ -// Official libghostty-vt engine (ADR 042) +// Official libghostty-vt binding (ADR 041, ADR 042) // ============================================================================ // // Rules (ADR 041): @@ -1445,10 +562,10 @@ function resolveOfficialLayout(layout: VtLayout) { type OfficialLayout = ReturnType; /** - * The official engine: Ghostty's unpatched libghostty-vt wasm (ADR 041, ADR 042). + * A loaded libghostty-vt wasm instance (ADR 041, ADR 042): creates terminals and + * key encoders. */ -export class OfficialGhostty extends Ghostty { - readonly engine = 'official' as const; +export class Ghostty { /** @internal The wasm exports; only this file calls them. */ readonly ex: OfficialWasmExports; /** @internal */ @@ -1467,7 +584,6 @@ export class OfficialGhostty extends Ghostty { readonly libraryVersion: string; constructor(exports: OfficialWasmExports) { - super(); this.ex = exports; const p = exports.ghostty_type_json(); const u8 = new Uint8Array(exports.memory.buffer); @@ -1492,6 +608,90 @@ export class OfficialGhostty extends Ghostty { this.unidentifiedKey = layout.value('GhosttyKey', 'UNIDENTIFIED'); } + static async load(wasmPath?: string): Promise { + // If explicit path provided, use it + if (wasmPath) { + return Ghostty.loadFromPath(wasmPath); + } + + // Resolve path relative to this module + const moduleUrl = new URL('../ghostty-vt/ghostty-vt-small.wasm', import.meta.url); + + // Build paths to try, prioritizing file system paths for Node/Bun + const defaultPaths: string[] = []; + + // For Node/Bun: try absolute file path first (strip file:// protocol) + if (moduleUrl.protocol === 'file:') { + let filePath = moduleUrl.pathname; + // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) + if (filePath.match(/^\/[A-Za-z]:\//)) { + filePath = filePath.slice(1); + } + defaultPaths.push(filePath); + } + + // Also try other common paths + defaultPaths.push(moduleUrl.href, './ghostty-vt-small.wasm', '/ghostty-vt-small.wasm'); + + let lastError: Error | null = null; + for (const path of defaultPaths) { + try { + return await Ghostty.loadFromPath(path); + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + } + } + throw lastError || new Error('Failed to load Ghostty WASM'); + } + + private static async loadFromPath(path: string): Promise { + let wasmBytes: ArrayBuffer | undefined; + + // Try Bun.file first (for Bun environments) + if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { + try { + const file = Bun.file(path); + if (await file.exists()) { + wasmBytes = await file.arrayBuffer(); + } + } catch { + // Bun.file failed, try next method + } + } + + // Try Node.js fs module if Bun.file didn't work + if (!wasmBytes) { + try { + const fs = await import('fs/promises'); + const buffer = await fs.readFile(path); + wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } catch { + // fs failed, try fetch + } + } + + // Fall back to fetch (for browser environments) + if (!wasmBytes) { + const response = await fetch(path); + if (!response.ok) { + throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); + } + wasmBytes = await response.arrayBuffer(); + if (wasmBytes.byteLength === 0) { + throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); + } + } + + if (!wasmBytes) { + throw new Error(`Could not load WASM from path: ${path}`); + } + + const wasmModule = await WebAssembly.compile(wasmBytes); + // The official wasm imports nothing. + const instance = await WebAssembly.instantiate(wasmModule, {}); + return new Ghostty(instance.exports as unknown as OfficialWasmExports); + } + createKeyEncoder(): KeyEncoder { return new OfficialKeyEncoder(this); } @@ -1649,7 +849,7 @@ function isKittyKeyboardReply(reply: Uint8Array): boolean { export class OfficialTerminal implements GhosttyTerminal { readonly answersSizeQueries = true; - private readonly vt: OfficialGhostty; + private readonly vt: Ghostty; private readonly ex: OfficialWasmExports; private readonly L: OfficialLayout; private term = 0; @@ -1740,7 +940,7 @@ export class OfficialTerminal implements GhosttyTerminal { private titleChanged = false; private pendingTitle: string | null = null; - constructor(vt: OfficialGhostty, cols: number, rows: number, config?: GhosttyTerminalConfig) { + constructor(vt: Ghostty, cols: number, rows: number, config?: GhosttyTerminalConfig) { this.vt = vt; this.ex = vt.ex; this.L = vt.L; @@ -2232,7 +1432,8 @@ export class OfficialTerminal implements GhosttyTerminal { 3, true ); - // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, as the Coder wasm answers. + // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, the replies webtty gave on + // the Coder wasm (without it, Ghostty's secondary DA is ESC[>1;0;0c). install( L.opt.DEVICE_ATTRIBUTES, 'DEVICE_ATTRIBUTES', @@ -2793,13 +1994,13 @@ export class OfficialTerminal implements GhosttyTerminal { * `KeyEncoderOption` values are translated by name to the official enums. */ class OfficialKeyEncoder implements KeyEncoder { - private readonly vt: OfficialGhostty; + private readonly vt: Ghostty; private readonly ex: OfficialWasmExports; private encoder: number; private readonly value: number; private readonly written: number; - constructor(vt: OfficialGhostty) { + constructor(vt: Ghostty) { this.vt = vt; this.ex = vt.ex; this.encoder = vt.newHandle('key_encoder_new', (slot) => this.ex.ghostty_key_encoder_new(0, slot)); diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index ae3f632..0e12003 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -73,16 +73,14 @@ export type { // Ghostty WASM components (for advanced usage) export { Ghostty, - CoderTerminal, - OfficialGhostty, OfficialTerminal, CellFlags, DirtyState, KeyEncoderOption, } from './ghostty'; -export type { EngineName, GhosttyTerminal, KeyEncoder, TerminalEffects } from './ghostty'; +export type { GhosttyTerminal, KeyEncoder, TerminalEffects } from './ghostty'; export { Key, KeyAction, Mods } from './types'; -export type { KeyEvent, GhosttyCell, RGB, Cursor, TerminalHandle } from './types'; +export type { KeyEvent, GhosttyCell, RGB, Cursor } from './types'; // Low-level components (for custom integrations) export { CanvasRenderer } from './renderer'; diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index a16b33e..9e29335 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -25,8 +25,8 @@ export interface IRenderable { getViewport?(): GhosttyCell[]; /** * The view's `viewportY` (lines scrolled up from the bottom). `render` calls it - * first, so every render path syncs the engine's viewport in one place. Only the - * official engine implements it. (webtty, ADR 042) + * first, so every render path syncs the terminal's view offset in one place. + * Optional for buffers that cannot scroll. (webtty, ADR 042) */ setViewportOffset?(linesFromBottom: number): void; getCursor(): { x: number; y: number; visible: boolean }; @@ -488,7 +488,7 @@ export class CanvasRenderer { scrollbackProvider?: IScrollbackProvider, scrollbarOpacity: number = 1 ): void { - // Move the engine's viewport to the view before any read (webtty, ADR 042) + // Record the view's offset before any read (webtty, ADR 042) buffer.setViewportOffset?.(viewportY); // Store buffer reference for grapheme lookups in renderCell diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 0bec96b..f77d548 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -74,8 +74,9 @@ const DECSCUSR_PREFIX = /\x1b(?:\[\d{0,3} ?)?$/; /** * Scans output for DECSCUSR and returns the last Ps from 0 to 6 (undefined if * none), plus the unfinished sequence at the end of the chunk to pass back in as - * `carry` with the next one. (webtty: the Coder wasm applies DECSCUSR but does not - * export the cursor style, so it is read from the output here.) + * `carry` with the next one. (webtty: the renderer draws the cursor from these + * options, not from Ghostty's render-state cursor style, so the style is read from + * the output here; ADR 013, ADR 047.) */ export function scanDecscusr( data: string, @@ -103,7 +104,7 @@ export function decscusrShape(ps: number): CursorShape | undefined { } /** - * A cell's size in whole device pixels, for the official engine's size reports + * A cell's size in whole device pixels, for Ghostty's size reports * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as kitty.ts's `deviceCellSize` * does for its own replies: the CSS cell times `devicePixelRatio`, rounded, at * least 1. (webtty, ADR 042) @@ -557,7 +558,7 @@ export class Terminal implements ITerminalCore { // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); - // Cell pixels for the official engine's size reports, before the first write, + // Cell pixels for Ghostty's size reports, before the first write, // so a reply never says 0 px. Mode 2048 cannot be on yet. (webtty, ADR 042) this.syncCellPixelSize(); @@ -699,19 +700,10 @@ export class Terminal implements ITerminalCore { // These need to be sent back to the PTY via onData this.processTerminalResponses(); - // Check for bell character (BEL, \x07) - // The Coder wasm doesn't expose bell events, so we detect it in the data stream. - // The official engine reports bell and title itself. (webtty, ADR 042) - const scanStream = this.ghostty?.engine !== 'official'; - if (!scanStream) { - const effects = this.wasmTerm!.readEffects(); - if (effects.bell) this.bellEmitter.fire(); - if (effects.title !== null) this.setTitle(effects.title); - } else if (typeof data === 'string' && data.includes('\x07')) { - this.bellEmitter.fire(); - } else if (data instanceof Uint8Array && data.includes(0x07)) { - this.bellEmitter.fire(); - } + // Bell and title (OSC 0/2) as Ghostty reported them (webtty, ADR 042) + const effects = this.wasmTerm!.readEffects(); + if (effects.bell) this.bellEmitter.fire(); + if (effects.title !== null) this.setTitle(effects.title); // DECSCUSR: the app's cursor shape (webtty) const decscusr = scanDecscusr( @@ -732,12 +724,6 @@ export class Terminal implements ITerminalCore { this.scrollToBottom(); } - // Check for title changes (OSC 0, 1, 2 sequences) - // This is a simplified implementation - Ghostty WASM may provide this - if (scanStream && typeof data === 'string' && data.includes('\x1b]')) { - this.checkForTitleChange(data); - } - // Call callback if provided if (callback) { // Queue callback after next render @@ -839,8 +825,8 @@ export class Terminal implements ITerminalCore { this.rows = rows; // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views). - // The official engine takes the device-pixel cell size in the same call, so a - // change of grid and pixels is one size report. (webtty, ADR 042) + // The device-pixel cell size goes in the same call, so a change of grid and + // pixels is one size report. (webtty, ADR 042) const px = this.cellPixelSize(); this.wasmTerm!.resize(cols, rows, px?.width, px?.height); @@ -875,8 +861,7 @@ export class Terminal implements ITerminalCore { * Sends the current device-pixel cell size to the engine for its size reports. * Called at the end of every fit: if the grid changed, resize() has already sent * it and this does nothing; if only the pixels changed (font zoom or load, DPR), - * the official engine makes one size update. A no-op on the Coder engine. - * (webtty, ADR 042) + * Ghostty makes one size update. (webtty, ADR 042) */ syncCellPixelSize(): void { const px = this.cellPixelSize(); @@ -2034,28 +2019,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Check for title changes in written data (OSC sequences) - * Simplified implementation - looks for OSC 0, 1, 2 - */ - private checkForTitleChange(data: string): void { - // OSC sequences: ESC ] Ps ; Pt BEL or ESC ] Ps ; Pt ST - // OSC 0 = icon + title, OSC 1 = icon, OSC 2 = title - const oscRegex = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g; - let match: RegExpExecArray | null = null; - - // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex pattern - while ((match = oscRegex.exec(data)) !== null) { - const ps = match[1]; - const pt = match[2]; - - // OSC 0 and OSC 2 set the title - if (ps === '0' || ps === '2') { - this.setTitle(pt); - } - } - } - /** Fires onTitleChange when the title differs from the current one. */ private setTitle(title: string): void { if (title === this.currentTitle) return; diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index a69ecff..4080e3a 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -349,138 +349,6 @@ export interface KeyEvent { unshiftedCodepoint?: number; } -// ============================================================================ -// WASM Exports Interface -// ============================================================================ - -/** - * Interface for libghostty-vt WASM exports - */ -export interface GhosttyWasmExports extends WebAssembly.Exports { - memory: WebAssembly.Memory; - - // Memory helpers - ghostty_wasm_alloc_opaque(): number; - ghostty_wasm_free_opaque(ptr: number): void; - ghostty_wasm_alloc_u8_array(len: number): number; - ghostty_wasm_free_u8_array(ptr: number, len: number): void; - ghostty_wasm_alloc_u16_array(len: number): number; - ghostty_wasm_free_u16_array(ptr: number, len: number): void; - ghostty_wasm_alloc_u8(): number; - ghostty_wasm_free_u8(ptr: number): void; - ghostty_wasm_alloc_usize(): number; - ghostty_wasm_free_usize(ptr: number): void; - - // SGR parser - ghostty_sgr_new(allocator: number, parserPtrPtr: number): number; - ghostty_sgr_free(parser: number): void; - ghostty_sgr_reset(parser: number): void; - ghostty_sgr_set_params( - parser: number, - paramsPtr: number, - subsPtr: number, - paramsLen: number - ): number; - ghostty_sgr_next(parser: number, attrPtr: number): boolean; - ghostty_sgr_attribute_tag(attrPtr: number): number; - ghostty_sgr_attribute_value(attrPtr: number, tagPtr: number): number; - ghostty_wasm_alloc_sgr_attribute(): number; - ghostty_wasm_free_sgr_attribute(ptr: number): void; - - // Key encoder - ghostty_key_encoder_new(allocator: number, encoderPtrPtr: number): number; - ghostty_key_encoder_free(encoder: number): void; - ghostty_key_encoder_setopt(encoder: number, option: number, valuePtr: number): number; - ghostty_key_encoder_encode( - encoder: number, - eventPtr: number, - bufPtr: number, - bufLen: number, - writtenPtr: number - ): number; - - // Key event - ghostty_key_event_new(allocator: number, eventPtrPtr: number): number; - ghostty_key_event_free(event: number): void; - ghostty_key_event_set_action(event: number, action: number): void; - ghostty_key_event_set_key(event: number, key: number): void; - ghostty_key_event_set_mods(event: number, mods: number): void; - ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; - - // Terminal lifecycle - ghostty_terminal_new(cols: number, rows: number): TerminalHandle; - ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle; - ghostty_terminal_free(terminal: TerminalHandle): void; - ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; - ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; - - // RenderState API - high-performance rendering (ONE call gets ALL data) - ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full - ghostty_render_state_get_cols(terminal: TerminalHandle): number; - ghostty_render_state_get_rows(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean; - ghostty_render_state_get_bg_color(terminal: TerminalHandle): number; // 0xRRGGBB - ghostty_render_state_get_fg_color(terminal: TerminalHandle): number; // 0xRRGGBB - ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean; - ghostty_render_state_mark_clean(terminal: TerminalHandle): void; - ghostty_render_state_get_viewport( - terminal: TerminalHandle, - bufPtr: number, - bufLen: number - ): number; // Returns total cells written or -1 on error - ghostty_render_state_get_grapheme( - terminal: TerminalHandle, - row: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns count of codepoints or -1 on error - - // Terminal modes - ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; - ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; - ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: boolean): number; - - // Scrollback API - ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; - ghostty_terminal_get_scrollback_line( - terminal: TerminalHandle, - offset: number, - bufPtr: number, - bufLen: number - ): number; // Returns cells written or -1 on error - ghostty_terminal_get_scrollback_grapheme( - terminal: TerminalHandle, - offset: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns codepoint count or -1 on error - ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; - - // Hyperlink API - ghostty_terminal_get_hyperlink_uri( - terminal: TerminalHandle, - row: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns bytes written, 0 if no hyperlink, -1 on error - ghostty_terminal_get_scrollback_hyperlink_uri( - terminal: TerminalHandle, - offset: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns bytes written, 0 if no hyperlink, -1 on error - - // Response API (for DSR and other terminal queries) - ghostty_terminal_has_response(terminal: TerminalHandle): boolean; - ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error -} - // ============================================================================ // Terminal Types // ============================================================================ @@ -495,8 +363,7 @@ export enum DirtyState { } /** - * Cursor state from RenderState (8 bytes packed) - * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8) + * Cursor state from RenderState */ export interface RenderStateCursor { x: number; @@ -509,7 +376,7 @@ export interface RenderStateCursor { } /** - * Colors from RenderState (12 bytes packed) + * Colors from RenderState */ export interface RenderStateColors { background: RGB; @@ -517,16 +384,6 @@ export interface RenderStateColors { cursor: RGB | null; } -/** - * Size of cursor struct in WASM (8 bytes) - */ -export const CURSOR_STRUCT_SIZE = 8; - -/** - * Size of colors struct in WASM (12 bytes) - */ -export const COLORS_STRUCT_SIZE = 12; - /** * Terminal configuration (passed to ghostty_terminal_new_with_config) * All color values use 0xRRGGBB format. A value of 0 means "use default". @@ -556,19 +413,8 @@ export interface GhosttyTerminalConfig { } /** - * Size of GhosttyTerminalConfig struct in WASM memory (bytes). - * Layout: scrollback_limit(u32) + fg_color(u32) + bg_color(u32) + cursor_color(u32) + palette[16](u32*16) - * Total: 4 + 4 + 4 + 4 + 64 = 80 bytes - */ -export const GHOSTTY_CONFIG_SIZE = 80; - -/** - * Opaque terminal pointer (WASM memory address) - */ -export type TerminalHandle = number; - -/** - * Cell structure matching ghostty_cell_t in C (16 bytes) + * A cell as the binding decodes it from Ghostty's render state (webtty: the field + * comments give ghostty-web's original C sizes) */ export interface GhosttyCell { codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) diff --git a/src/client/vendor/official-replay.test.ts b/src/client/vendor/official-replay.test.ts new file mode 100644 index 0000000..97e1897 --- /dev/null +++ b/src/client/vendor/official-replay.test.ts @@ -0,0 +1,530 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; + +// The official engine (ADR 042, ADR 047) replaying the same input two ways, cell +// for cell. A terminal that was scrolled, paged and cached while the input arrived +// must show what a fresh terminal fed the same input shows on its first read, so +// stale pages and lost dirty rows fail. Until ADR 047 these tests used the Coder +// engine as the reference. +// +// fixtures/*.bin are PTY captures at 120x40 (TERM=xterm-256color, LANG=en_US.UTF-8), +// recorded from an empty HOME in a scratch directory holding only a four-line +// sample.c: vim and nvim editing sample.c (`vim -u NONE`, `nvim --clean`), `man ls` +// in less, `ls -laG /usr/bin | head -80`, `top` showing one `sleep` process, +// `seq 5000`, an SGR/256-colour/truecolor test and a Unicode test. + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const OFFICIAL_WASM = path.join(HERE, 'ghostty-vt/ghostty-vt-small.wasm'); +const FIXTURES = path.join(HERE, 'fixtures'); +const COLS = 120; +const ROWS = 40; +const THEME = { fgColor: 0xd4d4d4, bgColor: 0x1e1e1e }; + +let vt: Ghostty; + +beforeAll(async () => { + vt = await Ghostty.load(OFFICIAL_WASM); +}); + +interface Cell { + text: string; + fg: number; + bg: number; + flags: number; + width: number; +} + +type Row = Cell[]; + +function toRow(line: ReturnType, grapheme: (x: number) => string): Row { + return (line ?? []).map((c, x) => ({ + text: c.grapheme_len > 0 ? grapheme(x) : c.codepoint ? String.fromCodePoint(c.codepoint) : '', + fg: (c.fg_r << 16) | (c.fg_g << 8) | c.fg_b, + bg: (c.bg_r << 16) | (c.bg_g << 8) | c.bg_b, + flags: c.flags, + width: c.width, + })); +} + +/** The active screen as the renderer reads it: one getViewport per frame. */ +function screen(t: GhosttyTerminal): { grid: Row[]; cursor: { x: number; y: number } } { + t.update(); + const { cols, rows } = t.getDimensions(); + const cells = t.getViewport(); + const grid: Row[] = []; + for (let y = 0; y < rows; y++) { + grid.push(toRow(cells.slice(y * cols, (y + 1) * cols), (x) => t.getGraphemeString(y, x))); + } + const { x, y } = t.getCursor(); + t.markClean(); + return { grid, cursor: { x, y } }; +} + +const activeRow = (t: GhosttyTerminal, y: number) => + toRow(t.getLine(y), (x) => t.getGraphemeString(y, x)); +const scrollbackRow = (t: GhosttyTerminal, i: number) => + toRow(t.getScrollbackLine(i), (x) => t.getScrollbackGraphemeString(i, x)); + +/** The rows a renderer shows at viewportY = k, the way renderer.ts picks them. */ +function shownRows(t: GhosttyTerminal, k: number): Row[] { + t.setViewportOffset(k); + const sb = t.getScrollbackLength(); + const rows: Row[] = []; + for (let y = 0; y < t.rows; y++) { + rows.push(y < k ? scrollbackRow(t, sb - k + y) : activeRow(t, y - k)); + } + return rows; +} + +const text = (row: Row) => + row + .map((c) => (c.width === 0 ? '' : c.text || ' ')) + .join('') + .trimEnd(); + +interface Difference { + y: number; + x: number; + field: keyof Cell; + expected: string | number; + actual: string | number; +} + +function diffRows(expected: Row[], actual: Row[]): Difference[] { + const out: Difference[] = []; + const rows = Math.max(expected.length, actual.length); + for (let y = 0; y < rows; y++) { + const a = expected[y] ?? []; + const b = actual[y] ?? []; + for (let x = 0; x < Math.max(a.length, b.length); x++) { + for (const field of ['text', 'fg', 'bg', 'flags', 'width'] as const) { + if (a[x]?.[field] !== b[x]?.[field]) { + out.push({ y, x, field, expected: a[x]?.[field] ?? '-', actual: b[x]?.[field] ?? '-' }); + } + } + } + } + return out; +} + +/** + * A terminal under test that records its input, so a fresh terminal can be fed the + * same writes and resizes as the reference. + */ +class Twin { + readonly t: GhosttyTerminal; + private readonly ops: ((t: GhosttyTerminal) => void)[] = []; + + constructor( + private readonly cols: number, + private readonly rows: number, + private readonly config: object, + ) { + this.t = vt.createTerminal(cols, rows, config); + } + + write(data: string | Uint8Array): void { + this.ops.push((t) => t.write(data)); + this.t.write(data); + } + + resize(cols: number, rows: number): void { + this.ops.push((t) => t.resize(cols, rows)); + this.t.resize(cols, rows); + } + + /** Runs `read` on a fresh terminal fed the same input, never scrolled or read before. */ + fresh(read: (t: GhosttyTerminal) => T): T { + const ref = vt.createTerminal(this.cols, this.rows, this.config); + for (const op of this.ops) op(ref); + try { + return read(ref); + } finally { + ref.free(); + } + } + + /** Differences between the rows shown at k and a fresh terminal's first read at k. */ + shownDiff(k: number): Difference[] { + return diffRows( + this.fresh((ref) => shownRows(ref, k)), + shownRows(this.t, k), + ); + } + + /** Differences between the active screen and a fresh terminal's. */ + screenDiff(): Difference[] { + return diffRows( + this.fresh((ref) => screen(ref).grid), + screen(this.t).grid, + ); + } + + free(): void { + this.t.free(); + } +} + +/** + * Ghostty at 05dcf887 clusters the Hindi conjunct स्ते (U+0938 U+094D U+0924 + * U+0947) as one width-2 cell followed by a spacer, where the Coder wasm kept two + * width-1 cells (ADR 041, ADR 042). + */ +function hasHindiConjunct(grid: Row[]): boolean { + return grid.some((row) => + row.some((c, x) => c.text === 'स्ते' && c.width === 2 && row[x + 1]?.width === 0), + ); +} + +const CAPTURES = ['ls', 'vim', 'nvim', 'less', 'top', 'sgr', 'uni', 'seq']; + +describe('PTY captures at 120x40: 1-byte writes match 512-byte writes', () => { + for (const name of CAPTURES) { + test(name, () => { + const data = new Uint8Array(fs.readFileSync(path.join(FIXTURES, `${name}.bin`))); + const whole = vt.createTerminal(COLS, ROWS, THEME); + const bytes = vt.createTerminal(COLS, ROWS, THEME); + let checkpoints = 0; + const compare = () => { + const a = screen(whole); + const b = screen(bytes); + checkpoints++; + expect(diffRows(a.grid, b.grid).slice(0, 10)).toEqual([]); + expect(b.cursor).toEqual(a.cursor); + return a.grid; + }; + for (let i = 0; i < data.length; i += 512) { + const piece = data.subarray(i, Math.min(i + 512, data.length)); + whole.write(piece); + for (let j = 0; j < piece.length; j++) bytes.write(piece.subarray(j, j + 1)); + compare(); + } + expect(checkpoints).toBeGreaterThan(0); + expect(hasHindiConjunct(compare())).toBe(name === 'uni'); + whole.free(); + bytes.free(); + }); + } +}); + +describe('soft wraps', () => { + test('isRowWrapped marks the rows that continue the row above', () => { + const t = vt.createTerminal(10, 6, THEME); + const wrapped = () => Array.from({ length: 6 }, (_, y) => t.isRowWrapped(y)); + t.write(`${'W'.repeat(25)}\r\nshort`); + expect(wrapped()).toEqual([false, true, true, false, false, false]); + // A line filling the row exactly, with the cursor parked at the margin, does not wrap yet. + t.write(`\r\n${'X'.repeat(10)}`); + expect(wrapped()).toEqual([false, true, true, false, false, false]); + t.write('Y'); + expect(wrapped()).toEqual([false, true, true, false, false, true]); + t.free(); + }); +}); + +describe('scrolled views against a fresh terminal', () => { + const lines = (from: number, to: number) => { + let s = ''; + for (let i = from; i <= to; i++) { + s += `\x1b[38;5;${i % 256}mline ${i}\x1b[0m ${'x'.repeat(i % 50)}\r\n`; + } + return s; + }; + // No line limit, so every written row is kept. + const config = { ...THEME, scrollbackLines: 0 }; + + test('scrollback pages match the same rows decoded on a screen tall enough to hold them', () => { + const t = vt.createTerminal(80, 24, config); + const tall = vt.createTerminal(80, 400, config); + t.write(lines(1, 300)); + tall.write(lines(1, 300)); + const sb = t.getScrollbackLength(); + expect(sb).toBe(301 - 24); + expect(tall.getScrollbackLength()).toBe(0); + t.setViewportOffset(40); + for (let i = 0; i < sb; i++) { + expect(diffRows([activeRow(tall, i)], [scrollbackRow(t, i)])).toEqual([]); + } + for (let y = 0; y < 24; y++) { + expect(diffRows([activeRow(tall, sb + y)], [activeRow(t, y)])).toEqual([]); + } + t.free(); + tall.free(); + }); + + test('getLine, getScrollbackLine and the shown rows match at several offsets', () => { + const tw = new Twin(80, 24, config); + tw.write(lines(1, 300)); + const sb = tw.t.getScrollbackLength(); + for (const k of [0, 1, 5, 23, 24, 25, 100, sb]) { + expect(tw.shownDiff(k)).toEqual([]); + for (const y of [0, 10, 23]) { + expect(diffRows([tw.fresh((ref) => activeRow(ref, y))], [activeRow(tw.t, y)])).toEqual([]); + } + } + tw.t.setViewportOffset(40); + const expected = tw.fresh((ref) => Array.from({ length: sb }, (_, i) => scrollbackRow(ref, i))); + for (let i = 0; i < sb; i += 7) { + expect(diffRows([expected[i]], [scrollbackRow(tw.t, i)])).toEqual([]); + } + expect(tw.screenDiff()).toEqual([]); + tw.free(); + }); + + test('a write while scrolled snaps to the bottom, and a stale offset moves back', () => { + const tw = new Twin(80, 24, config); + tw.write(lines(1, 200)); + shownRows(tw.t, 30); + tw.write(lines(201, 210)); + expect(tw.t.isRowDirty(0)).toBe(true); + expect(tw.screenDiff()).toEqual([]); + // The awaitingEcho render passes the stale viewportY. + expect(tw.shownDiff(30)).toEqual([]); + expect(tw.shownDiff(0)).toEqual([]); + tw.free(); + }); + + test('a resize while scrolled reflows the same rows', () => { + const tw = new Twin(80, 24, config); + tw.write(lines(1, 200)); + shownRows(tw.t, 30); + // Shrinking rewraps the long lines. + tw.resize(60, 20); + expect(tw.t.getScrollbackLength()).toBe(tw.fresh((ref) => ref.getScrollbackLength())); + expect(tw.shownDiff(30)).toEqual([]); + expect(tw.screenDiff()).toEqual([]); + // Growing unwraps them again, back to the text that was written. + shownRows(tw.t, 30); + tw.resize(100, 30); + const sb = tw.t.getScrollbackLength(); + expect(sb).toBe(tw.fresh((ref) => ref.getScrollbackLength())); + const written = (i: number) => `line ${i} ${'x'.repeat(i % 50)}`.trimEnd(); + const expected = Array.from({ length: 30 }, (_, y) => written(sb - 30 + y + 1)); + expect(shownRows(tw.t, 30).map(text)).toEqual(expected); + for (let i = 0; i < sb; i++) expect(text(scrollbackRow(tw.t, i))).toBe(written(i + 1)); + expect(tw.shownDiff(30)).toEqual([]); + expect(tw.screenDiff()).toEqual([]); + tw.free(); + }); + + test('a page read between a write and the next render loses no dirty rows', () => { + const tw = new Twin(80, 24, config); + tw.write(lines(1, 100)); + screen(tw.t); + tw.write('\x1b[5;3HCHANGED'); + // A selection copy or buffer read above the viewport before the frame. + expect(diffRows([tw.fresh((ref) => scrollbackRow(ref, 3))], [scrollbackRow(tw.t, 3)])).toEqual( + [], + ); + expect(tw.t.isRowDirty(4)).toBe(true); + expect(tw.t.isRowDirty(10)).toBe(false); + expect(text(activeRow(tw.t, 4))).toContain('CHANGED'); + expect(tw.screenDiff()).toEqual([]); + tw.free(); + }); + + test('stepping the offset through the screen keeps pages across moves, not writes', () => { + const tw = new Twin(80, 24, config); + tw.write(lines(1, 300)); + const sb = tw.t.getScrollbackLength(); + const walk = (ks: number[]) => { + for (const k of ks) { + expect(tw.shownDiff(k)).toEqual([]); + // The renderer's active rows come from one getViewport per frame. + expect(tw.screenDiff()).toEqual([]); + } + }; + const steps = [0, 1, 2, 12, 22, 23, 24, 25, 47, 48, 49, 100, sb, 30, 23, 1, 0]; + walk(steps); + // An in-place write below the view, then the same walk: no page from before it. + tw.t.setViewportOffset(10); + tw.write('\x1b[24;1HAFTER\x1b[1;1HTOP'); + walk(steps); + tw.free(); + }); + + // Graphemes in scrollback and on screen: VS16 emoji, a skin-tone modifier and a + // combining mark, so the scrolled rows' grapheme lookups are compared too. + const graphemeLines = (from: number, to: number) => { + let s = ''; + for (let i = from; i <= to; i++) { + s += + i % 3 === 0 + ? `\x1b[38;2;200;120;80m⏺️\x1b[0m ${i} café 👍🏽 ✔︎\r\n` + : `\x1b[48;5;${i % 256}mrow ${i}\x1b[0m ${'─'.repeat(i % 40)}\r\n`; + } + return s; + }; + + test('a scroll walk past the screen and back matches rows, graphemes and cursor', () => { + const tw = new Twin(80, 24, config); + tw.write(`${graphemeLines(1, 200)}\x1b[3;7H`); + const sb = tw.t.getScrollbackLength(); + expect(sb).toBe(201 - 24); + // The graphemes as written, so the reference is not just the same decoder. + expect(text(scrollbackRow(tw.t, 2))).toBe('⏺️ 3 café 👍🏽 ✔︎'); + const ks = [ + ...Array.from({ length: 60 }, (_, k) => k), + sb - 1, + sb, + sb + 5, + ...Array.from({ length: 60 }, (_, k) => 59 - k), + ]; + for (const k of ks) { + expect(tw.shownDiff(k)).toEqual([]); + expect(tw.t.getCursor()).toMatchObject({ x: 6, y: 2 }); + expect(tw.screenDiff()).toEqual([]); + } + tw.free(); + }); + + test('output while scrolled shows the new rows at the bottom and in later scrolls', () => { + const tw = new Twin(80, 24, config); + tw.write(graphemeLines(1, 120)); + for (const k of [5, 30]) shownRows(tw.t, k); + tw.write(graphemeLines(121, 130)); + expect(tw.t.needsFullRedraw()).toBe(true); + expect(tw.t.getScrollbackLength()).toBe(131 - 24); + for (const k of [0, 3, 24, 30, 0]) expect(tw.shownDiff(k)).toEqual([]); + tw.free(); + }); + + test('a resize while scrolled keeps the walk matching', () => { + const tw = new Twin(80, 24, config); + tw.write(graphemeLines(1, 150)); + for (const k of [10, 40]) shownRows(tw.t, k); + tw.resize(70, 20); + expect(tw.t.getScrollbackLength()).toBe(tw.fresh((ref) => ref.getScrollbackLength())); + for (const k of [40, 39, 20, 19, 1, 0, 25]) { + expect(tw.shownDiff(k)).toEqual([]); + expect(tw.screenDiff()).toEqual([]); + } + tw.free(); + }); + + test('a page read after an in-place write does not return stale cells', () => { + const tw = new Twin(80, 24, config); + tw.write(lines(1, 100)); + // Scrolled by 10: the bottom 10 active rows are below the view, read as a page. + shownRows(tw.t, 10); + expect(text(activeRow(tw.t, 23))).toBe(''); + tw.write('\x1b[24;1HIN PLACE'); + expect(tw.shownDiff(10)).toEqual([]); + expect(text(activeRow(tw.t, 23))).toStartWith('IN PLACE'); + expect(diffRows([tw.fresh((ref) => activeRow(ref, 23))], [activeRow(tw.t, 23)])).toEqual([]); + tw.free(); + }); +}); + +describe('coder/ghostty-web#180 stress payload', () => { + // Port of buildStressPayload() from coder/ghostty-web lib/viewport-corruption.test.ts + // (PR #180; MIT, Copyright (c) 2025 Coder, see ghostty-web/LICENSE). + const ESC = '\x1b'; + function buildStressPayload(): string { + const lines: string[] = []; + let n = 0; + const marked = (content: string) => { + const marker = `R00L${n.toString().padStart(3, '0')}`; + lines.push(`${ESC}[38;5;${(n * 17) % 256}m${marker}${ESC}[0m ${content}`); + n++; + }; + const blank = () => { + lines.push(''); + n++; + }; + marked(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`); + marked(`${ESC}[1mTerminal rendering scroll-growth regression${ESC}[0m`); + blank(); + marked(`${ESC}[1m256-color palette${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let c = ''; + for (let i = 0; i < 32; i++) c += `${ESC}[48;5;${row * 32 + i}m ${ESC}[0m`; + marked(c); + } + blank(); + marked(`${ESC}[1mTruecolor gradients${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let c = ''; + for (let i = 0; i < 80; i++) { + const r = Math.floor(Math.sin(i * 0.08 + row) * 127 + 128); + const g = Math.floor(Math.sin(i * 0.08 + row + 2) * 127 + 128); + const b = Math.floor(Math.sin(i * 0.08 + row + 4) * 127 + 128); + c += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`; + } + marked(c); + } + blank(); + marked(`${ESC}[1mCombined styles${ESC}[0m`); + marked( + `${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ` + + `${ESC}[7mReverse${ESC}[0m ${ESC}[9mStrike${ESC}[0m`, + ); + marked( + `${ESC}[1;31mBold Red${ESC}[0m ${ESC}[3;32mItalic Green${ESC}[0m ` + + `${ESC}[4;34mUnderline Blue${ESC}[0m ${ESC}[38;5;201mPalette Pink${ESC}[0m`, + ); + blank(); + marked(`${ESC}[1mSoft-wrap metadata seed${ESC}[0m`); + marked('wrap '.repeat(48)); + blank(); + marked(`${ESC}[1mUnicode box drawing${ESC}[0m`); + for (const line of [ + '┌──────────┬──────────┬──────────┐', + '│ Cell A │ Cell B │ Cell C │', + '├──────────┼──────────┼──────────┤', + '│ Cell D │ Cell E │ Cell F │', + '└──────────┴──────────┴──────────┘', + ]) { + marked(line); + } + blank(); + for (let section = 0; section < 8; section++) { + marked(`${ESC}[1mColor grid ${String.fromCharCode(65 + section)}${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let c = ''; + for (let i = 0; i < 70; i++) { + const idx = (section * 64 + row * 8 + i) % 256; + c += + (i + row) % 3 === 0 + ? `${ESC}[38;2;${(idx * 7) % 256};${(idx * 13) % 256};${(idx * 23) % 256}m*${ESC}[0m` + : `${ESC}[38;5;${idx}m*${ESC}[0m`; + } + marked(c); + } + blank(); + } + marked(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`); + marked(`${ESC}[32m✓${ESC}[0m Test complete`); + blank(); + return `${lines.join('\r\n')}\r\n`; + } + + test('0 stale rows over 300 repetitions', () => { + const cols = 160; + const rows = 39; + const t = vt.createTerminal(cols, rows, { ...THEME, scrollbackLines: 0 }); + const payload = new TextEncoder().encode(buildStressPayload()); + const failures: string[] = []; + let baseline: string[] | null = null; + for (let rep = 0; rep < 300; rep++) { + t.write(payload); + const { grid } = screen(t); + const shown = grid.map(text); + shown.forEach((row, y) => { + const markers = new Set(row.match(/R\d{2}L\d{3}/g) ?? []); + if (markers.size > 1) failures.push(`rep ${rep} row ${y}: merged ${[...markers]}`); + // isRowWrapped(y + 1): the next row continues this one, so this one wraps. + if (row.length === 0 && y + 1 < rows && t.isRowWrapped(y + 1)) + failures.push(`rep ${rep} row ${y}: blank, wrapped`); + }); + baseline ??= shown; + const changed = shown.findIndex((row, y) => row !== baseline?.[y]); + if (changed !== -1) failures.push(`rep ${rep} row ${changed} differs from rep 0`); + } + expect(failures.slice(0, 5)).toEqual([]); + t.free(); + }); +}); diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 1ce3e35..4651a99 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -5,7 +5,6 @@ import { DirtyState, Ghostty, type GhosttyTerminal, - OfficialGhostty, type OfficialTerminal, } from './ghostty-web/ghostty'; import { @@ -38,7 +37,7 @@ const THEME = { let vt: Ghostty; beforeAll(async () => { - vt = await Ghostty.load(OFFICIAL_WASM, 'official'); + vt = await Ghostty.load(OFFICIAL_WASM); }); function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { @@ -365,10 +364,8 @@ describe('official engine: scrollback, resize and the viewport', () => { ghostty_render_state_row_get_multi: counted('ghostty_render_state_row_get_multi', 'rows'), ghostty_terminal_scroll_viewport: counted('ghostty_terminal_scroll_viewport', 'scroll'), }; - const engine = new OfficialGhostty( - spy as unknown as ConstructorParameters[0], - ); - const t = engine.createTerminal(20, 5, { scrollbackLimit: 0 }); + const spied = new Ghostty(spy as unknown as ConstructorParameters[0]); + const t = spied.createTerminal(20, 5, { scrollbackLimit: 0 }); t.write(lines(50)); const sb = t.getScrollbackLength(); expect(sb).toBe(45); @@ -443,7 +440,7 @@ describe('official engine: scrollback, resize and the viewport', () => { t.write('abcdefghijklmnopqrstuvwxyz0123'); expect(rowText(t, 0)).toBe('abcdefghijklmnopqrst'); expect(rowText(t, 1)).toBe('uvwxyz0123'); - // Coder semantics: a row is wrapped when it continues the row above. + // A row is wrapped when it continues the row above (ghostty-web's semantics). expect(t.isRowWrapped(0)).toBe(false); expect(t.isRowWrapped(1)).toBe(true); t.resize(30, 5); @@ -637,10 +634,8 @@ describe('official engine: effects and replies', () => { return resize(...args); }, }; - const engine = new OfficialGhostty( - spy as unknown as ConstructorParameters[0], - ); - const t = engine.createTerminal(80, 24); + const spied = new Ghostty(spy as unknown as ConstructorParameters[0]); + const t = spied.createTerminal(80, 24); // open(): the initial cell size. t.setCellPixelSize(9, 18); expect(resizes).toBe(1); @@ -663,7 +658,7 @@ describe('official engine: effects and replies', () => { describe('official engine: memory growth', () => { test('reads correct cells and replies after the wasm memory has grown', () => { const t = term(120, 40, { scrollbackLimit: 0 }); - const memory = (vt as OfficialGhostty).ex.memory; + const memory = vt.ex.memory; const before = memory.buffer.byteLength; let i = 0; while (memory.buffer.byteLength === before && i < 200000) { diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index a573405..6d11bc8 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -10,8 +10,7 @@ import { } from './ghostty-web/renderer'; import type { GhosttyCell } from './ghostty-web/types'; -const WASM = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-web/ghostty-vt.wasm'); -const OFFICIAL_WASM = path.join( +const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), 'ghostty-vt/ghostty-vt-small.wasm', ); @@ -199,44 +198,39 @@ function recordingCanvas(cellHeight: number) { } describe('graphemes while scrolled', () => { - for (const [engine, wasm] of [ - ['coder', WASM], - ['official', OFFICIAL_WASM], - ] as const) { - test(`draws scrollback graphemes from their own row (${engine})`, async () => { - const doc = globalThis.document; - const { canvas, drawn } = recordingCanvas(15); - globalThis.document = { createElement: () => canvas } as unknown as Document; - try { - const ghostty = await Ghostty.load(wasm, engine); - const term = ghostty.createTerminal(30, 6, { scrollbackLimit: 10000 }); - // A grapheme at a different column on every line, so a lookup on the wrong - // row finds something else. - for (let i = 0; i < 20; i++) term.write(`${'x'.repeat(i % 7)}e\u0301 ${i}\r\n`); - term.write('$ abcdefghijklmnop'); - const sb = term.getScrollbackLength(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => sb, - getScrollbackGraphemeString: (o: number, c: number) => - term.getScrollbackGraphemeString(o, c), - }; - const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1, overscan: 1 }); - renderer.resize(30, 6); - renderer.render(term, true, 3, provider); - // Below the overscan row, canvas rows 1-3 show the last three scrollback lines - // (line i is the i-th written); rows 4-6 the top of the screen. - for (let row = 0; row < 3; row++) { - expect(drawn.get(row + 1)).toContain('e\u0301'); - expect(drawn.get(row + 1)?.join('')).toContain(` ${sb - 3 + row} `); - } - expect(drawn.get(4)).toContain('e\u0301'); - term.free(); - } finally { - globalThis.document = doc; + test(`draws scrollback graphemes from their own row`, async () => { + const doc = globalThis.document; + const { canvas, drawn } = recordingCanvas(15); + globalThis.document = { createElement: () => canvas } as unknown as Document; + try { + const ghostty = await Ghostty.load(WASM); + const term = ghostty.createTerminal(30, 6, { scrollbackLines: 10000 }); + // A grapheme at a different column on every line, so a lookup on the wrong + // row finds something else. + for (let i = 0; i < 20; i++) term.write(`${'x'.repeat(i % 7)}e\u0301 ${i}\r\n`); + term.write('$ abcdefghijklmnop'); + const sb = term.getScrollbackLength(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => sb, + getScrollbackGraphemeString: (o: number, c: number) => + term.getScrollbackGraphemeString(o, c), + }; + const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1, overscan: 1 }); + renderer.resize(30, 6); + renderer.render(term, true, 3, provider); + // Below the overscan row, canvas rows 1-3 show the last three scrollback lines + // (line i is the i-th written); rows 4-6 the top of the screen. + for (let row = 0; row < 3; row++) { + expect(drawn.get(row + 1)).toContain('e\u0301'); + expect(drawn.get(row + 1)?.join('')).toContain(` ${sb - 3 + row} `); } - }); - } + expect(drawn.get(4)).toContain('e\u0301'); + term.free(); + } finally { + globalThis.document = doc; + } + }); }); /** @@ -470,494 +464,485 @@ describe('overscan and copied scrolls (ADR 043)', () => { return offset >= cellHeight ? { line: line + 1, offset: 0 } : { line, offset }; } - for (const [engine, wasm] of [ - ['coder', WASM], - ['official', OFFICIAL_WASM], - ] as const) { - async function terminal() { - const ghostty = await Ghostty.load(wasm, engine); - const term = ghostty.createTerminal(cols, rows, { scrollbackLimit: 10000 }); - // Colours, backgrounds, a wide character, combining marks and descenders that - // overflow into the rows above and below. - for (let i = 0; i < 40; i++) { - term.write( - i % 3 === 0 - ? `\x1b[3${i % 8}mline ${i} e\u0301 g\x1b[0m\r\n` - : i % 3 === 1 - ? `\x1b[4${i % 8}m bg ${i} \x1b[0m 中 g\r\n` - : `plain ${i} a\u0301\r\n`, - ); - // OSC 8 links, two lines each - if (i % 7 === 0) - term.write(`\x1b]8;;https://x/${i % 2}\x1b\\link ${i}\r\nmore g\x1b]8;;\x1b\\\r\n`); - } - // The cursor mid-screen, so a scroll moves it onto another row in view. - term.write('$ prompt\x1b[5;3H'); - const sb = term.getScrollbackLength(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => sb, - getScrollbackGraphemeString: (o: number, c: number) => - term.getScrollbackGraphemeString(o, c), - }; - return { term, provider }; + async function terminal() { + const ghostty = await Ghostty.load(WASM); + const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); + // Colours, backgrounds, a wide character, combining marks and descenders that + // overflow into the rows above and below. + for (let i = 0; i < 40; i++) { + term.write( + i % 3 === 0 + ? `\x1b[3${i % 8}mline ${i} e\u0301 g\x1b[0m\r\n` + : i % 3 === 1 + ? `\x1b[4${i % 8}m bg ${i} \x1b[0m 中 g\r\n` + : `plain ${i} a\u0301\r\n`, + ); + // OSC 8 links, two lines each + if (i % 7 === 0) + term.write(`\x1b]8;;https://x/${i % 2}\x1b\\link ${i}\r\nmore g\x1b]8;;\x1b\\\r\n`); } + // The cursor mid-screen, so a scroll moves it onto another row in view. + term.write('$ prompt\x1b[5;3H'); + const sb = term.getScrollbackLength(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => sb, + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; + return { term, provider }; + } - type Term = Awaited>; - - /** - * What the previous renderer showed at a position, drawn independently: the viewport - * as a screen of its own lines, nothing below it, and at a fraction of a line the - * bottom of the line above drawn on its own (ADR 040's strip). No scrollbar. - */ - function previous({ term, provider }: Term, view: number, offset: number, dpr: number) { - const sb = provider.getScrollbackLength(); - const source = (r: number) => - r < view - ? { - line: provider.getScrollbackLine(sb - view + r), - grapheme: (c: number) => provider.getScrollbackGraphemeString(sb - view + r, c), - } - : { - line: term.getLine(r - view), - grapheme: (c: number) => term.getGraphemeString(r - view, c), - }; - const screen = (from: number, count: number, cursor: boolean) => { - const lines = Array.from({ length: count }, (_, i) => source(from + i)); - const t = renderer(dpr); - t.r.resize(cols, count); - t.r.render( - { - getLine: (y) => lines[y]?.line ?? null, - getGraphemeString: (y, c) => lines[y].grapheme(c), - getCursor: () => (cursor ? term.getCursor() : { x: 0, y: 0, visible: false }), - getDimensions: () => ({ cols, rows: count }), - isRowDirty: () => false, - clearDirty: () => {}, - }, - true, - 0, - ); - return t.shown(); - }; - const width = cols * 8 * dpr; - const cut = offset * dpr * width; - const viewport = screen(0, rows, view === 0); - if (!offset) return viewport; - const above = screen(-1, 1, false); - return [...above.slice(above.length - cut), ...viewport.slice(0, viewport.length - cut)]; - } + type Term = Awaited>; + + /** + * What the previous renderer showed at a position, drawn independently: the viewport + * as a screen of its own lines, nothing below it, and at a fraction of a line the + * bottom of the line above drawn on its own (ADR 040's strip). No scrollbar. + */ + function previous({ term, provider }: Term, view: number, offset: number, dpr: number) { + const sb = provider.getScrollbackLength(); + const source = (r: number) => + r < view + ? { + line: provider.getScrollbackLine(sb - view + r), + grapheme: (c: number) => provider.getScrollbackGraphemeString(sb - view + r, c), + } + : { + line: term.getLine(r - view), + grapheme: (c: number) => term.getGraphemeString(r - view, c), + }; + const screen = (from: number, count: number, cursor: boolean) => { + const lines = Array.from({ length: count }, (_, i) => source(from + i)); + const t = renderer(dpr); + t.r.resize(cols, count); + t.r.render( + { + getLine: (y) => lines[y]?.line ?? null, + getGraphemeString: (y, c) => lines[y].grapheme(c), + getCursor: () => (cursor ? term.getCursor() : { x: 0, y: 0, visible: false }), + getDimensions: () => ({ cols, rows: count }), + isRowDirty: () => false, + clearDirty: () => {}, + }, + true, + 0, + ); + return t.shown(); + }; + const width = cols * 8 * dpr; + const cut = offset * dpr * width; + const viewport = screen(0, rows, view === 0); + if (!offset) return viewport; + const above = screen(-1, 1, false); + return [...above.slice(above.length - cut), ...viewport.slice(0, viewport.length - cut)]; + } - test(`the rows in view equal the previous renderer at every position (${engine})`, async () => { - const t = await terminal(); - const sb = t.provider.getScrollbackLength(); - for (const [dpr, overscan] of [ - [1, 3], - [2, 1], - [2, 2], - [2, undefined], - ] as const) { - const live = renderer(dpr, { overscan }); - const h = live.r.getMetrics().height; - live.r.render(t.term, true, 0, t.provider, 0); - // Trackpad steps up and down, a jump past the overscan, line steps, the top - const positions = [ - ...Array.from({ length: 24 }, (_, i) => i * 0.35), - 18.6, - 15.4, - ...Array.from({ length: 12 }, (_, i) => 15.4 - i * 0.45), - 1, - 2, - 5, - 4, - 0, - 3, - 10, - 9, - 17, - 16, - 0.5, - 0, - sb, - sb - 0.25, - sb - 7.75, - 0, - ]; - for (const p of positions) { - const { line, offset } = split(p, h, dpr); - live.r.setScrollOffset(offset); - live.r.render(t.term, false, line, t.provider, 0); - expect(live.r.scrollOffset).toBe(offset); - expect(live.shown()).toEqual(previous(t, line, offset, dpr)); - // The bottom strip's line is the last row's own, at any fraction: not the - // glyphs the part in view ends on, offset above it. - const area = live.r.visibleArea; - expect(area.lastLine).toBe(area.y + area.height - 1 + offset * dpr); - const whole = previous(t, line, 0, dpr); - expect(live.lastLine()).toEqual(whole.slice(whole.length - cols * 8 * dpr)); - } + test(`the rows in view equal the previous renderer at every position`, async () => { + const t = await terminal(); + const sb = t.provider.getScrollbackLength(); + for (const [dpr, overscan] of [ + [1, 3], + [2, 1], + [2, 2], + [2, undefined], + ] as const) { + const live = renderer(dpr, { overscan }); + const h = live.r.getMetrics().height; + live.r.render(t.term, true, 0, t.provider, 0); + // Trackpad steps up and down, a jump past the overscan, line steps, the top + const positions = [ + ...Array.from({ length: 24 }, (_, i) => i * 0.35), + 18.6, + 15.4, + ...Array.from({ length: 12 }, (_, i) => 15.4 - i * 0.45), + 1, + 2, + 5, + 4, + 0, + 3, + 10, + 9, + 17, + 16, + 0.5, + 0, + sb, + sb - 0.25, + sb - 7.75, + 0, + ]; + for (const p of positions) { + const { line, offset } = split(p, h, dpr); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 0); + expect(live.r.scrollOffset).toBe(offset); + expect(live.shown()).toEqual(previous(t, line, offset, dpr)); + // The bottom strip's line is the last row's own, at any fraction: not the + // glyphs the part in view ends on, offset above it. + const area = live.r.visibleArea; + expect(area.lastLine).toBe(area.y + area.height - 1 + offset * dpr); + const whole = previous(t, line, 0, dpr); + expect(live.lastLine()).toEqual(whole.slice(whole.length - cols * 8 * dpr)); } - t.term.free(); - }); + } + t.term.free(); + }); - test(`an offset set for a taller cell shows at most a line (${engine})`, async () => { - const t = await terminal(); - const live = renderer(2); + test(`an offset set for a taller cell shows at most a line`, async () => { + const t = await terminal(); + const live = renderer(2); + const h = live.r.getMetrics().height; + live.r.setScrollOffset(h + 6); + live.r.render(t.term, true, 3, t.provider, 0); + expect(live.r.scrollOffset).toBe(h - 0.5); + expect(live.shown()).toEqual(previous(t, 3, h - 0.5, 2)); + t.term.free(); + }); + + test(`random scrolls, output, selection, hover and scrollbar equal a full redraw`, async () => { + const { term } = await terminal(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; + // A fixed pseudo-random sequence + let seed = 7; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + let view = 0; + let selection: { + startCol: number; + startLine: number; + endCol: number; + endLine: number; + } | null = null; + const manager = { + hasSelection: () => selection !== null, + getSelectionRange: () => { + if (!selection) return null; + const sb = term.getScrollbackLength(); + return { + startCol: selection.startCol, + startRow: selection.startLine - sb + view, + endCol: selection.endCol, + endRow: selection.endLine - sb + view, + }; + }, + clearDirtySelectionRows: () => {}, + } as unknown as Parameters[0]; + let range: { startX: number; startY: number; endX: number; endY: number } | null = null; + // The OSC 8 link ids, and none + const ids = new Set([0]); + for (let o = 0; o < term.getScrollbackLength(); o++) { + for (const cell of term.getScrollbackLine(o) ?? []) ids.add(cell.hyperlink_id); + } + const links = [...ids]; + expect(links.length).toBeGreaterThan(1); + let hovered = 0; + for (const overscan of [1, 3]) { + const options = { overscan }; + const live = renderer(2, options); + live.r.setSelectionManager(manager); const h = live.r.getMetrics().height; - live.r.setScrollOffset(h + 6); - live.r.render(t.term, true, 3, t.provider, 0); - expect(live.r.scrollOffset).toBe(h - 0.5); - expect(live.shown()).toEqual(previous(t, 3, h - 0.5, 2)); - t.term.free(); - }); - - test(`random scrolls, output, selection, hover and scrollbar equal a full redraw (${engine})`, async () => { - const { term } = await terminal(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => - term.getScrollbackGraphemeString(o, c), - }; - // A fixed pseudo-random sequence - let seed = 7; - const random = () => { - seed = (seed * 1103515245 + 12345) % 2147483648; - return seed / 2147483648; - }; - let view = 0; - let selection: { - startCol: number; - startLine: number; - endCol: number; - endLine: number; - } | null = null; - const manager = { - hasSelection: () => selection !== null, - getSelectionRange: () => { - if (!selection) return null; - const sb = term.getScrollbackLength(); - return { - startCol: selection.startCol, - startRow: selection.startLine - sb + view, - endCol: selection.endCol, - endRow: selection.endLine - sb + view, - }; - }, - clearDirtySelectionRows: () => {}, - } as unknown as Parameters[0]; - let range: { startX: number; startY: number; endX: number; endY: number } | null = null; - // The OSC 8 link ids, and none - const ids = new Set([0]); - for (let o = 0; o < term.getScrollbackLength(); o++) { - for (const cell of term.getScrollbackLine(o) ?? []) ids.add(cell.hyperlink_id); - } - const links = [...ids]; - expect(links.length).toBeGreaterThan(1); - let hovered = 0; - for (const overscan of [1, 3]) { - const options = { overscan }; - const live = renderer(2, options); - live.r.setSelectionManager(manager); - const h = live.r.getMetrics().height; - let position = 0; - let opacity = 1; - for (let step = 0; step < 500; step++) { - const sb = term.getScrollbackLength(); - // Some steps also move, so a change can land on the frame that copies rows - const op = random(); - if (op >= 0.45 && random() < 0.4) position += (random() - 0.5) * 10; - if (op < 0.45) position += (random() - 0.5) * (random() < 0.3 ? 14 : 4); - else if (op < 0.5) position = 0; - else if (op < 0.6) { - const i = Math.floor(random() * 30); - term.write(i % 2 ? `out ${i} e\u0301 g\r\n` : `\x1b[4${i % 8}m${i}\x1b[0m \u4e2d g`); - } else if (op < 0.7) opacity = opacity ? 0 : 1; - else if (op < 0.8) { - const line = Math.floor(random() * (sb + rows)); - selection = - random() < 0.3 - ? null - : { - startCol: Math.floor(random() * cols), - startLine: line, - endCol: Math.floor(random() * cols), - endLine: line + Math.floor(random() * 6), - }; - } else if (op < 0.9) { - const y = Math.floor(random() * rows); - range = random() < 0.3 ? null : { startX: 2, startY: y, endX: 6, endY: y }; - } else { - hovered = links[Math.floor(random() * links.length)]; - } - position = Math.max(0, Math.min(term.getScrollbackLength(), position)); - const { line, offset } = split(position, h, 2); - view = line; - live.r.setHoveredLinkRange(range); - live.r.setHoveredHyperlinkId(hovered); - live.r.setScrollOffset(offset); - live.r.render(term, false, view, provider, opacity); - const full = renderer(2, options); - full.r.setSelectionManager(manager); - full.r.setHoveredLinkRange(range); - full.r.setHoveredHyperlinkId(hovered); - full.r.setScrollOffset(offset); - full.r.render(term, true, view, provider, opacity); - expect(live.shown()).toEqual(full.shown()); + let position = 0; + let opacity = 1; + for (let step = 0; step < 500; step++) { + const sb = term.getScrollbackLength(); + // Some steps also move, so a change can land on the frame that copies rows + const op = random(); + if (op >= 0.45 && random() < 0.4) position += (random() - 0.5) * 10; + if (op < 0.45) position += (random() - 0.5) * (random() < 0.3 ? 14 : 4); + else if (op < 0.5) position = 0; + else if (op < 0.6) { + const i = Math.floor(random() * 30); + term.write(i % 2 ? `out ${i} e\u0301 g\r\n` : `\x1b[4${i % 8}m${i}\x1b[0m \u4e2d g`); + } else if (op < 0.7) opacity = opacity ? 0 : 1; + else if (op < 0.8) { + const line = Math.floor(random() * (sb + rows)); + selection = + random() < 0.3 + ? null + : { + startCol: Math.floor(random() * cols), + startLine: line, + endCol: Math.floor(random() * cols), + endLine: line + Math.floor(random() * 6), + }; + } else if (op < 0.9) { + const y = Math.floor(random() * rows); + range = random() < 0.3 ? null : { startX: 2, startY: y, endX: 6, endY: y }; + } else { + hovered = links[Math.floor(random() * links.length)]; } - } - term.free(); - }); - - test(`a hovered link is repainted out of view too (${engine})`, async () => { - const { term, provider } = await terminal(); - const live = renderer(2, { overscan: 4 }); - live.r.render(term, true, 0, provider, 0); - // Each line in turn is the last row, then just below the viewport while the hover - // changes, then the last row again. - let hovered = 0; - for (let y = 0; y < 24; y++) { - live.r.render(term, false, y, provider, 0); - live.r.render(term, false, y + 1, provider, 0); - hovered = 1 - hovered; + position = Math.max(0, Math.min(term.getScrollbackLength(), position)); + const { line, offset } = split(position, h, 2); + view = line; + live.r.setHoveredLinkRange(range); live.r.setHoveredHyperlinkId(hovered); - live.r.render(term, false, y + 1, provider, 0); - live.r.render(term, false, y, provider, 0); - const full = renderer(2, { overscan: 4 }); + live.r.setScrollOffset(offset); + live.r.render(term, false, view, provider, opacity); + const full = renderer(2, options); + full.r.setSelectionManager(manager); + full.r.setHoveredLinkRange(range); full.r.setHoveredHyperlinkId(hovered); - full.r.render(term, true, y, provider, 0); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, provider, opacity); expect(live.shown()).toEqual(full.shown()); } - term.free(); - }); + } + term.free(); + }); - test(`rows out of view follow output at the bottom (${engine})`, async () => { - const { term } = await terminal(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => - term.getScrollbackGraphemeString(o, c), - }; - const live = renderer(2, { overscan: 4 }); - live.r.render(term, true, 0, provider, 0); - // Draw the rows above the viewport, return, and let output (a new line, then a line - // changed in place) move the lines they show - for (const output of [ - '\x1b[8;1H\r\nnew line e\u0301 g', - '\rsame line g\x1b[K', - '\r\n\x1b[41m\u4e2d\u4e2d\x1b[0m', - ]) { - for (const y of [3, 2, 1, 0]) live.r.render(term, false, y, provider, 0); - term.write(output); - live.r.render(term, false, 0, provider, 0); - for (const [y, offset] of [ - [0, 7.5], - [2, 0], - [3, 0.5], - ]) { - live.r.setScrollOffset(offset); - live.r.render(term, false, y, provider, 0); - expect(live.shown()).toEqual(previous({ term, provider }, y, offset, 2)); - } - live.r.setScrollOffset(0); - } - term.free(); - }); + test(`a hovered link is repainted out of view too`, async () => { + const { term, provider } = await terminal(); + const live = renderer(2, { overscan: 4 }); + live.r.render(term, true, 0, provider, 0); + // Each line in turn is the last row, then just below the viewport while the hover + // changes, then the last row again. + let hovered = 0; + for (let y = 0; y < 24; y++) { + live.r.render(term, false, y, provider, 0); + live.r.render(term, false, y + 1, provider, 0); + hovered = 1 - hovered; + live.r.setHoveredHyperlinkId(hovered); + live.r.render(term, false, y + 1, provider, 0); + live.r.render(term, false, y, provider, 0); + const full = renderer(2, { overscan: 4 }); + full.r.setHoveredHyperlinkId(hovered); + full.r.render(term, true, y, provider, 0); + expect(live.shown()).toEqual(full.shown()); + } + term.free(); + }); - test(`a scroll draws only the rows that came into view and the edges (${engine})`, async () => { - const t = await terminal(); - const live = renderer(2, { overscan: 6 }); - const h = live.r.getMetrics().height; - live.r.render(t.term, true, 0, t.provider, 1); - let view = 0; - for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { - const before = live.drawn.length; - live.r.render(t.term, false, y, t.provider, 1); - // The rows that came into view and the one their glyphs overflow into, the new - // last row, and the row the old cursor moved to with the row below it. - expect(live.drawn.length - before).toBeLessThanOrEqual(Math.abs(y - view) + 4); - view = y; - } - // Up and down within the overscan once its rows are drawn: no copy, and per line - // only the rows at the edges, which lose or gain what the line beyond draws into them - for (let p = 1; p <= 4; p += 0.25) { - const { line, offset } = split(p, h, 2); + test(`rows out of view follow output at the bottom`, async () => { + const { term } = await terminal(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; + const live = renderer(2, { overscan: 4 }); + live.r.render(term, true, 0, provider, 0); + // Draw the rows above the viewport, return, and let output (a new line, then a line + // changed in place) move the lines they show + for (const output of [ + '\x1b[8;1H\r\nnew line e\u0301 g', + '\rsame line g\x1b[K', + '\r\n\x1b[41m\u4e2d\u4e2d\x1b[0m', + ]) { + for (const y of [3, 2, 1, 0]) live.r.render(term, false, y, provider, 0); + term.write(output); + live.r.render(term, false, 0, provider, 0); + for (const [y, offset] of [ + [0, 7.5], + [2, 0], + [3, 0.5], + ]) { live.r.setScrollOffset(offset); - live.r.render(t.term, false, line, t.provider, 1); + live.r.render(term, false, y, provider, 0); + expect(live.shown()).toEqual(previous({ term, provider }, y, offset, 2)); } - const copies = live.copies(); + live.r.setScrollOffset(0); + } + term.free(); + }); + + test(`a scroll draws only the rows that came into view and the edges`, async () => { + const t = await terminal(); + const live = renderer(2, { overscan: 6 }); + const h = live.r.getMetrics().height; + live.r.render(t.term, true, 0, t.provider, 1); + let view = 0; + for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { const before = live.drawn.length; - for (let p = 4; p >= 1; p -= 0.25) { - const { line, offset } = split(p, h, 2); + live.r.render(t.term, false, y, t.provider, 1); + // The rows that came into view and the one their glyphs overflow into, the new + // last row, and the row the old cursor moved to with the row below it. + expect(live.drawn.length - before).toBeLessThanOrEqual(Math.abs(y - view) + 4); + view = y; + } + // Up and down within the overscan once its rows are drawn: no copy, and per line + // only the rows at the edges, which lose or gain what the line beyond draws into them + for (let p = 1; p <= 4; p += 0.25) { + const { line, offset } = split(p, h, 2); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 1); + } + const copies = live.copies(); + const before = live.drawn.length; + for (let p = 4; p >= 1; p -= 0.25) { + const { line, offset } = split(p, h, 2); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 1); + } + expect(live.copies()).toBe(copies); + expect(live.drawn.length - before).toBeLessThanOrEqual(3 * 4 + 2); + // Frames at a fraction of a line with nothing else changed draw nothing + const still = live.drawn.length; + for (const offset of [0.5, 3, 7.5, 3]) { + live.r.setScrollOffset(offset); + live.r.render(t.term, false, 1, t.provider, 1); + } + expect(live.drawn.length).toBe(still); + t.term.free(); + }); + + test(`a scrolled frame with nothing changed draws no row`, async () => { + const { term, provider } = await terminal(); + const live = renderer(2); + live.r.render(term, true, 0, provider, 1); + live.r.render(term, false, 6, provider, 1); + const before = live.drawn.length; + for (let i = 0; i < 3; i++) live.r.render(term, false, 6, provider, 1); + expect(live.drawn.length).toBe(before); + // Dropping the scrollbar repaints the rows in view, so none keeps the thumb. + live.r.render(term, false, 6, provider, 0); + expect(live.drawn.length - before).toBe(rows); + const full = renderer(2); + full.r.render(term, true, 6, provider, 0); + expect(live.shown()).toEqual(full.shown()); + term.free(); + }); + + test(`selection, link underline and cursor follow a scroll`, async () => { + const { term, provider } = await terminal(); + const sb = provider.getScrollbackLength(); + let view = 0; + // A selection anchored to buffer lines, as SelectionManager keeps it, reaching past + // the viewport. + const first = { startCol: 3, startLine: sb - 4, endCol: 12, endLine: sb + 2 }; + const second = { startCol: 0, startLine: sb - 12, endCol: 4, endLine: sb - 9 }; + let selection = first; + const manager = { + hasSelection: () => true, + getSelectionRange: () => ({ + startCol: selection.startCol, + startRow: selection.startLine - sb + view, + endCol: selection.endCol, + endRow: selection.endLine - sb + view, + }), + clearDirtySelectionRows: () => {}, + } as unknown as Parameters[0]; + const range = { startX: 2, startY: 6, endX: 9, endY: 6 }; + for (const overscan of [1, 4]) { + const options = { overscan, theme: { cursor: 'rgba(255, 0, 0, 0.3)' } }; + const live = renderer(2, options); + live.r.setSelectionManager(manager); + live.r.setHoveredLinkRange(range); + live.r.render(term, true, 0, provider, 1); + selection = first; + for (const [y, offset, changed] of [ + [1, 0], + [3, 0], + [3, 7.5], + [2, 0.5], + [0, 4], + [0, 0], + [2, 3], + [7, 0], + [9, 1], + [6, 0], + [6, 0, second], + [6, 2], + [11, 0], + // The selection changes on a frame that copies rows, with the old one still in view + [13, 0, first], + [12, 0, second], + [5, 3, first], + [0, 0], + ] as const) { + if (changed) selection = changed; + view = y; live.r.setScrollOffset(offset); - live.r.render(t.term, false, line, t.provider, 1); + live.r.render(term, false, view, provider, 1); + const full = renderer(2, options); + full.r.setSelectionManager(manager); + full.r.setHoveredLinkRange(range); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, provider, 1); + expect(live.shown()).toEqual(full.shown()); } - expect(live.copies()).toBe(copies); - expect(live.drawn.length - before).toBeLessThanOrEqual(3 * 4 + 2); - // Frames at a fraction of a line with nothing else changed draw nothing - const still = live.drawn.length; - for (const offset of [0.5, 3, 7.5, 3]) { - live.r.setScrollOffset(offset); - live.r.render(t.term, false, 1, t.provider, 1); + } + term.free(); + }); + + test(`a translucent cursor drawn again is not blended over itself`, async () => { + const { term, provider } = await terminal(); + const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; + for (const cursorStyle of ['block', 'bar', 'underline'] as const) { + const live = renderer(2, { theme, cursorStyle }); + live.r.render(term, true, 0, provider, 1); + // Idle frames, a scroll away and back, and a frame that repaints the cursor row + for (const [y, force] of [ + [0, false], + [0, false], + [2, false], + [0, false], + [0, false], + [0, true], + [0, false], + ] as const) { + live.r.render(term, force, y, provider, 1); + const full = renderer(2, { theme, cursorStyle }); + full.r.render(term, true, y, provider, 1); + expect(live.shown()).toEqual(full.shown()); } - expect(live.drawn.length).toBe(still); - t.term.free(); - }); + live.r.dispose(); + } + term.free(); + }); - test(`a scrolled frame with nothing changed draws no row (${engine})`, async () => { - const { term, provider } = await terminal(); + test(`the block cursor redraws the grapheme of its own screen row`, async () => { + const ghostty = await Ghostty.load(WASM); + const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); + // Every row starts with a cluster of its own, the cursor on a combining one + term.write( + Array.from({ length: rows }, (_, i) => `${'abcdefgh'[i]}\u0301\u0302 ${i}`).join('\r\n'), + ); + term.write('\x1b[3;1H'); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; + const live = renderer(2, { cursorStyle: 'block' }); + const internals = live.r as unknown as { renderCellText: (...args: unknown[]) => void }; + const renderCellText = internals.renderCellText.bind(live.r); + const accented: string[] = []; + internals.renderCellText = (...args: unknown[]) => { + // The cursor draws its character in the accent colour + if (args[3] !== undefined) accented.push((args[4] as (col: number) => string)(0)); + renderCellText(...args); + }; + live.r.render(term, true, 0, provider, 1); + expect(accented).toEqual([term.getGraphemeString(2, 0)]); + expect(accented[0]).toBe('c\u0301\u0302'); + live.r.dispose(); + term.free(); + }); + + test(`a restored context is repainted on the next frame`, async () => { + const { term, provider } = await terminal(); + for (const y of [6, 0]) { const live = renderer(2); live.r.render(term, true, 0, provider, 1); - live.r.render(term, false, 6, provider, 1); - const before = live.drawn.length; - for (let i = 0; i < 3; i++) live.r.render(term, false, 6, provider, 1); - expect(live.drawn.length).toBe(before); - // Dropping the scrollbar repaints the rows in view, so none keeps the thumb. - live.r.render(term, false, 6, provider, 0); - expect(live.drawn.length - before).toBe(rows); + live.r.render(term, false, y, provider, 1); + live.lose(); + live.r.render(term, false, y, provider, 1); const full = renderer(2); - full.r.render(term, true, 6, provider, 0); + full.r.render(term, true, y, provider, 1); expect(live.shown()).toEqual(full.shown()); - term.free(); - }); - - test(`selection, link underline and cursor follow a scroll (${engine})`, async () => { - const { term, provider } = await terminal(); - const sb = provider.getScrollbackLength(); - let view = 0; - // A selection anchored to buffer lines, as SelectionManager keeps it, reaching past - // the viewport. - const first = { startCol: 3, startLine: sb - 4, endCol: 12, endLine: sb + 2 }; - const second = { startCol: 0, startLine: sb - 12, endCol: 4, endLine: sb - 9 }; - let selection = first; - const manager = { - hasSelection: () => true, - getSelectionRange: () => ({ - startCol: selection.startCol, - startRow: selection.startLine - sb + view, - endCol: selection.endCol, - endRow: selection.endLine - sb + view, - }), - clearDirtySelectionRows: () => {}, - } as unknown as Parameters[0]; - const range = { startX: 2, startY: 6, endX: 9, endY: 6 }; - for (const overscan of [1, 4]) { - const options = { overscan, theme: { cursor: 'rgba(255, 0, 0, 0.3)' } }; - const live = renderer(2, options); - live.r.setSelectionManager(manager); - live.r.setHoveredLinkRange(range); - live.r.render(term, true, 0, provider, 1); - selection = first; - for (const [y, offset, changed] of [ - [1, 0], - [3, 0], - [3, 7.5], - [2, 0.5], - [0, 4], - [0, 0], - [2, 3], - [7, 0], - [9, 1], - [6, 0], - [6, 0, second], - [6, 2], - [11, 0], - // The selection changes on a frame that copies rows, with the old one still in view - [13, 0, first], - [12, 0, second], - [5, 3, first], - [0, 0], - ] as const) { - if (changed) selection = changed; - view = y; - live.r.setScrollOffset(offset); - live.r.render(term, false, view, provider, 1); - const full = renderer(2, options); - full.r.setSelectionManager(manager); - full.r.setHoveredLinkRange(range); - full.r.setScrollOffset(offset); - full.r.render(term, true, view, provider, 1); - expect(live.shown()).toEqual(full.shown()); - } - } - term.free(); - }); - - test(`a translucent cursor drawn again is not blended over itself (${engine})`, async () => { - const { term, provider } = await terminal(); - const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; - for (const cursorStyle of ['block', 'bar', 'underline'] as const) { - const live = renderer(2, { theme, cursorStyle }); - live.r.render(term, true, 0, provider, 1); - // Idle frames, a scroll away and back, and a frame that repaints the cursor row - for (const [y, force] of [ - [0, false], - [0, false], - [2, false], - [0, false], - [0, false], - [0, true], - [0, false], - ] as const) { - live.r.render(term, force, y, provider, 1); - const full = renderer(2, { theme, cursorStyle }); - full.r.render(term, true, y, provider, 1); - expect(live.shown()).toEqual(full.shown()); - } - live.r.dispose(); - } - term.free(); - }); - - test(`the block cursor redraws the grapheme of its own screen row (${engine})`, async () => { - const ghostty = await Ghostty.load(wasm, engine); - const term = ghostty.createTerminal(cols, rows, { scrollbackLimit: 10000 }); - // Every row starts with a cluster of its own, the cursor on a combining one - term.write( - Array.from({ length: rows }, (_, i) => `${'abcdefgh'[i]}\u0301\u0302 ${i}`).join('\r\n'), - ); - term.write('\x1b[3;1H'); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => - term.getScrollbackGraphemeString(o, c), - }; - const live = renderer(2, { cursorStyle: 'block' }); - const internals = live.r as unknown as { renderCellText: (...args: unknown[]) => void }; - const renderCellText = internals.renderCellText.bind(live.r); - const accented: string[] = []; - internals.renderCellText = (...args: unknown[]) => { - // The cursor draws its character in the accent colour - if (args[3] !== undefined) accented.push((args[4] as (col: number) => string)(0)); - renderCellText(...args); - }; - live.r.render(term, true, 0, provider, 1); - expect(accented).toEqual([term.getGraphemeString(2, 0)]); - expect(accented[0]).toBe('c\u0301\u0302'); + // Disposed, it no longer listens live.r.dispose(); - term.free(); - }); - - test(`a restored context is repainted on the next frame (${engine})`, async () => { - const { term, provider } = await terminal(); - for (const y of [6, 0]) { - const live = renderer(2); - live.r.render(term, true, 0, provider, 1); - live.r.render(term, false, y, provider, 1); - live.lose(); - live.r.render(term, false, y, provider, 1); - const full = renderer(2); - full.r.render(term, true, y, provider, 1); - expect(live.shown()).toEqual(full.shown()); - // Disposed, it no longer listens - live.r.dispose(); - live.lose(); - live.r.render(term, false, y, provider, 1); - expect(live.shown()).not.toEqual(full.shown()); - } - term.free(); - }); - } + live.lose(); + live.r.render(term, false, y, provider, 1); + expect(live.shown()).not.toEqual(full.shown()); + } + term.free(); + }); }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index c0d1dc5..d3fb15f 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -169,3 +169,36 @@ describe('Terminal scrollback options', () => { expect(wasmConfig({ scrollback: 3277 })?.scrollbackLines).toBeUndefined(); }); }); + +describe('Terminal bell and title', () => { + // Bell and title come from the engine's readEffects, not from scanning the output + // (ADR 047). + test('a bell and a title reported by readEffects reach onBell and onTitleChange', () => { + const term = new Terminal({ ghostty: {} as never }); + let effects: { bell: boolean; title: string | null } = { bell: true, title: 'vim' }; + (term as unknown as { wasmTerm: object }).wasmTerm = { + write: () => {}, + hasResponse: () => false, + readResponse: () => null, + readEffects: () => { + const e = effects; + effects = { bell: false, title: null }; + return e; + }, + }; + const write = (data: string) => + (term as unknown as { writeInternal(data: string): void }).writeInternal(data); + let bells = 0; + const titles: string[] = []; + term.onBell(() => bells++); + term.onTitleChange((title) => titles.push(title)); + + write('\x1b]2;vim\x07\x07'); + expect(bells).toBe(1); + expect(titles).toEqual(['vim']); + // A BEL or OSC 2 the engine did not report fires nothing. + write('\x07\x1b]2;other\x07'); + expect(bells).toBe(1); + expect(titles).toEqual(['vim']); + }); +}); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index f78cfc4..1ca4f93 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -47,21 +47,6 @@ describe('server — routes', () => { expect(res.status).toBe(404); }); - test('GET /ghostty-vt.wasm serves the vendored wasm byte for byte', async () => { - const sidecar = JSON.parse( - fs.readFileSync( - path.resolve(__dirname, '../client/vendor/ghostty-web/ghostty-vt.wasm.json'), - 'utf8', - ), - ) as { size: number; sha256: string }; - const res = await fetch(`${baseUrl}/ghostty-vt.wasm`); - expect(res.status).toBe(200); - expect(res.headers.get('content-type')).toBe('application/wasm'); - const body = new Uint8Array(await res.arrayBuffer()); - expect(body.byteLength).toBe(sidecar.size); - expect(createHash('sha256').update(body).digest('hex')).toBe(sidecar.sha256); - }); - test('GET /ghostty-vt-small.wasm serves the official wasm byte for byte', async () => { const sidecar = JSON.parse( fs.readFileSync( @@ -78,7 +63,12 @@ describe('server — routes', () => { }); test('wasm routes serve only the listed files', async () => { - for (const p of ['/ghostty-vt.wasm.json', '/ghostty-vt-small.wasm.minisig']) { + // /ghostty-vt.wasm was the Coder engine's wasm, removed in ADR 047. + for (const p of [ + '/ghostty-vt.wasm', + '/ghostty-vt-small.wasm.json', + '/ghostty-vt-small.wasm.minisig', + ]) { const res = await fetch(`${baseUrl}${p}`); expect(res.status).toBe(404); } diff --git a/src/server/routes.ts b/src/server/routes.ts index a076614..e326184 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -72,7 +72,7 @@ export function readJson(req: http.IncomingMessage): Promise { * * @param req - The incoming HTTP request. * @param res - The HTTP response object. - * @param wasmPaths - Served wasm files: URL path (`/ghostty-vt.wasm`) → file path. + * @param wasmPaths - Served wasm files: URL path (`/ghostty-vt-small.wasm`) → file path. * @param clientDistPath - Path to the client dist directory. * @param onStop - Callback invoked when `POST /api/server/stop` is received. */ diff --git a/src/server/static.test.ts b/src/server/static.test.ts index b2d9b78..4b82f7b 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -28,48 +28,12 @@ describe('mimeType', () => { }); describe('findGhosttyWasm', () => { - const VENDORED = path.join('src', 'client', 'vendor', 'ghostty-web', 'ghostty-vt.wasm'); - const BUILT = path.join('dist', 'ghostty-vt.wasm'); - - test('prefers the wasm copied into dist/ by the build', () => { - const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => String(p).endsWith(BUILT)); - expect(findGhosttyWasm().endsWith(BUILT)).toBe(true); - existsSpy.mockRestore(); - }); - - test('falls back to the vendored wasm when dist/ has none', () => { - const realExistsSync = fs.existsSync.bind(fs); - const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => { - const s = String(p); - return s.endsWith(BUILT) ? false : realExistsSync(s); - }); - const wasmPath = findGhosttyWasm(); - expect(wasmPath.endsWith(VENDORED)).toBe(true); - expect(realExistsSync(wasmPath)).toBe(true); - existsSpy.mockRestore(); - }); - - test('exits when the wasm is missing', () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - findGhosttyWasm(); - - expect(exitSpy).toHaveBeenCalledWith(1); - existsSpy.mockRestore(); - exitSpy.mockRestore(); - errorSpy.mockRestore(); - }); -}); - -describe('findGhosttyWasm — official ghostty-vt-small.wasm', () => { const VENDORED = path.join('src', 'client', 'vendor', 'ghostty-vt', 'ghostty-vt-small.wasm'); const BUILT = path.join('dist', 'ghostty-vt-small.wasm'); test('prefers the wasm copied into dist/ by the build', () => { const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => String(p).endsWith(BUILT)); - expect(findGhosttyWasm('ghostty-vt-small.wasm').endsWith(BUILT)).toBe(true); + expect(findGhosttyWasm().endsWith(BUILT)).toBe(true); existsSpy.mockRestore(); }); @@ -79,7 +43,7 @@ describe('findGhosttyWasm — official ghostty-vt-small.wasm', () => { const s = String(p); return s.endsWith(BUILT) ? false : realExistsSync(s); }); - const wasmPath = findGhosttyWasm('ghostty-vt-small.wasm'); + const wasmPath = findGhosttyWasm(); expect(wasmPath.endsWith(VENDORED)).toBe(true); expect(realExistsSync(wasmPath)).toBe(true); existsSpy.mockRestore(); @@ -90,7 +54,7 @@ describe('findGhosttyWasm — official ghostty-vt-small.wasm', () => { const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - findGhosttyWasm('ghostty-vt-small.wasm'); + findGhosttyWasm(); expect(exitSpy).toHaveBeenCalledWith(1); expect(errorSpy).toHaveBeenCalledWith('Error: Could not find ghostty-vt-small.wasm.'); @@ -101,10 +65,9 @@ describe('findGhosttyWasm — official ghostty-vt-small.wasm', () => { }); describe('findGhosttyWasms', () => { - test('maps both served wasm URLs to their files', () => { + test('maps the served wasm URL to its file', () => { const paths = findGhosttyWasms(); - expect(Object.keys(paths).sort()).toEqual(['/ghostty-vt-small.wasm', '/ghostty-vt.wasm']); - expect(path.basename(paths['/ghostty-vt.wasm'] as string)).toBe('ghostty-vt.wasm'); + expect(Object.keys(paths)).toEqual(['/ghostty-vt-small.wasm']); expect(path.basename(paths['/ghostty-vt-small.wasm'] as string)).toBe('ghostty-vt-small.wasm'); for (const p of Object.values(paths)) expect(fs.existsSync(p)).toBe(true); }); diff --git a/src/server/static.ts b/src/server/static.ts index 213813a..fae8ce2 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -32,11 +32,10 @@ export function mimeType(filePath: string): string { /** * The wasm files the server serves at `GET /`, each with the directory under - * `src/client/vendor/` it is vendored in: the Coder engine's `ghostty-vt.wasm` (ADR 041) - * and the official libghostty-vt `ghostty-vt-small.wasm` (ADR 042). + * `src/client/vendor/` it is vendored in: the official libghostty-vt `ghostty-vt-small.wasm` + * (ADR 042), the only engine since ADR 047. */ export const GHOSTTY_WASM_FILES = { - 'ghostty-vt.wasm': 'ghostty-web', 'ghostty-vt-small.wasm': 'ghostty-vt', } as const; @@ -46,11 +45,11 @@ export type GhosttyWasmFile = keyof typeof GHOSTTY_WASM_FILES; * Locates a served wasm file, preferring the copy the build puts in `dist/` (npm install) * over the vendored source copy (local dev). Exits the process if neither exists. * - * @param file - The wasm file name; defaults to the Coder engine's `ghostty-vt.wasm`. + * @param file - The wasm file name; defaults to `ghostty-vt-small.wasm`. * @returns The absolute path to the wasm file. * @throws Exits the process with code 1 if the wasm cannot be found. */ -export function findGhosttyWasm(file: GhosttyWasmFile = 'ghostty-vt.wasm'): string { +export function findGhosttyWasm(file: GhosttyWasmFile = 'ghostty-vt-small.wasm'): string { const projectRoot = path.join(__dirname, '..', '..'); const candidates = [ path.join(projectRoot, 'dist', file), diff --git a/src/version.test.ts b/src/version.test.ts index 363392d..4667cf5 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -51,11 +51,7 @@ describe('getVersion — bundled dist', () => { external: ['@lydell/node-pty', 'ws'], }); if (!result.success) throw new Error(`bundle failed: ${result.logs.join('\n')}`); - // The server needs both wasm files at startup; the published package ships them in dist/. - fs.copyFileSync( - path.join(REPO_ROOT, 'src/client/vendor/ghostty-web/ghostty-vt.wasm'), - path.join(pkgRoot, 'dist/ghostty-vt.wasm'), - ); + // The server needs the wasm at startup; the published package ships it in dist/. fs.copyFileSync( path.join(REPO_ROOT, 'src/client/vendor/ghostty-vt/ghostty-vt-small.wasm'), path.join(pkgRoot, 'dist/ghostty-vt-small.wasm'), From 47f973cc5fb36194ff46668668d4ef7012386d57 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:27:55 -0400 Subject: [PATCH 039/295] refactor(client): set history and theme colours without Coder encodings The Coder config struct needed a byte scrollback limit and encoded an unset colour as 0, which is also #000000 (ADR 047). - The client no longer derives a history limit from config.scrollback, and ClientConfig drops scrollback. The terminal's scrollback option and GhosttyTerminalConfig.scrollbackLimit go. - The binding always sets SCROLLBACK_MAX_LINES from scrollbackLines, default 10,000, under the 32 MiB ceiling. A terminal built without config now keeps 10,000 lines instead of 10,000 bytes. - Theme colours are number | undefined, and undefined means unset. colorsSet goes. The 0xcccccc / 0x000000 fallback for a missing foreground or background stays as webtty's default. --- src/client/index.ts | 4 -- src/client/vendor/ghostty-web/ghostty.ts | 49 ++++++++------------ src/client/vendor/ghostty-web/interfaces.ts | 3 +- src/client/vendor/ghostty-web/terminal.ts | 21 ++------- src/client/vendor/ghostty-web/types.ts | 23 +++------- src/client/vendor/official-terminal.test.ts | 51 +++++++++++++-------- src/client/vendor/terminal.test.ts | 14 ++++-- src/config.ts | 4 +- 8 files changed, 74 insertions(+), 95 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index ceeb7b8..c425a24 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -47,7 +47,6 @@ interface ClientConfig { fontFamily: string; cursorStyle: 'block' | 'bar' | 'underline'; cursorStyleBlink: boolean; - scrollback: number; scrollbackLines: number; theme: Theme; copyOnSelect: boolean; @@ -81,9 +80,6 @@ const term = new Terminal({ cursorBlink: config.cursorStyleBlink, fontSize: config.fontSize, fontFamily: config.fontFamily, - // The Coder engine's history in bytes, derived from the server's replay buffer; - // the official engine uses scrollbackLines (ADR 046). - scrollback: Math.ceil(config.scrollback / 80), scrollbackLines: config.scrollbackLines, mouseScrollSpeed: config.mouseScrollSpeed, theme: terminalTheme, diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 6232c93..5e554a0 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -776,14 +776,11 @@ interface DecodedStyle { const I32 = WasmValType.I32; /** At most this many pages of scrollback rows stay cached per generation. */ const PAGE_CACHE_SIZE = 16; -/** - * The scrollback byte ceiling: with `scrollbackLines` always (ADR 046), and for - * `scrollback: 0`, which is unlimited on the Coder wasm. - */ +/** The scrollback byte ceiling under the line limit (ADR 046). */ const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; -/** The Coder engine's default scrollback limit when no config is given. */ -const DEFAULT_SCROLLBACK_BYTES = 10000; -/** The Coder engine's default colours, used when the theme leaves one unset. */ +/** History in lines when the config gives none (ADR 046, ADR 047). */ +const DEFAULT_SCROLLBACK_LINES = 10000; +/** webtty's default colours, used when the theme leaves one unset. */ const FALLBACK_FOREGROUND = 0xcccccc; const FALLBACK_BACKGROUND = 0x000000; /** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ @@ -1460,21 +1457,14 @@ export class OfficialTerminal implements GhosttyTerminal { const L = this.L; const at = this.at; - // Scrollback in lines under a 32 MiB ceiling (ADR 046); without scrollbackLines, - // the derived byte budget (ADR 042). Whichever limit is reached first applies. 0 - // bytes would disable scrollback and a NULL byte limit would remove the ceiling, - // so neither is ever passed; NULL lines removes the line limit. - const lines = config?.scrollbackLines; - if (lines !== undefined) { - this.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); - this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); - if (lines > 0) this.dv().setUint32(at.u32, lines, true); - this.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); - } else { - const limit = config?.scrollbackLimit ?? DEFAULT_SCROLLBACK_BYTES; - this.dv().setUint32(at.u32, limit > 0 ? limit : SCROLLBACK_CEILING_BYTES, true); - this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); - } + // Scrollback in lines under a 32 MiB ceiling (ADR 046). Whichever limit is reached + // first applies. 0 bytes would disable scrollback and a NULL byte limit would + // remove the ceiling, so neither is ever passed; NULL lines removes the line limit. + const lines = config?.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES; + this.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); + this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + if (lines > 0) this.dv().setUint32(at.u32, lines, true); + this.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); // Grapheme clustering (mode 2027) on by default, so RIS keeps it. const dv = this.dv(); @@ -1482,9 +1472,6 @@ export class OfficialTerminal implements GhosttyTerminal { dv.setUint8(at.mode + L.mode.value, 1); this.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); - const given = config?.colorsSet; - const isSet = (flag: boolean | undefined, value: number | undefined) => - given ? !!flag && value !== undefined : !!value; const setColor = (option: number, value: number, what: string) => { this.writeRgb(at.u32, value); this.setOption(option, at.u32, what); @@ -1492,25 +1479,25 @@ export class OfficialTerminal implements GhosttyTerminal { // Foreground and background are always both set: Ghostty's render state copies // the default colours only when both are set (render.zig `bg_fg`), otherwise it // keeps white on black and ignores the given colour, OSC 10/11 and DECSCNM. - // An unset one falls back to the Coder engine's effective default. + // An unset one falls back to webtty's default. setColor( L.opt.COLOR_FOREGROUND, - isSet(given?.foreground, config?.fgColor) ? (config?.fgColor ?? 0) : FALLBACK_FOREGROUND, + config?.fgColor ?? FALLBACK_FOREGROUND, 'COLOR_FOREGROUND' ); setColor( L.opt.COLOR_BACKGROUND, - isSet(given?.background, config?.bgColor) ? (config?.bgColor ?? 0) : FALLBACK_BACKGROUND, + config?.bgColor ?? FALLBACK_BACKGROUND, 'COLOR_BACKGROUND' ); if (!config) return; - if (isSet(given?.cursor, config.cursorColor)) { - setColor(L.opt.COLOR_CURSOR, config.cursorColor ?? 0, 'COLOR_CURSOR'); + if (config.cursorColor !== undefined) { + setColor(L.opt.COLOR_CURSOR, config.cursorColor, 'COLOR_CURSOR'); } const palette = config.palette ?? []; const entries = palette .map((value, i) => ({ value, i })) - .filter(({ value, i }) => i < 16 && isSet(given?.palette?.[i], value)); + .filter((e): e is { value: number; i: number } => e.i < 16 && e.value !== undefined); if (entries.length > 0) { // Unset entries and 16-255 keep Ghostty's default palette. const rc = this.ex.ghostty_terminal_get( diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index d9e9020..e1cfc9a 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -10,8 +10,7 @@ export interface ITerminalOptions { cursorBlink?: boolean; // Default: false cursorStyle?: 'block' | 'underline' | 'bar'; theme?: ITheme; - scrollback?: number; // Default: 10000 - scrollbackLines?: number; // Official engine: history in lines, 0 = no line limit (webtty, ADR 046) + scrollbackLines?: number; // History in lines, 0 = no line limit; default 10000 (webtty, ADR 046) fontSize?: number; // Default: 15 fontFamily?: string; // Default: 'monospace' allowTransparency?: boolean; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index f77d548..9cc9343 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -255,7 +255,6 @@ export class Terminal implements ITerminalCore { cursorBlink: options.cursorBlink ?? false, cursorStyle: options.cursorStyle ?? 'block', theme: options.theme ?? {}, - scrollback: options.scrollback ?? 10000, scrollbackLines: options.scrollbackLines, fontSize: options.fontSize ?? 15, fontFamily: options.fontFamily ?? 'monospace', @@ -398,11 +397,10 @@ export class Terminal implements ITerminalCore { */ private buildWasmConfig(): GhosttyTerminalConfig | undefined { const theme = this.options.theme; - const scrollback = this.options.scrollback; const scrollbackLines = this.options.scrollbackLines; // If no theme and default scrollback, use defaults - if (!theme && scrollback === 10000 && scrollbackLines === undefined) { + if (!theme && scrollbackLines === undefined) { return undefined; } @@ -432,20 +430,11 @@ export class Terminal implements ITerminalCore { const cursor = this.parseColor(theme?.cursor); return { - scrollbackLimit: scrollback, scrollbackLines, - fgColor: fg ?? 0, - bgColor: bg ?? 0, - cursorColor: cursor ?? 0, - palette: paletteColors.map((color) => color ?? 0), - // Which colours parsed, so the official engine sets #000000 as black instead - // of reading 0 as "use default". The Coder engine ignores it. (webtty, ADR 042) - colorsSet: { - foreground: fg !== undefined, - background: bg !== undefined, - cursor: cursor !== undefined, - palette: paletteColors.map((color) => color !== undefined), - }, + fgColor: fg, + bgColor: bg, + cursorColor: cursor, + palette: paletteColors, }; } diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index 4080e3a..de5fcd5 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -385,31 +385,20 @@ export interface RenderStateColors { } /** - * Terminal configuration (passed to ghostty_terminal_new_with_config) - * All color values use 0xRRGGBB format. A value of 0 means "use default". + * Terminal configuration, applied when the terminal is created. Colours are + * 0xRRGGBB; undefined leaves one unset, so 0 is black. (webtty, ADR 047) */ export interface GhosttyTerminalConfig { - scrollbackLimit?: number; /** - * History in lines, with a 32 MiB ceiling; 0 = no line limit. Official engine - * only; when set it replaces `scrollbackLimit` there. (webtty, ADR 046) + * History in lines, with a 32 MiB ceiling; 0 = no line limit. Default 10,000. + * (webtty, ADR 046) */ scrollbackLines?: number; fgColor?: number; bgColor?: number; cursorColor?: number; - palette?: number[]; - /** - * webtty (ADR 042): which colours above were given, so the official engine can - * set `#000000` (0) as black instead of reading it as "use default". Without it, - * a non-zero value counts as given. The Coder engine ignores it. - */ - colorsSet?: { - foreground?: boolean; - background?: boolean; - cursor?: boolean; - palette?: boolean[]; - }; + /** The 16 ANSI colours; an undefined entry keeps Ghostty's default. */ + palette?: (number | undefined)[]; } /** diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 4651a99..9f8da80 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -28,7 +28,6 @@ const BG = 0x1e1e1e; const RED = 0xcd3131; const BLUE = 0x2472c8; const THEME = { - scrollbackLimit: 10000, fgColor: FG, bgColor: BG, palette: [0x000000, RED, 0x0dbc79, 0xe5e510, BLUE], @@ -162,19 +161,30 @@ describe('official engine: cells', () => { }); test('uses an explicitly black theme background instead of the default', () => { - const t = term(40, 5, { - fgColor: FG, - bgColor: 0, - colorsSet: { foreground: true, background: true }, - }); + const t = term(40, 5, { fgColor: FG, bgColor: 0 }); expect(t.getColors().background).toEqual({ r: 0, g: 0, b: 0 }); t.write('\x1b]11;?\x07'); expect(t.readResponse()).toBe('\x1b]11;rgb:0000/0000/0000\x07'); t.free(); }); + test("sets a black palette entry as black and keeps Ghostty's default for an unset one", () => { + const fgs = (config: object) => { + const t = term(10, 3, config); + t.write('\x1b[30mA\x1b[31mB'); + t.update(); + const line = t.getLine(0) ?? []; + t.free(); + return [rgb(line[0]), rgb(line[1])]; + }; + const ghosttyBlack = fgs({})[0]; + expect(ghosttyBlack).not.toBe(0); + expect(fgs({ palette: [0x000000, RED] })).toEqual([0, RED]); + expect(fgs({ palette: [undefined, RED] })).toEqual([ghosttyBlack, RED]); + }); + // Ghostty's render state copies the default colours only when both are set, so the - // binding always sets both, falling back to the Coder engine's 0xcccccc on black. + // binding always sets both, falling back to webtty's default 0xcccccc on black. const hex = (c: { r: number; g: number; b: number } | null) => c ? (c.r << 16) | (c.g << 8) | c.b : null; const defaults = (t: GhosttyTerminal) => { @@ -198,10 +208,7 @@ describe('official engine: cells', () => { }; test('falls back to the default background when only the foreground is given', () => { - for (const config of [ - { fgColor: FG, bgColor: 0, colorsSet: { foreground: true } }, - { fgColor: FG, bgColor: 0 }, - ]) { + for (const config of [{ fgColor: FG }, { fgColor: FG, bgColor: undefined }]) { const t = term(10, 3, config); expect(defaults(t)).toEqual({ fg: FG, @@ -216,7 +223,7 @@ describe('official engine: cells', () => { }); test('falls back to the default foreground when only the background is given', () => { - const t = term(10, 3, { bgColor: 0x202020, colorsSet: { background: true } }); + const t = term(10, 3, { bgColor: 0x202020 }); expect(defaults(t)).toEqual({ fg: 0xcccccc, bg: 0x202020, @@ -229,7 +236,7 @@ describe('official engine: cells', () => { }); test('without theme colours still applies OSC 11 and DECSCNM', () => { - for (const config of [undefined, { scrollbackLimit: 1000 }]) { + for (const config of [undefined, { scrollbackLines: 1000 }]) { const t = vt.createTerminal(10, 3, config) as OfficialTerminal; expect(defaults(t)).toEqual({ fg: 0xcccccc, @@ -317,7 +324,7 @@ describe('official engine: scrollback, resize and the viewport', () => { const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n'); test('keeps scrolled-off rows, oldest first', () => { - const t = term(20, 5, { scrollbackLimit: 0 }); + const t = term(20, 5, { scrollbackLines: 0 }); t.write(lines(50)); expect(t.getScrollbackLength()).toBe(45); expect(scrollbackText(t, 0)).toBe('line 1'); @@ -329,7 +336,7 @@ describe('official engine: scrollback, resize and the viewport', () => { }); test('keeps active-screen coordinates while the view is scrolled up', () => { - const t = term(20, 5, { scrollbackLimit: 0 }); + const t = term(20, 5, { scrollbackLines: 0 }); t.write(lines(50)); t.setViewportOffset(7); expect(t.isRowDirty(0)).toBe(true); @@ -365,7 +372,7 @@ describe('official engine: scrollback, resize and the viewport', () => { ghostty_terminal_scroll_viewport: counted('ghostty_terminal_scroll_viewport', 'scroll'), }; const spied = new Ghostty(spy as unknown as ConstructorParameters[0]); - const t = spied.createTerminal(20, 5, { scrollbackLimit: 0 }); + const t = spied.createTerminal(20, 5, { scrollbackLines: 0 }); t.write(lines(50)); const sb = t.getScrollbackLength(); expect(sb).toBe(45); @@ -530,7 +537,7 @@ describe('official engine: scrollback, resize and the viewport', () => { describe('official engine: scrollback in lines (ADR 046)', () => { /** Writes `n` numbered lines and returns how many scrollback rows the terminal kept. */ - function keep(config: object, n: number, cols = 80): number { + function keep(config: object | undefined, n: number, cols = 80): number { const t = vt.createTerminal(cols, 24, config) as OfficialTerminal; let chunk = ''; for (let i = 0; i < n; i++) { @@ -557,8 +564,12 @@ describe('official engine: scrollback in lines (ADR 046)', () => { expect(keep({ scrollbackLines: 0 }, 20000)).toBe(20000 - 24 + 1); }); - test('without scrollbackLines the byte limit applies, as before', () => { - expect(keep({ scrollbackLimit: 3277 }, 15000)).toBeLessThan(2000); + test('without scrollbackLines keeps the default 10,000 lines', () => { + for (const config of [undefined, THEME]) { + const kept = keep(config, 15000); + expect(kept).toBeGreaterThan(9000); + expect(kept).toBeLessThanOrEqual(10000 + 200); + } }); }); @@ -657,7 +668,7 @@ describe('official engine: effects and replies', () => { describe('official engine: memory growth', () => { test('reads correct cells and replies after the wasm memory has grown', () => { - const t = term(120, 40, { scrollbackLimit: 0 }); + const t = term(120, 40, { scrollbackLines: 0 }); const memory = vt.ex.memory; const before = memory.buffer.byteLength; let i = 0; diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index d3fb15f..1930a95 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -7,7 +7,7 @@ import { scanDecscusr, Terminal, } from './ghostty-web/terminal'; -import type { ILink } from './ghostty-web/types'; +import type { GhosttyTerminalConfig, ILink } from './ghostty-web/types'; describe('mouseCursorFor', () => { const link: ILink = { @@ -156,7 +156,7 @@ describe('Terminal scrollback options', () => { const wasmConfig = (options: object) => ( new Terminal({ ghostty: {} as never, ...options }) as unknown as { - buildWasmConfig(): { scrollbackLimit?: number; scrollbackLines?: number } | undefined; + buildWasmConfig(): GhosttyTerminalConfig | undefined; } ).buildWasmConfig(); @@ -166,7 +166,15 @@ describe('Terminal scrollback options', () => { }); test('leaves it unset when not given', () => { - expect(wasmConfig({ scrollback: 3277 })?.scrollbackLines).toBeUndefined(); + expect(wasmConfig({ theme: {} })?.scrollbackLines).toBeUndefined(); + }); + + test('leaves unset and invalid colours undefined, so #000000 is black', () => { + const config = wasmConfig({ + theme: { foreground: '#d4d4d4', background: '#000000', red: 'nope', green: '#0dbc79' }, + }); + expect(config).toMatchObject({ fgColor: 0xd4d4d4, bgColor: 0, cursorColor: undefined }); + expect(config?.palette?.slice(0, 3)).toEqual([undefined, undefined, 0x0dbc79]); }); }); diff --git a/src/config.ts b/src/config.ts index ef315e2..b1558f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,9 +75,9 @@ export interface Config { term: string; /** `$COLORTERM` env var passed to the shell. */ colorTerm: string; - /** PTY history buffer in bytes; used for server-side replay on reload/reconnect. */ + /** PTY history buffer in bytes; used for server-side replay on reload/reconnect. The client does not read it. */ scrollback: number; - /** Terminal history in lines on the official engine; `0` = no line limit. History is also capped at 32 MiB. See ADR 046. */ + /** Terminal history in lines; `0` = no line limit. History is also capped at 32 MiB. See ADR 046. */ scrollbackLines: number; /** Initial terminal width in columns. */ cols: number; From bab2e2c27d7f0ae4abe82d5a75615e51c4a45d5d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:29:31 -0400 Subject: [PATCH 040/295] refactor(client): leave size reports to Ghostty Ghostty answers XTWINOPS 14/16/18/19 t and mode 2048 through OPT_SIZE with the same device-pixel cell size, and graphics.ts already skipped its own replies on that engine (ADR 041 step 5, ADR 047). - Delete SIZE_QUERY, sizeQueries, TerminalSize and sizeReply from kitty.ts, ControlScan.queries, and graphics.ts's terminalSize(). - Delete answersSizeQueries from GhosttyTerminal. - ControlScanner, deviceCellSize and axisScale stay for clear detection and frame scaling. --- src/client/graphics.test.ts | 20 ++--------- src/client/graphics.ts | 26 +++----------- src/client/index.ts | 7 ++-- src/client/kitty.test.ts | 32 +++-------------- src/client/kitty.ts | 43 ++++------------------- src/client/vendor/ghostty-web/ghostty.ts | 4 --- src/client/vendor/ghostty-web/terminal.ts | 4 +-- 7 files changed, 24 insertions(+), 112 deletions(-) diff --git a/src/client/graphics.test.ts b/src/client/graphics.test.ts index 51d98c0..8ec42e0 100644 --- a/src/client/graphics.test.ts +++ b/src/client/graphics.test.ts @@ -125,7 +125,6 @@ interface Harness { function harness( limits: Partial = {}, cell: { width: number; height: number } = { width: 10, height: 20 }, - answersSizeQueries = false, ): Harness { const written: string[] = []; const sent: string[] = []; @@ -145,7 +144,7 @@ function harness( }, }, }, - wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0, answersSizeQueries }, + wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0 }, renderer: { getMetrics: () => cell }, }; const container = { appendChild: () => {}, querySelector: () => null }; @@ -464,15 +463,6 @@ describe('sequences split across chunks', () => { expect(h.placements().size).toBe(0); }); - test('a size query split over two feeds is answered', () => { - const h = harness(); - h.g.feed(`${ESC}[1`); - expect(h.sent).toEqual([]); - h.g.feed('4t'); - // 80x24 cells of 10x20 px - expect(h.sent).toEqual([`${ESC}[4;480;800t`]); - }); - test('the text is still written to the terminal in the same pieces', () => { const h = harness(); h.g.feed(`a${ESC}[`); @@ -482,14 +472,8 @@ describe('sequences split across chunks', () => { }); describe('size queries', () => { - test('are answered from the cell size on the Coder engine', () => { + test('are left to Ghostty, which answers them (ADR 042, ADR 047)', () => { const h = harness(); - h.g.feed(`${ESC}[16t${ESC}[18t`); - expect(h.sent).toEqual([`${ESC}[6;20;10t`, `${ESC}[8;24;80t`]); - }); - - test('are left to an engine that answers them itself (ADR 042)', () => { - const h = harness({}, undefined, true); h.g.feed(`${ESC}[14t${ESC}[16t${ESC}[18t`); expect(h.sent).toEqual([]); expect(h.written.join('')).toBe(`${ESC}[14t${ESC}[16t${ESC}[18t`); diff --git a/src/client/graphics.ts b/src/client/graphics.ts index f208077..35a09d4 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -4,7 +4,6 @@ import { ChunkAssembler, ControlScanner, cellSpan, - deviceCellSize, type KittyKeys, kittyReply, MAX_TRANSMISSION_BYTES, @@ -12,13 +11,12 @@ import { parseKittyApc, pngSize, pngSizeFromBytes, - sizeReply, type Transmission, } from './kitty'; import type { Terminal } from './vendor/ghostty-web'; -// Sizes reported to programs (CSI 14t/16t) are device pixels, as in kitty and -// Ghostty: programs such as terminal-browser divide them by the display's +// Sizes Ghostty reports to programs (CSI 14t/16t) are device pixels, as in kitty: +// programs such as terminal-browser divide them by the display's // scale factor to get CSS pixels, so reporting CSS pixels makes their content // render at the wrong size. On a 2x display this means 4x the pixels per // full-window frame. Cell sizes are whole device pixels (see kitty.ts), so the @@ -249,12 +247,9 @@ export class KittyGraphics { private writeText(text: string): void { this.term.write(text); - const { clears, queries } = this.scanner.scan(text); - if (clears) this.clear(); - // The official engine answers size queries itself (OPT_SIZE), so apps get one - // reply per query. See ADR 042. - if (this.term.wasmTerm?.answersSizeQueries) return; - for (const q of queries) this.send(sizeReply(q, this.terminalSize())); + // Size queries (CSI 14/16/18 t) are answered by Ghostty (OPT_SIZE). See ADR 042 + // and ADR 047. + if (this.scanner.scan(text).clears) this.clear(); } private handleApc(body: string, overflow = false): void { @@ -563,17 +558,6 @@ export class KittyGraphics { return { width: m?.width ?? 8, height: m?.height ?? 16 }; } - private terminalSize() { - const cell = this.cell(); - const dpr = window.devicePixelRatio || 1; - return { - cols: this.term.cols, - rows: this.term.rows, - cellWidth: deviceCellSize(cell.width, dpr), - cellHeight: deviceCellSize(cell.height, dpr), - }; - } - /** The alternate screen has no scrollback, so rows there are viewport rows. */ private scrollbackLength(): number { const wasm = this.term.wasmTerm; diff --git a/src/client/index.ts b/src/client/index.ts index c425a24..81873e2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -121,7 +121,7 @@ function fit(): void { } // Every font zoom, font load and DPR change reaches fit(). A grid change has // already sent the new cell pixels with resize; this sends them when only the - // pixels changed, for the official engine's size reports (ADR 042). + // pixels changed, for Ghostty's size reports (ADR 042). term.syncCellPixelSize(); const canvas = container.querySelector('canvas:not([data-edge])') as HTMLElement | null; if (!canvas) return; @@ -191,8 +191,9 @@ watchDevicePixelRatio(window, scheduleFit); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let ws: WebSocket; -// Kitty graphics: ghostty-web's WASM discards the protocol's APC sequences, so -// PTY output goes through this instead of term.write(). See ADR 032. +// Kitty graphics: the wasm32 libghostty-vt build leaves kitty graphics out and +// discards the protocol's APC sequences, so PTY output goes through this instead of +// term.write(). See ADR 032 and ADR 041. const graphics = new KittyGraphics(term, container, (data: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }); diff --git a/src/client/kitty.test.ts b/src/client/kitty.test.ts index ee27b1b..269bc3d 100644 --- a/src/client/kitty.test.ts +++ b/src/client/kitty.test.ts @@ -11,8 +11,6 @@ import { parseKittyApc, pngSize, pngSizeFromBytes, - sizeQueries, - sizeReply, } from './kitty'; const ESC = '\x1b'; @@ -187,19 +185,6 @@ describe('placement clearing', () => { }); }); -describe('size queries', () => { - test('finds queries in order', () => { - expect(sizeQueries('\x1b[14tabc\x1b[16t\x1b[18t\x1b[15t')).toEqual([14, 16, 18]); - }); - - test('replies', () => { - const size = { cols: 100, rows: 30, cellWidth: 8, cellHeight: 16 }; - expect(sizeReply(14, size)).toBe('\x1b[4;480;800t'); - expect(sizeReply(16, size)).toBe('\x1b[6;16;8t'); - expect(sizeReply(18, size)).toBe('\x1b[8;30;100t'); - }); -}); - describe('cellSpan', () => { const img = { width: 100, height: 50 }; @@ -307,14 +292,8 @@ describe('ChunkAssembler size cap', () => { describe('ControlScanner', () => { test('a clear split across chunks is seen once it completes', () => { const s = new ControlScanner(); - expect(s.scan('text\x1b[')).toEqual({ clears: false, queries: [] }); - expect(s.scan('2Jmore')).toEqual({ clears: true, queries: [] }); - }); - - test('a size query split across chunks gets its reply', () => { - const s = new ControlScanner(); - expect(s.scan('\x1b[1').queries).toEqual([]); - expect(s.scan('4t').queries).toEqual([14]); + expect(s.scan('text\x1b[')).toEqual({ clears: false }); + expect(s.scan('2Jmore')).toEqual({ clears: true }); }); test('split at every position', () => { @@ -324,7 +303,6 @@ describe('ControlScanner', () => { const a = s.scan(full.slice(0, cut)); const b = s.scan(full.slice(cut)); expect(a.clears || b.clears).toBe(true); - expect([...a.queries, ...b.queries]).toEqual([16]); } }); @@ -336,15 +314,15 @@ describe('ControlScanner', () => { test('a completed sequence is not counted again with the next chunk', () => { const s = new ControlScanner(); - expect(s.scan('\x1b[2J\x1b[14t').queries).toEqual([14]); - expect(s.scan('plain')).toEqual({ clears: false, queries: [] }); + expect(s.scan('\x1b[2J').clears).toBe(true); + expect(s.scan('plain')).toEqual({ clears: false }); }); test('an unrelated unfinished sequence is dropped once it turns into something else', () => { const s = new ControlScanner(); s.scan('\x1b[3'); expect(s.scan('1m text').clears).toBe(false); - expect(s.scan('plain').queries).toEqual([]); + expect(s.scan('plain').clears).toBe(false); }); }); diff --git a/src/client/kitty.ts b/src/client/kitty.ts index aaaf94d..67bc2db 100644 --- a/src/client/kitty.ts +++ b/src/client/kitty.ts @@ -301,22 +301,11 @@ export function clearsPlacements(text: string): boolean { return CLEARS_PLACEMENTS.test(text); } -const SIZE_QUERY = new RegExp(`${ESC}\\[(14|16|18)t`, 'g'); - -/** Window-op size queries (`CSI 14 t`, `16 t`, `18 t`) present in `text`, in order. */ -export function sizeQueries(text: string): number[] { - const found: number[] = []; - SIZE_QUERY.lastIndex = 0; - for (let m = SIZE_QUERY.exec(text); m !== null; m = SIZE_QUERY.exec(text)) { - found.push(Number(m[1])); - } - return found; -} - /** - * A cell's size in whole device pixels. Reported to programs (CSI 16t) and used - * by them to size frames as cols x this, so it has to be an integer even though - * the CSS cell size (and devicePixelRatio, e.g. 1.25 or 1.5 on Windows) is not. + * A cell's size in whole device pixels. Ghostty reports it to programs (CSI 16t, + * from terminal.ts `deviceCellPixels`, which computes the same) and they size + * frames as cols x this, so it has to be an integer even though the CSS cell size + * (and devicePixelRatio, e.g. 1.25 or 1.5 on Windows) is not. */ export function deviceCellSize(cssCell: number, dpr: number): number { return Math.max(1, Math.round(cssCell * dpr)); @@ -338,8 +327,6 @@ export function axisScale(cssCell: number, dpr: number): number { export interface ControlScan { /** A sequence that invalidates placed images was completed. */ clears: boolean; - /** Window-op size queries completed, in order. */ - queries: number[]; } // A sequence cut off at the end of a chunk: ESC, or ESC [ followed by parameter @@ -347,8 +334,7 @@ export interface ControlScan { const INCOMPLETE = new RegExp(`${ESC}(?:\\[[0-9;?]{0,16})?$`); /** - * Finds clear sequences and size queries in text that arrives in arbitrary - * pieces. PTY output and WebSocket frames can end anywhere, so `ESC [` in one + * Finds clear sequences in text that arrives in arbitrary pieces. PTY output and WebSocket frames can end anywhere, so `ESC [` in one * piece and `2 J` in the next are one sequence; the terminal reassembles them * in its own parser, and this keeps the unfinished tail between calls so the * client sees them too. @@ -362,27 +348,10 @@ export class ControlScanner { // Anything before the tail is complete, so it is scanned now; the tail // itself cannot match yet and is scanned again once it is finished. this.carry = tail ? tail[0] : ''; - return { clears: clearsPlacements(s), queries: sizeQueries(s) }; + return { clears: clearsPlacements(s) }; } } -export interface TerminalSize { - cols: number; - rows: number; - /** Cell size in pixels. */ - cellWidth: number; - cellHeight: number; -} - -/** Reply to a `CSI t` window-op size query. */ -export function sizeReply(query: number, size: TerminalSize): string { - const w = Math.round(size.cols * size.cellWidth); - const h = Math.round(size.rows * size.cellHeight); - if (query === 14) return `${ESC}[4;${h};${w}t`; - if (query === 16) return `${ESC}[6;${Math.round(size.cellHeight)};${Math.round(size.cellWidth)}t`; - return `${ESC}[8;${size.rows};${size.cols}t`; -} - export interface CellSpan { cols: number; rows: number; diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 5e554a0..59b4840 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -56,8 +56,6 @@ export interface TerminalEffects { export interface GhosttyTerminal { readonly cols: number; readonly rows: number; - /** True when the engine answers XTWINOPS size queries and mode 2048 itself. */ - readonly answersSizeQueries: boolean; // Lifecycle write(data: string | Uint8Array): void; @@ -844,8 +842,6 @@ function isKittyKeyboardReply(reply: Uint8Array): boolean { * offset and serves rows from the copy and the cache. */ export class OfficialTerminal implements GhosttyTerminal { - readonly answersSizeQueries = true; - private readonly vt: Ghostty; private readonly ex: OfficialWasmExports; private readonly L: OfficialLayout; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 9cc9343..283c47c 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -106,8 +106,8 @@ export function decscusrShape(ps: number): CursorShape | undefined { /** * A cell's size in whole device pixels, for Ghostty's size reports * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as kitty.ts's `deviceCellSize` - * does for its own replies: the CSS cell times `devicePixelRatio`, rounded, at - * least 1. (webtty, ADR 042) + * sizes image frames: the CSS cell times `devicePixelRatio`, rounded, at least 1. + * (webtty, ADR 042) */ export function deviceCellPixels(cssCell: number, dpr: number): number { return Math.max(1, Math.round(cssCell * dpr)); From e0b1ba93dc4a7900f67514e5bfe3a33479289fa9 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:31:28 -0400 Subject: [PATCH 041/295] docs(client): describe the official engine as the only engine Bring the client and kitty-graphics SPECs, ADR 013 and 032, both PROVENANCE files and the pin-update hint in line with ADR 047: no Coder wasm, /ghostty-vt.wasm or ?engine, size queries answered by Ghostty, and the parity tests renamed official-replay.test.ts. Marks the "Official engine only" feature done. --- docs/adrs/013.client.cursor-style.md | 2 +- .../adrs/032.client.kitty-graphics-overlay.md | 5 ++- docs/adrs/047.client.official-engine-only.md | 4 +-- docs/specs/client.md | 31 +++++++------------ docs/specs/kitty-graphics.md | 31 ++++++++++++------- scripts/update-ghostty-vt.ts | 4 +-- src/client/vendor/ghostty-vt/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 14 ++++----- 8 files changed, 46 insertions(+), 47 deletions(-) diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 315d651..3203d6b 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -34,7 +34,7 @@ Intercept DECSCUSR sequences in `src/client/cursor.ts` before passing data to `t The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and terminal wiring in `index.ts`. It is removed when ghostty-web implements DECSCUSR natively. -**Update (ADR 041):** DECSCUSR is now applied inside the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `write()` scans the output (`scanDecscusr`, which also carries a sequence split across writes) and keeps the app's shape apart from `options.cursorStyle` / `options.cursorBlink`, which stay the configured default. Ps 0 now clears the app's shape, so the configured cursor shows again (it was blinking block). `cursor.ts` and its call in `index.ts` are deleted. The Coder wasm does not export the cursor style, so the output is still scanned; reading it from the render state is ADR 041 step 5. +**Update (ADR 041):** DECSCUSR is now applied inside the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `write()` scans the output (`scanDecscusr`, which also carries a sequence split across writes) and keeps the app's shape apart from `options.cursorStyle` / `options.cursorBlink`, which stay the configured default. Ps 0 now clears the app's shape, so the configured cursor shows again (it was blinking block). `cursor.ts` and its call in `index.ts` are deleted. The Coder wasm does not export the cursor style, so the output is still scanned; reading it from the render state is ADR 041 step 5. ([ADR 047](047.client.official-engine-only.md) removed the Coder wasm but kept the scan: the official render state does expose the cursor style, and reading it from there is a follow-up with its own update to this ADR, since Ghostty's blink also follows mode 12.) ## Considered Options diff --git a/docs/adrs/032.client.kitty-graphics-overlay.md b/docs/adrs/032.client.kitty-graphics-overlay.md index b93e163..be7bb83 100644 --- a/docs/adrs/032.client.kitty-graphics-overlay.md +++ b/docs/adrs/032.client.kitty-graphics-overlay.md @@ -52,7 +52,10 @@ Implement the protocol in the webtty client, in front of ghostty-web: (`ESC _ G i=;OK ESC \`) are sent back over the WebSocket as if typed. Programs that size their output in pixels ask with `CSI 14 t` / `16 t` / `18 t`; the client answers these itself from the renderer metrics, because - the WASM cannot know pixel size. + the WASM cannot know pixel size. (Changed by [ADR 042](042.client.official-vt-engine.md) + and [ADR 047](047.client.official-engine-only.md): the official wasm takes + the cell size through `OPT_SIZE` and answers them, and the client's replies + are deleted.) 7. **Pixel scale is `devicePixelRatio`.** Sizes reported to programs are device pixels, as kitty and Ghostty report them, and images are drawn at `size / scale` CSS pixels. Programs rely on this: terminal-browser divides diff --git a/docs/adrs/047.client.official-engine-only.md b/docs/adrs/047.client.official-engine-only.md index c4b6b83..9d6d6e1 100644 --- a/docs/adrs/047.client.official-engine-only.md +++ b/docs/adrs/047.client.official-engine-only.md @@ -33,13 +33,13 @@ Make the official libghostty-vt wasm the only engine now, and delete the Coder e - **Coder-only types** in the vendored `types.ts`: `GhosttyWasmExports`, `GHOSTTY_CONFIG_SIZE`, `CURSOR_STRUCT_SIZE`, `COLORS_STRUCT_SIZE` and `TerminalHandle`. Upstream leftovers that were unused before this change (`SgrAttribute*`, `RGBColor`, `TerminalConfig`, `TerminalMode`) stay. They are not engine code, and removing them would only churn the vendored file. - **Two-engine interface members.** `answersSizeQueries` goes. `setViewportOffset` becomes required on `GhosttyTerminal`. `readEffects`, `setCellPixelSize` and the pixel arguments of `resize` stay, because the official engine uses them; their "no-op on Coder" comments go. Comments that say rows are "in the Coder engine's coordinates" now say active-screen rows, with `getScrollbackLine(i)` counted from the oldest row. - **Stream-scanned bell and title.** `writeInternal` no longer scans output for BEL and OSC 0/2, and `checkForTitleChange` goes. Bell and title always come from `readEffects` (Ghostty's `BELL` and `TITLE_CHANGED`). -- **Scrollback derived from `scrollback`.** The client stops passing `Math.ceil(config.scrollback / 80)`, and `GhosttyTerminalConfig.scrollbackLimit` and the derived byte limit go. The binding always sets `SCROLLBACK_MAX_LINES` from `scrollbackLines` (default 10,000; NULL for 0) and `SCROLLBACK_MAX_BYTES` to 32 MiB. A `Terminal` built without config now keeps 10,000 lines instead of the wasm's 10,000-byte default. `/api/config` still serves `scrollback`, the server's replay buffer (ADR 046), and the client no longer reads it. +- **Scrollback derived from `scrollback`.** The client stops passing `Math.ceil(config.scrollback / 80)`, and the terminal's `scrollback` option, `GhosttyTerminalConfig.scrollbackLimit` and the derived byte limit go. The binding always sets `SCROLLBACK_MAX_LINES` from `scrollbackLines` (default 10,000; NULL for 0) and `SCROLLBACK_MAX_BYTES` to 32 MiB. A `Terminal` built without config now keeps 10,000 lines instead of the wasm's 10,000-byte default. `/api/config` still serves `scrollback`, the server's replay buffer (ADR 046), and the client no longer reads it. - **`colorsSet` and 0 as "unset".** The mask was needed because the Coder config struct encoded an unset colour as 0, which is also `#000000`. Theme colours become `number | undefined`, and undefined means unset. The 0xcccccc / 0x000000 fallback for a missing foreground or background stays, because Ghostty applies the default colours only when both are set (ADR 042). It is now described as webtty's default. - **`kitty.ts` size replies** (ADR 041 step 5, "size reports answered by Ghostty through `OPT_SIZE`"). On the official engine they were already suppressed (`answersSizeQueries`), and Ghostty answers XTWINOPS 14/16/18/19t and mode 2048 from `OPT_SIZE` with the same device-pixel cell size. `SIZE_QUERY`, `sizeQueries`, `TerminalSize`, `sizeReply`, `ControlScan.queries` and `graphics.ts`'s `terminalSize()` go. `ControlScanner` (clear detection), `deviceCellSize` and `axisScale` (frame scaling) stay. **Tests rewritten, not dropped.** -- `engine-parity.test.ts` used the Coder engine as the reference for the official engine's scrolled view, page cache and generation counter. Those tests now compare against a fresh official terminal that was fed the same bytes and is read at offset 0, so stale pages and lost dirty rows still fail them. The #180 stress payload test stays. The PTY captures in `fixtures/` stay as a chunking check: each capture written in 512-byte and in 1-byte writes must give the same screen. +- `engine-parity.test.ts` (renamed `official-replay.test.ts`) used the Coder engine as the reference for the official engine's scrolled view, page cache and generation counter. Those tests now compare against a fresh official terminal that was fed the same bytes and is read at offset 0, so stale pages and lost dirty rows still fail them. The #180 stress payload test stays. The PTY captures in `fixtures/` stay as a chunking check: each capture written in 512-byte and in 1-byte writes must give the same screen. - `renderer.test.ts` runs its engine-parameterised tests on the official wasm only, and its single Coder-wasm test moves to the official wasm. - `official-terminal.test.ts` loads through the new `Ghostty.load(path)`. The `scrollbackLimit` cases move to `scrollbackLines`, and the test that contrasts the old byte limit goes. - A `terminal.test.ts` case checks that a title and a bell reported by `readEffects` reach `onTitleChange` and `onBell`. diff --git a/docs/specs/client.md b/docs/specs/client.md index dfa03c7..1cb9fcc 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -21,34 +21,26 @@ dist/ client.html ← static HTML shell; served at GET /s/:id client-browser.js ← compiled from src/client/index.ts, with the vendored ghostty-web TS bundled in client.css ← copied from src/client/index.css - ghostty-vt.wasm ← copied from src/client/vendor/ghostty-web/; served at GET /ghostty-vt.wasm ghostty-vt-small.wasm ← official libghostty-vt (ReleaseSmall), copied from src/client/vendor/ghostty-vt/ only if it matches its sidecar's size and sha256; served at GET /ghostty-vt-small.wasm - THIRD_PARTY_LICENSES ← Coder's ghostty-web and Ghostty's MIT notices, from src/client/vendor/ghostty-web/ - and src/client/vendor/ghostty-vt/ + THIRD_PARTY_LICENSES ← MIT notices for Coder's ghostty-web TS, Ghostty's libghostty-vt and ghostty-vt-js, + from src/client/vendor/ghostty-web/ and src/client/vendor/ghostty-vt/ ``` -`client.html` loads only `client-browser.js`. The npm `ghostty-web.js` build inlined the wasm as a base64 `data:` URL, so the browser never fetched `/ghostty-vt.wasm`. The vendored TS is bundled without inlining, so the client now fetches it on every page load through an explicit `Ghostty.load('/ghostty-vt.wasm')`, and the page depends on the server serving that route as `application/wasm`. The server resolves the wasm from `dist/` (npm install) or the vendored path (local dev); nothing comes from `node_modules` ([ADR 041](../adrs/041.client.libghostty-vt.md)). - -The server resolves `ghostty-vt-small.wasm`, the official libghostty-vt wasm ([ADR 042](../adrs/042.client.official-vt-engine.md)), the same way and serves it at `GET /ghostty-vt-small.wasm` as `application/wasm`. - -The client fetches only the selected engine's wasm ([ADR 042](../adrs/042.client.official-vt-engine.md)): `/ghostty-vt.wasm` for the Coder engine, `/ghostty-vt-small.wasm` for the official one. The engine is the build-time default (`DEFAULT_ENGINE` in `engine.ts`, `coder` until the acceptance matrix is recorded) or `?engine=coder|official` in the page URL; any other value uses the default, and nothing is stored. The Coder wasm goes away when the Coder engine is removed. +`client.html` loads only `client-browser.js`. The npm `ghostty-web.js` build inlined the wasm as a base64 `data:` URL. The vendored TS is bundled without inlining, so the client fetches the wasm on every page load through an explicit `Ghostty.load('/ghostty-vt-small.wasm')`, and the page depends on the server serving that route as `application/wasm`. It is Ghostty's official libghostty-vt wasm, the only engine ([ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md)). The server resolves it from `dist/` (npm install) or the vendored path (local dev); nothing comes from `node_modules` ([ADR 041](../adrs/041.client.libghostty-vt.md)). `GET /ghostty-vt.wasm`, the Coder engine's wasm until ADR 047, returns 404, and `?engine=` in a page URL is ignored. ## Source layout ``` src/client/ index.ts ← browser TypeScript entry point - engine.ts ← DEFAULT_ENGINE and the ?engine=coder|official override (ADR 042) index.html ← HTML shell (no inline script) index.css ← terminal styles vendor/ ghostty-web/ ← coder/ghostty-web lib/ at 1858a59 (non-test TS, unchanged), MIT LICENSE, PROVENANCE.md; - Ghostty's LICENSE.ghostty; Coder ghostty-vt.wasm + ghostty-vt.wasm.json sidecar (npm version, Ghostty commit + patch, - licence, size, sha256), checked by vendor/ghostty-web.test.ts; - ghostty.ts holds every wasm call for both engines (Coder and official, ADR 042); + ghostty.ts holds every wasm call, the binding to the official wasm (ADR 042, ADR 047); fn-table.ts is the function-table trampoline borrowed from ghostty-vt-js (LICENSE.ghostty-vt-js) - fixtures/ ← PTY captures replayed into both engines by vendor/engine-parity.test.ts + fixtures/ ← PTY captures replayed in 1-byte and 512-byte writes by vendor/official-replay.test.ts ghostty-vt/ ← official ghostty-vt-small.wasm (Ghostty 05dcf887, ReleaseSmall) + .minisig + sidecar (commit, URL, size, sha256, signature, key, licence), Ghostty LICENSE, PROVENANCE.md; checked offline by vendor/ghostty-vt.test.ts (size, sha256, minisign signature) @@ -57,8 +49,6 @@ scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networ The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. `vendor/ghostty-vt/` is excluded from Biome too, since the update script writes its sidecar; nothing in it is edited. -The Coder wasm and sidecar stay until the Coder engine is removed ([ADR 041](../adrs/041.client.libghostty-vt.md) step 5). - ## Page `GET /s/:id` returns `dist/client.html` — a static HTML shell. On load, `client-browser.js`: @@ -66,7 +56,7 @@ The Coder wasm and sidecar stay until the Coder engine is removed ([ADR 041](../ 1. Reads `sessionId` from `window.location.pathname` (`/s/main` → `main`) 2. Fetches `GET /api/config` to get terminal config 3. Sets `document.title = sessionId + ' | webtty'` -4. Picks the engine (`?engine=coder|official`, else the build-time default), loads its wasm and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) +4. Loads the official libghostty-vt wasm and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollbackLines, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) 5. Connects to `ws:///ws/:id?cols=&rows=` over WebSocket 6. Fits the terminal to the viewport from the full container size (no scrollbar reserve, see ADR 033) and observes resize events 7. Sends a `{ type: 'resize', cols, rows }` JSON message on open and on every terminal resize @@ -78,8 +68,9 @@ The Coder wasm and sidecar stay until the Coder engine is removed ([ADR 041](../ ```ts { - cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollback, - scrollbackLines, // Official engine: history in lines, 0 = no line limit, 32 MiB ceiling (ADR 046) + cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, + scrollback, // the server's replay buffer; the client does not read it (ADR 047) + scrollbackLines, // history in lines, 0 = no line limit, 32 MiB ceiling (ADR 046) theme, copyOnSelect, rightClickBehavior, mouseScrollSpeed, // Terminal option: SGR wheel ticks per line/page event when the app tracks the mouse padding, // px around the canvas, used by fit(); coloured by theme.padding or theme.background @@ -196,10 +187,10 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default (superseded by the row below) | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | -| Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders and theme colours from render state remain follow-ups | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ⬜ | +| Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders and theme colours from render state remain follow-ups | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | -| Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider on both engines), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | +| Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index 6b811e8..95556e1 100644 --- a/docs/specs/kitty-graphics.md +++ b/docs/specs/kitty-graphics.md @@ -1,6 +1,6 @@ # SPEC: Kitty Graphics -**Last Updated:** 2026-09-20 +**Last Updated:** 2026-10-13 --- @@ -8,9 +8,10 @@ Display images from PTY programs using the [kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), -which ghostty-web's WASM core does not implement. The client intercepts the -protocol's APC sequences before they reach ghostty-web, decodes the images -itself, and draws them on an overlay canvas above the terminal canvas. +which the wasm32 libghostty-vt build leaves out ([ADR 041](../adrs/041.client.libghostty-vt.md)). +The client intercepts the protocol's APC sequences before they reach the +terminal, decodes the images itself, and draws them on an overlay canvas above +the terminal canvas. Primary target for the POC: full-window streaming apps such as [terminal-browser](https://github.com/zenbu-labs/terminal-browser) (standalone @@ -32,9 +33,12 @@ PTY ──► server ──► WebSocket ──► ws.onmessage └─ cursor ───┘ ▼ advance / reply overlay canvas (src/client/graphics.ts) -replies (a=q OK, CSI 14t/16t/18t) ──► ws.send ──► PTY +replies (a=q OK) ──► ws.send ──► PTY ``` +Size queries (`CSI 14t/16t/18t`) are text: they reach the terminal, and Ghostty +answers them (see Replies). + Text before an APC is written first, so the cursor position read when the command is handled is exact. @@ -66,11 +70,14 @@ Written back to the PTY over the WebSocket: - `ESC _ G i=[,p=] ; OK ESC \` — success, when the command carried an image id (`i` or `I`) and `q` did not suppress it. - `ESC _ G i= ; E: ESC \` — failure. -- `CSI 4 ; ; t` (answer to `CSI 14 t`) — text-area size in pixels. -- `CSI 6 ; ; t` (answer to `CSI 16 t`) — cell size in pixels. -- `CSI 8 ; ; t` (answer to `CSI 18 t`) — text-area size in cells. -Pixel sizes are device pixels, matching what kitty and Ghostty report. The cell size is `round(cssCell × devicePixelRatio)` and the text-area size is `cols × cell`, so the numbers a program derives always agree with each other, including at fractional ratios. +Window size queries are answered by Ghostty, not by the client: `CSI 14 t`, +`16 t`, `18 t` and `19 t` and mode 2048, from the cell size `terminal.ts` passes +it (`OPT_SIZE`, [ADR 042](../adrs/042.client.official-vt-engine.md)). Until +[ADR 047](../adrs/047.client.official-engine-only.md) the client answered +`14/16/18 t` itself on the Coder engine. + +Pixel sizes are device pixels, matching what kitty and Ghostty report. The cell size is `round(cssCell × devicePixelRatio)` (`deviceCellPixels`, the same as `kitty.ts`'s `deviceCellSize` that frames are scaled by) and the text-area size is `cols × cell`, so the numbers a program derives always agree with each other, including at fractional ratios. ## Limits @@ -118,7 +125,7 @@ reuses the same image id. ``` src/client/ - kitty.ts ← pure: APC splitter, key=value parser, reply builders (unit tested) + kitty.ts ← pure: APC splitter, key=value parser, kitty reply builder, clear scanner (unit tested) kitty.test.ts graphics.ts ← browser: decode, placement table, overlay canvas, scroll tracking graphics.test.ts ← state handling against a stubbed DOM and image decoder @@ -156,10 +163,10 @@ src/client/ | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| APC splitter | Stateful across chunks; strips APC from text handed to ghostty-web | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | +| APC splitter | Stateful across chunks; strips APC from text handed to the terminal | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | | Direct-transmission decode | `t=d`, `f=24/32/100`, `o=z`, chunked | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | | Overlay canvas + placements | Anchored to scrollback rows, cleared on screen clear / alt switch | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | -| Query and size replies | `a=q`, `CSI 14t` / `16t` / `18t` | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | +| Query and size replies | `a=q` from the client; `CSI 14t` / `16t` / `18t` from Ghostty since ADR 047 | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | | terminal-browser (standalone) works | Verified end to end in a browser tab on macOS (HiDPI) | [ADR 032](../adrs/032.client.kitty-graphics-overlay.md) | ✅ | | Scrollback APC stripping (server) | Keep image payloads out of `session.scrollback` | — | ⬜ | | Unicode placeholders | `U=1`, needed for tmux and terminal-browser embedded mode | — | ⬜ | diff --git a/scripts/update-ghostty-vt.ts b/scripts/update-ghostty-vt.ts index 301aec2..3f6eb57 100644 --- a/scripts/update-ghostty-vt.ts +++ b/scripts/update-ghostty-vt.ts @@ -329,9 +329,7 @@ async function update(commit: string): Promise { `${JSON.stringify(sidecar, null, 2)}\n`, ); console.log(`wrote ${WASM_FILE}{,.minisig,.json} and LICENSE for ${commit} to ${VENDOR_DIR}`); - console.log( - 'Update PROVENANCE.md if needed, then re-run parity and the binding tests (ADR 041).', - ); + console.log('Update PROVENANCE.md if needed, then re-run the binding tests (ADR 041).'); } async function verify(): Promise { diff --git a/src/client/vendor/ghostty-vt/PROVENANCE.md b/src/client/vendor/ghostty-vt/PROVENANCE.md index d699557..526f375 100644 --- a/src/client/vendor/ghostty-vt/PROVENANCE.md +++ b/src/client/vendor/ghostty-vt/PROVENANCE.md @@ -8,4 +8,4 @@ Ghostty's official libghostty-vt wasm, ReleaseSmall build, unpatched and not edi Checked offline by `src/client/vendor/ghostty-vt.test.ts` (size, sha256, signature, key) on every `bun test`, and by `bun scripts/update-ghostty-vt.ts --verify`. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar, and writes the licence to `dist/THIRD_PARTY_LICENSES`. -The wasm, signature, sidecar and `LICENSE` are written by `bun scripts/update-ghostty-vt.ts --commit `, the only step that uses the network, and are not edited by hand. A pin bump is a deliberate change: update the commit above and re-run parity and the binding's tests (ADR 041). +The wasm, signature, sidecar and `LICENSE` are written by `bun scripts/update-ghostty-vt.ts --commit `, the only step that uses the network, and are not edited by hand. A pin bump is a deliberate change: update the commit above and re-run the binding's tests (`official-terminal.test.ts`, `official-replay.test.ts`, ADR 041). diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 3a6bf9e..df5f2bf 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -4,9 +4,9 @@ Vendored copy of [coder/ghostty-web](https://github.com/coder/ghostty-web), MIT - **Upstream commit:** `1858a5947767a3e1c9e98dbf53b2ff87fedb2aab` (2026-06-28), the `gitHead` of npm `ghostty-web@0.4.0-next.20.g1858a59`. - **TypeScript:** every non-test file under upstream `lib/` (including `addons/` and `providers/`), copied byte for byte. Upstream's tests (`*.test.ts`, `test-helpers.ts`) and its happy-dom preload were not copied. -- **`ghostty-vt.wasm`:** copied byte for byte from the npm package. Built by Coder from Ghostty `5714ed07a1012573261b7b7e3ed2add9c1504496` plus upstream `patches/ghostty-wasm-api.patch`, so it is Ghostty code under Ghostty's MIT licence, Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors ([LICENSE.ghostty](LICENSE.ghostty), copied from Ghostty at that commit), plus Coder's MIT-licensed patch. Size and sha256 are in [ghostty-vt.wasm.json](ghostty-vt.wasm.json) and checked by `src/client/vendor/ghostty-web.test.ts`. +- **`ghostty-vt.wasm`:** the npm package's wasm (Ghostty `5714ed07` plus Coder's `patches/ghostty-wasm-api.patch`) was vendored here with its sidecar and Ghostty's licence until [ADR 047](../../../../docs/adrs/047.client.official-engine-only.md) removed it with the Coder engine. The terminal runs Ghostty's official wasm from [`../ghostty-vt/`](../ghostty-vt/PROVENANCE.md). -`bun run build` writes both notices to `dist/THIRD_PARTY_LICENSES`. +`bun run build` writes the notice to `dist/THIRD_PARTY_LICENSES`. webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. @@ -20,11 +20,11 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. -- `ghostty.ts`, `types.ts`, `index.ts`: a second engine on Ghostty's official libghostty-vt wasm (ADR 042). `GhosttyTerminal` and `KeyEncoder` are interfaces implemented by the Coder engine (`CoderTerminal`, the former `GhosttyTerminal` class) and the official engine (`OfficialTerminal`, `OfficialGhostty`); `Ghostty.load(path, engine)` returns either. The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` (official only), `readEffects` and `answersSizeQueries`, which are no-ops or empty on the Coder engine. `GhosttyTerminalConfig` gains `colorsSet`, which the Coder engine ignores. The official engine drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. -- `terminal.ts`, `renderer.ts`: the client runs either engine (ADR 042). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; on the official engine bell and title come from `readEffects` instead of the stream scan; `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours, and `buildWasmConfig` fills `colorsSet` from it; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. -- `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder engine). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. -- `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The official engine reports its real dirty state while scrolled. +- `ghostty.ts`, `types.ts`, `index.ts`: the terminal runs on Ghostty's official libghostty-vt wasm (ADR 042, ADR 047). Upstream's `GhosttyTerminal` class, which ran the Coder wasm, is replaced: `GhosttyTerminal` and `KeyEncoder` are interfaces, implemented by `OfficialTerminal` and `OfficialKeyEncoder`, and `Ghostty.load(path)` loads the official wasm (by default from `../ghostty-vt/ghostty-vt-small.wasm`). The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` and `readEffects`. `GhosttyTerminalConfig` drops `scrollbackLimit`, and its colours are undefined when unset instead of 0, so `#000000` is black. The binding drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. Upstream's Coder-only types (`GhosttyWasmExports`, the struct sizes, `TerminalHandle`) are removed. +- `terminal.ts`, `renderer.ts`: the terminal side of the official binding (ADR 042, ADR 047). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; bell and title come from `readEffects` instead of upstream's stream scan (`checkForTitleChange` is removed); `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours; the `scrollback` option is removed; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. +- `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder wasm). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. +- `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), a hyperlink hover change repaints the rows out of view too, and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. -- `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the official engine then sets `SCROLLBACK_MAX_LINES` (NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB instead of the derived byte limit. The Coder engine ignores it. +- `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. From b79cf701b1deefa1aefd0e5edfa5670f93f1550e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:45:46 -0400 Subject: [PATCH 042/295] refactor(server): resolve and serve the one wasm without a file map GHOSTTY_WASM_FILES, findGhosttyWasms and the URL-to-path map in handleRequest existed to serve two engines' wasm files. With the official wasm the only one (ADR 047), findGhosttyWasm() resolves ghostty-vt-small.wasm from dist/ or the vendored path and handleRequest serves it at /ghostty-vt-small.wasm, as it served /ghostty-vt.wasm before ADR 042. --- src/server/index.ts | 6 +++--- src/server/routes.test.ts | 2 +- src/server/routes.ts | 9 ++++----- src/server/static.test.ts | 11 +---------- src/server/static.ts | 37 +++++++------------------------------ 5 files changed, 16 insertions(+), 49 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 2563e61..24c1df0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config'; import { handleRequest } from './routes'; -import { findGhosttyWasms } from './static'; +import { findGhosttyWasm } from './static'; import { closeAllSessions, createWebSocketServer, setLastSessionClosedHandler } from './websocket'; const __filename = fileURLToPath(import.meta.url); @@ -20,7 +20,7 @@ delete process.env.PORT; const HTTP_HOST_DISPLAY = config.host; const HTTP_HOST = config.host === 'localhost' ? '127.0.0.1' : config.host; -const wasmPaths = findGhosttyWasms(); +const wasmPath = findGhosttyWasm(); const projectRoot = path.resolve(__dirname, '..', '..'); const builtDistPath = path.join(projectRoot, 'dist'); const srcClientPath = path.join(projectRoot, 'src', 'client'); @@ -47,7 +47,7 @@ process.on('unhandledRejection', (reason) => { }); const httpServer = http.createServer((req, res) => { - handleRequest(req, res, wasmPaths, clientDistPath, shutdown).catch((err) => { + handleRequest(req, res, wasmPath, clientDistPath, shutdown).catch((err) => { console.error('[webtty] unhandled route error:', err); if (!res.headersSent) { res.writeHead(500); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 1ca4f93..52d7e13 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -62,7 +62,7 @@ describe('server — routes', () => { expect(createHash('sha256').update(body).digest('hex')).toBe(sidecar.sha256); }); - test('wasm routes serve only the listed files', async () => { + test('only the wasm itself is served', async () => { // /ghostty-vt.wasm was the Coder engine's wasm, removed in ADR 047. for (const p of [ '/ghostty-vt.wasm', diff --git a/src/server/routes.ts b/src/server/routes.ts index e326184..a6683ac 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -14,7 +14,7 @@ import { sessionToJson, setLastUsedId, } from './session'; -import { serveFile } from './static'; +import { GHOSTTY_WASM, serveFile } from './static'; import { broadcastToSubscribers, closeSession } from './websocket'; const MAX_BODY = 64 * 1024; @@ -72,14 +72,14 @@ export function readJson(req: http.IncomingMessage): Promise { * * @param req - The incoming HTTP request. * @param res - The HTTP response object. - * @param wasmPaths - Served wasm files: URL path (`/ghostty-vt-small.wasm`) → file path. + * @param wasmPath - Path to the wasm served at `/ghostty-vt-small.wasm`. * @param clientDistPath - Path to the client dist directory. * @param onStop - Callback invoked when `POST /api/server/stop` is received. */ export async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, - wasmPaths: Readonly>, + wasmPath: string, clientDistPath: string, onStop: () => void, ): Promise { @@ -472,8 +472,7 @@ export async function handleRequest( return; } - const wasmPath = Object.hasOwn(wasmPaths, pathname) ? wasmPaths[pathname] : undefined; - if (wasmPath) { + if (pathname === `/${GHOSTTY_WASM}`) { serveFile(wasmPath, res); return; } diff --git a/src/server/static.test.ts b/src/server/static.test.ts index 4b82f7b..4895188 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -1,7 +1,7 @@ import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { findGhosttyWasm, findGhosttyWasms, mimeType, serveFile } from './static'; +import { findGhosttyWasm, mimeType, serveFile } from './static'; describe('mimeType', () => { test('returns correct type for known extensions', () => { @@ -64,15 +64,6 @@ describe('findGhosttyWasm', () => { }); }); -describe('findGhosttyWasms', () => { - test('maps the served wasm URL to its file', () => { - const paths = findGhosttyWasms(); - expect(Object.keys(paths)).toEqual(['/ghostty-vt-small.wasm']); - expect(path.basename(paths['/ghostty-vt-small.wasm'] as string)).toBe('ghostty-vt-small.wasm'); - for (const p of Object.values(paths)) expect(fs.existsSync(p)).toBe(true); - }); -}); - describe('serveFile', () => { test('serves file with correct content type', async () => { const existsSpy = spyOn(fs, 'readFile').mockImplementation((( diff --git a/src/server/static.ts b/src/server/static.ts index fae8ce2..057f510 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -30,52 +30,29 @@ export function mimeType(filePath: string): string { return MIME_TYPES[ext] ?? 'application/octet-stream'; } -/** - * The wasm files the server serves at `GET /`, each with the directory under - * `src/client/vendor/` it is vendored in: the official libghostty-vt `ghostty-vt-small.wasm` - * (ADR 042), the only engine since ADR 047. - */ -export const GHOSTTY_WASM_FILES = { - 'ghostty-vt-small.wasm': 'ghostty-vt', -} as const; - -export type GhosttyWasmFile = keyof typeof GHOSTTY_WASM_FILES; +/** The wasm the client loads: Ghostty's official libghostty-vt (ADR 042, ADR 047). */ +export const GHOSTTY_WASM = 'ghostty-vt-small.wasm'; /** - * Locates a served wasm file, preferring the copy the build puts in `dist/` (npm install) + * Locates {@link GHOSTTY_WASM}, preferring the copy the build puts in `dist/` (npm install) * over the vendored source copy (local dev). Exits the process if neither exists. * - * @param file - The wasm file name; defaults to `ghostty-vt-small.wasm`. * @returns The absolute path to the wasm file. * @throws Exits the process with code 1 if the wasm cannot be found. */ -export function findGhosttyWasm(file: GhosttyWasmFile = 'ghostty-vt-small.wasm'): string { +export function findGhosttyWasm(): string { const projectRoot = path.join(__dirname, '..', '..'); const candidates = [ - path.join(projectRoot, 'dist', file), - path.join(projectRoot, 'src', 'client', 'vendor', GHOSTTY_WASM_FILES[file], file), + path.join(projectRoot, 'dist', GHOSTTY_WASM), + path.join(projectRoot, 'src', 'client', 'vendor', 'ghostty-vt', GHOSTTY_WASM), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } - console.error(`Error: Could not find ${file}.`); + console.error(`Error: Could not find ${GHOSTTY_WASM}.`); process.exit(1); } -/** - * Locates every file in {@link GHOSTTY_WASM_FILES}, keyed by the URL path it is served at. - * - * @returns A map from `/` to the absolute path {@link findGhosttyWasm} resolves. - * @throws Exits the process with code 1 if any wasm cannot be found. - */ -export function findGhosttyWasms(): Record { - const paths: Record = {}; - for (const file of Object.keys(GHOSTTY_WASM_FILES) as GhosttyWasmFile[]) { - paths[`/${file}`] = findGhosttyWasm(file); - } - return paths; -} - /** * Reads `filePath` from disk and writes it to `res` with the correct Content-Type header. * Responds with 404 if the file cannot be read. From 2ac2bbb31e72f9c30c9a8977a24ba36e7488a022 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:47:07 -0400 Subject: [PATCH 043/295] refactor(client): remove code left unused by the single engine - GhosttyTerminal and OfficialTerminal drop isDirty, getGrapheme and getScrollbackGrapheme. The Coder engine built its grapheme strings from the codepoint arrays; the official binding reads strings directly and nothing calls the arrays. codepointsOf goes with them. - Ghostty drops libraryVersion, which nothing reads. - ControlScanner.scan returns whether a clear completed. ControlScan was the result object for clears and size queries; only clears remain since ADR 047. PROVENANCE.md and ADR 047 record the removals. --- docs/adrs/047.client.official-engine-only.md | 2 +- src/client/graphics.ts | 2 +- src/client/kitty.test.ts | 18 ++++++------- src/client/kitty.ts | 19 +++++-------- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/ghostty.ts | 28 -------------------- src/client/vendor/official-terminal.test.ts | 2 +- 7 files changed, 20 insertions(+), 53 deletions(-) diff --git a/docs/adrs/047.client.official-engine-only.md b/docs/adrs/047.client.official-engine-only.md index 9d6d6e1..9cda3d9 100644 --- a/docs/adrs/047.client.official-engine-only.md +++ b/docs/adrs/047.client.official-engine-only.md @@ -35,7 +35,7 @@ Make the official libghostty-vt wasm the only engine now, and delete the Coder e - **Stream-scanned bell and title.** `writeInternal` no longer scans output for BEL and OSC 0/2, and `checkForTitleChange` goes. Bell and title always come from `readEffects` (Ghostty's `BELL` and `TITLE_CHANGED`). - **Scrollback derived from `scrollback`.** The client stops passing `Math.ceil(config.scrollback / 80)`, and the terminal's `scrollback` option, `GhosttyTerminalConfig.scrollbackLimit` and the derived byte limit go. The binding always sets `SCROLLBACK_MAX_LINES` from `scrollbackLines` (default 10,000; NULL for 0) and `SCROLLBACK_MAX_BYTES` to 32 MiB. A `Terminal` built without config now keeps 10,000 lines instead of the wasm's 10,000-byte default. `/api/config` still serves `scrollback`, the server's replay buffer (ADR 046), and the client no longer reads it. - **`colorsSet` and 0 as "unset".** The mask was needed because the Coder config struct encoded an unset colour as 0, which is also `#000000`. Theme colours become `number | undefined`, and undefined means unset. The 0xcccccc / 0x000000 fallback for a missing foreground or background stays, because Ghostty applies the default colours only when both are set (ADR 042). It is now described as webtty's default. -- **`kitty.ts` size replies** (ADR 041 step 5, "size reports answered by Ghostty through `OPT_SIZE`"). On the official engine they were already suppressed (`answersSizeQueries`), and Ghostty answers XTWINOPS 14/16/18/19t and mode 2048 from `OPT_SIZE` with the same device-pixel cell size. `SIZE_QUERY`, `sizeQueries`, `TerminalSize`, `sizeReply`, `ControlScan.queries` and `graphics.ts`'s `terminalSize()` go. `ControlScanner` (clear detection), `deviceCellSize` and `axisScale` (frame scaling) stay. +- **`kitty.ts` size replies** (ADR 041 step 5, "size reports answered by Ghostty through `OPT_SIZE`"). On the official engine they were already suppressed (`answersSizeQueries`), and Ghostty answers XTWINOPS 14/16/18/19t and mode 2048 from `OPT_SIZE` with the same device-pixel cell size. `SIZE_QUERY`, `sizeQueries`, `TerminalSize`, `sizeReply`, `ControlScan` and `graphics.ts`'s `terminalSize()` go. `ControlScanner` (clear detection, its `scan` now returning whether a clear completed), `deviceCellSize` and `axisScale` (frame scaling) stay. **Tests rewritten, not dropped.** diff --git a/src/client/graphics.ts b/src/client/graphics.ts index 35a09d4..3d8003c 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -249,7 +249,7 @@ export class KittyGraphics { this.term.write(text); // Size queries (CSI 14/16/18 t) are answered by Ghostty (OPT_SIZE). See ADR 042 // and ADR 047. - if (this.scanner.scan(text).clears) this.clear(); + if (this.scanner.scan(text)) this.clear(); } private handleApc(body: string, overflow = false): void { diff --git a/src/client/kitty.test.ts b/src/client/kitty.test.ts index 269bc3d..b03c83f 100644 --- a/src/client/kitty.test.ts +++ b/src/client/kitty.test.ts @@ -292,8 +292,8 @@ describe('ChunkAssembler size cap', () => { describe('ControlScanner', () => { test('a clear split across chunks is seen once it completes', () => { const s = new ControlScanner(); - expect(s.scan('text\x1b[')).toEqual({ clears: false }); - expect(s.scan('2Jmore')).toEqual({ clears: true }); + expect(s.scan('text\x1b[')).toBe(false); + expect(s.scan('2Jmore')).toBe(true); }); test('split at every position', () => { @@ -302,27 +302,27 @@ describe('ControlScanner', () => { const s = new ControlScanner(); const a = s.scan(full.slice(0, cut)); const b = s.scan(full.slice(cut)); - expect(a.clears || b.clears).toBe(true); + expect(a || b).toBe(true); } }); test('a lone trailing ESC is carried, so ESC c split in two is a reset', () => { const s = new ControlScanner(); - expect(s.scan('x\x1b').clears).toBe(false); - expect(s.scan('cy').clears).toBe(true); + expect(s.scan('x\x1b')).toBe(false); + expect(s.scan('cy')).toBe(true); }); test('a completed sequence is not counted again with the next chunk', () => { const s = new ControlScanner(); - expect(s.scan('\x1b[2J').clears).toBe(true); - expect(s.scan('plain')).toEqual({ clears: false }); + expect(s.scan('\x1b[2J')).toBe(true); + expect(s.scan('plain')).toBe(false); }); test('an unrelated unfinished sequence is dropped once it turns into something else', () => { const s = new ControlScanner(); s.scan('\x1b[3'); - expect(s.scan('1m text').clears).toBe(false); - expect(s.scan('plain').clears).toBe(false); + expect(s.scan('1m text')).toBe(false); + expect(s.scan('plain')).toBe(false); }); }); diff --git a/src/client/kitty.ts b/src/client/kitty.ts index 67bc2db..8a7716d 100644 --- a/src/client/kitty.ts +++ b/src/client/kitty.ts @@ -323,32 +323,27 @@ export function axisScale(cssCell: number, dpr: number): number { return cssCell > 0 ? deviceCellSize(cssCell, dpr) / cssCell : dpr; } -/** What a stretch of terminal text asks of the client. */ -export interface ControlScan { - /** A sequence that invalidates placed images was completed. */ - clears: boolean; -} - // A sequence cut off at the end of a chunk: ESC, or ESC [ followed by parameter // bytes but no final byte yet. const INCOMPLETE = new RegExp(`${ESC}(?:\\[[0-9;?]{0,16})?$`); /** - * Finds clear sequences in text that arrives in arbitrary pieces. PTY output and WebSocket frames can end anywhere, so `ESC [` in one - * piece and `2 J` in the next are one sequence; the terminal reassembles them - * in its own parser, and this keeps the unfinished tail between calls so the - * client sees them too. + * Finds clear sequences in text that arrives in arbitrary pieces. PTY output and + * WebSocket frames can end anywhere, so `ESC [` in one piece and `2 J` in the next + * are one sequence; the terminal reassembles them in its own parser, and this + * keeps the unfinished tail between calls so the client sees them too. */ export class ControlScanner { private carry = ''; - scan(text: string): ControlScan { + /** Whether a sequence that invalidates placed images was completed. */ + scan(text: string): boolean { const s = this.carry + text; const tail = INCOMPLETE.exec(s); // Anything before the tail is complete, so it is scanned now; the tail // itself cannot match yet and is scanned again once it is finished. this.carry = tail ? tail[0] : ''; - return { clears: clearsPlacements(s) }; + return clearsPlacements(s); } } diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index df5f2bf..4bb0ca7 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -20,7 +20,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. -- `ghostty.ts`, `types.ts`, `index.ts`: the terminal runs on Ghostty's official libghostty-vt wasm (ADR 042, ADR 047). Upstream's `GhosttyTerminal` class, which ran the Coder wasm, is replaced: `GhosttyTerminal` and `KeyEncoder` are interfaces, implemented by `OfficialTerminal` and `OfficialKeyEncoder`, and `Ghostty.load(path)` loads the official wasm (by default from `../ghostty-vt/ghostty-vt-small.wasm`). The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` and `readEffects`. `GhosttyTerminalConfig` drops `scrollbackLimit`, and its colours are undefined when unset instead of 0, so `#000000` is black. The binding drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. Upstream's Coder-only types (`GhosttyWasmExports`, the struct sizes, `TerminalHandle`) are removed. +- `ghostty.ts`, `types.ts`, `index.ts`: the terminal runs on Ghostty's official libghostty-vt wasm (ADR 042, ADR 047). Upstream's `GhosttyTerminal` class, which ran the Coder wasm, is replaced: `GhosttyTerminal` and `KeyEncoder` are interfaces, implemented by `OfficialTerminal` and `OfficialKeyEncoder`, and `Ghostty.load(path)` loads the official wasm (by default from `../ghostty-vt/ghostty-vt-small.wasm`). The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` and `readEffects`, and drops upstream's `isDirty`, `getGrapheme` and `getScrollbackGrapheme`, which nothing calls: graphemes are read as strings (`getGraphemeString`, `getScrollbackGraphemeString`). `GhosttyTerminalConfig` drops `scrollbackLimit`, and its colours are undefined when unset instead of 0, so `#000000` is black. The binding drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. Upstream's Coder-only types (`GhosttyWasmExports`, the struct sizes, `TerminalHandle`) are removed. - `terminal.ts`, `renderer.ts`: the terminal side of the official binding (ADR 042, ADR 047). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; bell and title come from `readEffects` instead of upstream's stream scan (`checkForTitleChange` is removed); `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours; the `scrollback` option is removed; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder wasm). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 59b4840..59b9ff5 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -78,7 +78,6 @@ export interface GhosttyTerminal { markClean(): void; getViewport(): GhosttyCell[]; getLine(y: number): GhosttyCell[] | null; - isDirty(): boolean; needsFullRedraw(): boolean; clearDirty(): void; getDimensions(): { cols: number; rows: number }; @@ -96,9 +95,7 @@ export interface GhosttyTerminal { isRowWrapped(row: number): boolean; getHyperlinkUri(row: number, col: number): string | null; getScrollbackHyperlinkUri(offset: number, col: number): string | null; - getGrapheme(row: number, col: number): number[] | null; getGraphemeString(row: number, col: number): string; - getScrollbackGrapheme(offset: number, col: number): number[] | null; getScrollbackGraphemeString(offset: number, col: number): string; // Replies and effects @@ -578,8 +575,6 @@ export class Ghostty { readonly keyActions: Map; /** @internal */ readonly unidentifiedKey: number; - /** The library's manifest header: library version and build commit. */ - readonly libraryVersion: string; constructor(exports: OfficialWasmExports) { this.ex = exports; @@ -588,12 +583,10 @@ export class Ghostty { let end = p; while (u8[end] !== 0) end++; const manifest = JSON.parse(new TextDecoder().decode(u8.subarray(p, end))) as { - library_version?: string; types: Record; }; const layout = new VtLayout(manifest.types); this.L = resolveOfficialLayout(layout); - this.libraryVersion = manifest.library_version ?? ''; this.fnTable = new FnTable(exports.__indirect_function_table); this.keys = translateEnum(Key, layout, 'GhosttyKey', OFFICIAL_KEY_NAMES); this.encoderOptions = translateEnum( @@ -1162,10 +1155,6 @@ export class OfficialTerminal implements GhosttyTerminal { return this.copyRow(this.rowAt(this.sbRows + y)); } - isDirty(): boolean { - return this.update() !== DirtyState.NONE; - } - needsFullRedraw(): boolean { return this.update() === DirtyState.FULL; } @@ -1248,23 +1237,12 @@ export class OfficialTerminal implements GhosttyTerminal { return this.hyperlinkAt(this.L.point.HISTORY, col, offset); } - getGrapheme(row: number, col: number): number[] | null { - if (row < 0 || row >= this._rows) return null; - return this.codepointsOf(this.getGraphemeString(row, col)); - } - getGraphemeString(row: number, col: number): string { if (row < 0 || row >= this._rows || col < 0 || col >= this._cols) return ' '; this.refresh(); return this.graphemeOf(this.rowAt(this.sbRows + row), col); } - getScrollbackGrapheme(offset: number, col: number): number[] | null { - this.refresh(); - if (offset < 0 || offset >= this.sbRows) return null; - return this.codepointsOf(this.getScrollbackGraphemeString(offset, col)); - } - getScrollbackGraphemeString(offset: number, col: number): string { this.refresh(); if (offset < 0 || offset >= this.sbRows || col < 0 || col >= this._cols) return ' '; @@ -1908,12 +1886,6 @@ export class OfficialTerminal implements GhosttyTerminal { return cp ? String.fromCodePoint(cp) : ' '; } - private codepointsOf(s: string): number[] { - const cps: number[] = []; - for (const ch of s) cps.push(ch.codePointAt(0) ?? 0); - return cps; - } - // ========================================================================== // Private: single-cell lookups (grid_ref) // ========================================================================== diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 9f8da80..0810769 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -136,7 +136,7 @@ describe('official engine: cells', () => { expect(t.getGraphemeString(0, 8)).toBe('❤️'); expect(line[11].grapheme_len).toBe(1); expect(t.getGraphemeString(0, 11)).toBe('é'); - expect(t.getGrapheme(0, 11)).toEqual([0x65, 0x301]); + expect([...t.getGraphemeString(0, 11)].map((c) => c.codePointAt(0))).toEqual([0x65, 0x301]); expect(t.getGraphemeString(0, 13)).toBe('👨‍👩‍👧'); expect(line[13].width).toBe(2); expect(rowText(t, 0)).toBe('你好|😀|❤️|é|👨‍👩‍👧'); From 1163dc26e8ebbc7192fd2e746351c78ffa8a7175 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 19:59:29 -0400 Subject: [PATCH 044/295] docs(client): record ADR 046's memory measured in headless Chrome --- docs/adrs/046.client.scrollback-lines.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/adrs/046.client.scrollback-lines.md b/docs/adrs/046.client.scrollback-lines.md index 71caff0..f1cc878 100644 --- a/docs/adrs/046.client.scrollback-lines.md +++ b/docs/adrs/046.client.scrollback-lines.md @@ -37,7 +37,17 @@ A new client config key, `scrollbackLines`, sets the terminal's history in lines | `scrollbackLines: 10000` | 300 | 9,886 | | `scrollbackLines: 500` | 80 | 266 | | `scrollbackLines: 0` (40,000 written) | 80 | 39,977 | -- Memory per tab grows with history instead of staying near one page. ADR 041's Node figures were about 11 MB at 10,000 lines of plain text at 120 columns and 25 MB of heavy truecolor; this ADR's measurements in headless Chrome are below. +- Memory per tab grows with history instead of staying near one page, and the 32 MiB ceiling binds sooner than the line limit for heavy output. Measured in headless Chrome 155 at DPR 2, 30,000 lines written (60,000 for `0`); wasm memory never shrinks once grown: + + | Setting | Output | Columns | Lines kept | wasm memory | + |---|---|---|---|---| + | `10000` | plain (`seq`) | 120 | 9,587–9,666 | 12.3 MB | + | `10000` | plain | 300 | 9,869 | 28.4 MB | + | `10000` | every cell a different truecolor fg/bg | 120 | 5,445 (32 MiB binds) | 58.7 MB | + | `10000` | every cell a different truecolor fg/bg | 300 | 2,209 (32 MiB binds) | 61.7 MB | + | `0` | plain | 120 | 32,450 (32 MiB binds) | 34.1 MB | + + The JS heap stays near 2–3 MB. The canvases cost more than history and do not depend on it: about 66 MB at 1400×900 and DPR 2 (the overscan canvas 29.5 MB, the kitty graphics overlay 19.2 MB even with no images, the renderer's scratch canvas 16.7 MB). An app on the alternate screen, such as a multiplexer, adds no history. - **Config migration:** none required. A user who raised `scrollback` to get more history keeps a larger replay buffer and gets 10,000 lines of history on the official engine; to change that, they set `scrollbackLines`. - On the Coder engine (still the default) nothing changes, and `scrollbackLines` has no effect. From b3272cb3f2c21350221bb4dad736c0b0ce8e6687 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 20:13:04 -0400 Subject: [PATCH 045/295] docs(webtty): ADR 048, PTY output flow control by acknowledgements --- docs/adrs/048.webtty.output-flow-control.md | 61 +++++++++++++++++++++ docs/specs/client.md | 4 +- docs/specs/webtty.md | 17 +++++- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 docs/adrs/048.webtty.output-flow-control.md diff --git a/docs/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md new file mode 100644 index 0000000..1d6d67e --- /dev/null +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -0,0 +1,61 @@ +# ADR 048: webtty — PTY output flow control + +**SPEC:** [webtty](../specs/webtty.md), [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-13 + +--- + +## Context + +The server broadcasts PTY output with `client.send(data)` for every `onData` chunk, to every client, with no flow control. Nothing ever waits for a browser tab: a PTY that writes faster than a tab parses fills the WebSocket's send buffer without bound. + +Under Bun, `import { WebSocketServer } from 'ws'` (ADR 001) does not load the `ws` package. Bun substitutes its built-in shim (`BunWebSocketMocked` over native uWebSockets), and the shim breaks the stream once a client falls behind: + +- **Reordering.** Native `send` returns `0` when it drops a message past its 16 MB `backpressureLimit`. The shim then queues that message, but later calls to `send` go straight to native and overtake the queue. +- **Duplicates and gaps.** When the shim drains its queue, it treats `-1` (accepted, with backpressure) as a failure and sends the head message again, while the rest of the queue waits. + +`ws` under Node has neither bug. `origin/main` has the same code. Any tab that falls more than 16 MB behind sees it: a background tab, a slow machine, or a heavy truecolor flood (`cat` of a large log, a build). Reproduced on webtty's own server with a client that sleeps 0.5 ms per message: 18,398 backward jumps, gaps, missing lines, and SGR sequences spliced into other lines, which the terminal shows as literal `38;2;…m` text. + +Bounding the buffer fixes both problems. The shim misbehaves only past 16 MB, and memory on the server stops growing with the slowest tab. + +## Decision + +The server paces output to each client by acknowledgements from that client, and pauses the PTY when every client is behind and the PTY can pause. + +**Client.** + +- Connects with `?flow=1` in the PTY WebSocket URL, which declares that it will acknowledge. +- Counts the characters (UTF-16 code units, `string.length`) of each output message after `graphics.feed()` returns. `term.write()` parses synchronously in the wasm (the vendored `writeQueue` is never filled), so by then the output has been parsed. +- Sends `{"type":"ack","chars":}`, with the running total since connect, every 64 Ki characters, and 100 ms after the last message when less than that is outstanding. A total, not a delta, so a repeated or late ack cannot be counted twice. + +**Server (`src/server/flow.ts`, `OutputPacer`, one per session).** + +- Per client: characters sent, characters acknowledged, and a queue of pending output. +- Sends output straight through while unacknowledged characters are under the high-water mark (2 Mi characters, at most 6 MB of UTF-8, far under the shim's 16 MB). At the mark the client is *blocked*: new output is queued in order. +- When acks bring the client down to the low-water mark (512 Ki characters), it is unblocked and the queue is sent in order, coalesced into messages of up to 64 Ki characters, until the client blocks again. Whole PTY chunks are joined and never split, so a surrogate pair is never cut. +- Scrollback replay and the banner go through the same path, so they count against the window. +- When every client of a session is blocked and the PTY has `pause()` (node-pty), the PTY is paused and the app blocks on its own writes, as in a real terminal; it resumes as soon as any client is unblocked or leaves. `Bun.Terminal` has no pause or resume (checked on Bun 1.3.11), so under Bun the PTY keeps running while clients are blocked. +- A queue over 16 Mi characters closes that client with `4003` (*fell behind*). Under Bun this is what bounds memory. The client resets its terminal, says so in one status line, and reconnects at once, getting the scrollback replay like a new tab. +- A client that connects without `?flow=1` is *legacy* (a tab loaded before an upgrade): it is sent output straight through as before, and closed with `4003` once `bufferedAmount` passes 8 MB. The old client reconnects on any code other than `4001` and `1001`, so it recovers through the replay. The client is served by the same server, so a mismatch lasts only until that tab reloads. + +Frames stay text frames. The URL parameter replaces the brief's "treat a client that has never acked as legacy": the server knows the protocol at connect, before any output, instead of guessing from silence. + +## Consequences + +- Output reaches every acknowledging client in order, without gaps or duplicates, at any speed, and the server's send buffer per client stays under about 2 Mi characters plus one PTY chunk. +- A hidden tab that stops processing holds its window and its queue. Under node-pty the app is paced by the slowest attached tab, as with tmux or a real terminal on a slow link. Under Bun, a tab that falls 16 Mi characters behind is reset and replayed; what scrolled past in that time is not in its history. +- One small upstream JSON message per 64 Ki characters of output. +- The `ws` shim stays in use under Bun. Its bugs are no longer reachable for acknowledging clients; a legacy client can still hit them between 16 MB of native backlog and the 8 MB `bufferedAmount` check (the shim only counts what it queued), until it reloads. + +## Considered Options + +- **Native `Bun.serve` WebSockets with our own queue.** Fixes the shim at its source and exposes `getBufferedAmount()`, but replaces the `node:http` + `ws` server that also has to run on Node (Windows, `server:node`), so it means two WebSocket server paths. It still needs a bound on per-client memory, which takes acknowledgements or a buffer cap anyway. +- **Stopgap: close a client when `bufferedAmount` grows.** Small, but the shim's `bufferedAmount` is 0 until native has dropped a message, so it cannot see trouble coming; every slow tab would be disconnected and replayed rather than slowed down, and the app is never paced. +- **Do nothing.** Background tabs and heavy output keep corrupting the screen under Bun, and server memory grows with the slowest tab. + +## Related Decisions + +- [ADR 001 — Bootstrap](001.webtty.bootstrap.md): chose `ws` for the WebSocket server. +- [ADR 007 — Session client](007.webtty.session-client.md): multi-client broadcast and scrollback replay, which the pacer keeps. +- [ADR 032 — Kitty graphics overlay](032.client.kitty-graphics-overlay.md): `graphics.feed()` is where output is parsed and counted. diff --git a/docs/specs/client.md b/docs/specs/client.md index 1cb9fcc..e2ac8eb 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -57,7 +57,7 @@ The vendored TS is excluded from Biome so it stays byte-identical to upstream un 2. Fetches `GET /api/config` to get terminal config 3. Sets `document.title = sessionId + ' | webtty'` 4. Loads the official libghostty-vt wasm and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollbackLines, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) -5. Connects to `ws:///ws/:id?cols=&rows=` over WebSocket +5. Connects to `ws:///ws/:id/pty?cols=&rows=&flow=1` over WebSocket, and acknowledges output as it parses it: `{ type: 'ack', chars }`, the characters parsed since connecting, every 64 Ki characters and 100 ms after output stops (ADR 048) 6. Fits the terminal to the viewport from the full container size (no scrollbar reserve, see ADR 033) and observes resize events 7. Sends a `{ type: 'resize', cols, rows }` JSON message on open and on every terminal resize 8. Forwards all keystrokes to the PTY via `term.onData` @@ -111,6 +111,7 @@ All status messages written to the terminal share a consistent style: |-------|---------|----------| | WS close `4001` (session deleted or shell exited) | `Session removed.` | `window.close()` after 500ms | | WS close `1001` (server stopped) | `Server stopped.` | `window.close()` after 500ms | +| WS close `4003` (tab fell behind the output) | `Tab fell behind; replaying recent output.` | Terminal reset, reconnect at once | | WS close (unexpected) | `Connection lost. Reconnecting in 2s...` | Reconnect after 2s | | WS error | `WebSocket error.` | — | @@ -176,6 +177,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Config endpoint | `GET /api/config` — serves client-relevant config keys; replaces server-side template injection | [ADR 012](../adrs/012.client.static-assets.md) | ✅ | | Session support | `GET /s/:id` opens a named session; `GET /` redirects to last-used or creates `main` | [ADR 005](../adrs/005.client.session-support.md) | ✅ | | Multi-client | Multiple tabs can attach to the same session; scrollback replayed on reconnect; tab closes when PTY exits | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | +| Output acknowledgements | Output is acknowledged as it is parsed, so the server sends it in order at the tab's pace; a tab closed with `4003` for falling behind resets and reconnects to the replay | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime inside the terminal (Ps 0 restores the default), and the cursor cell is repainted so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index fde0c25..23d2692 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -1,6 +1,6 @@ # SPEC: webtty -**Last Updated:** 2026-09-20 +**Last Updated:** 2026-10-13 --- @@ -46,6 +46,20 @@ interface Session { Sessions survive client disconnects — the PTY keeps running while idle. Multiple clients can attach to the same session simultaneously; PTY output is broadcast to all. +### Output flow control + +PTY output is paced per client by acknowledgements (ADR 048). A client that connects with `?flow=1` sends `{"type":"ack","chars":}` as it parses output: the running count of characters (UTF-16 code units) since it connected, every 64 Ki characters and 100 ms after output stops. + +| Rule | Value | +|------|-------| +| Client blocked (new output queued in order) | 2 Mi characters unacknowledged | +| Client unblocked (queue sent, coalesced into ≤ 64 Ki-character messages) | 512 Ki characters unacknowledged | +| PTY paused | while every client is blocked, if the PTY can pause (node-pty; `Bun.Terminal` cannot) | +| Client closed with `4003` (fell behind; it reconnects and gets the replay) | queue over 16 Mi characters | +| Legacy client (no `?flow=1`) | sent straight through; closed with `4003` once `bufferedAmount` passes 8 MB | + +Scrollback replay and the banner count against the same window. Frames are text frames. + Session IDs appear directly in the URL path (`/s/:id`), so they must be valid URL path segments: - Allowed characters: `a-z`, `0-9`, `-`, `_`, `.` @@ -83,3 +97,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ✅ | | Session base directory | `POST /api/sessions` accepts `baseDir` (absolute path); PTY shell spawns there instead of `homedir()`; CLI `webtty [go] [id] --dir ` resolves and forwards it; ignored when attaching to an existing session | [ADR 028](../adrs/028.session.base-dir.md) | ✅ | +| Output flow control | Output paced per client by acknowledgements: in order at any client speed, bounded server buffers, PTY paused while every client is behind (node-pty), a client that falls 16 Mi characters behind is closed with `4003` and reconnects to the replay; avoids Bun's `ws` shim reordering and duplicating messages past 16 MB | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ | From 8ae08ed5b2a4e3f7fed3ca84a222fa08499ce090 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 20:15:35 -0400 Subject: [PATCH 046/295] feat(server): pace PTY output to each client by its acknowledgements --- src/pty/node-pty.d.ts | 2 + src/pty/node.ts | 6 + src/pty/types.ts | 7 + src/server/flow.test.ts | 248 +++++++++++++++++++++++++++++++++++ src/server/flow.ts | 181 +++++++++++++++++++++++++ src/server/session.ts | 4 + src/server/websocket.test.ts | 43 ++++++ src/server/websocket.ts | 27 ++-- 8 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 src/server/flow.test.ts create mode 100644 src/server/flow.ts diff --git a/src/pty/node-pty.d.ts b/src/pty/node-pty.d.ts index 0abe9ff..01702c5 100644 --- a/src/pty/node-pty.d.ts +++ b/src/pty/node-pty.d.ts @@ -20,6 +20,8 @@ declare module '@lydell/node-pty' { onExit(cb: (e: { exitCode: number; signal?: number }) => void): void; write(data: string): void; resize(cols: number, rows: number): void; + pause(): void; + resume(): void; kill(signal?: string): void; } diff --git a/src/pty/node.ts b/src/pty/node.ts index 005af65..80c0edc 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -48,6 +48,12 @@ export function spawn( resize(cols, rows) { ptyProc.resize(cols, rows); }, + pause() { + ptyProc.pause(); + }, + resume() { + ptyProc.resume(); + }, kill() { ptyProc.kill(); }, diff --git a/src/pty/types.ts b/src/pty/types.ts index 739353a..2aef41e 100644 --- a/src/pty/types.ts +++ b/src/pty/types.ts @@ -10,6 +10,13 @@ export interface PtyProcess { write(data: string): void; /** Notify the PTY of a terminal resize. */ resize(cols: number, rows: number): void; + /** + * Stop reading output, so the app blocks on its writes once the PTY's buffer is + * full. Only node-pty has it: `Bun.Terminal` cannot pause (ADR 048). + */ + pause?(): void; + /** Read output again after {@link PtyProcess.pause}. */ + resume?(): void; /** Terminate the child process. */ kill(): void; } diff --git a/src/server/flow.test.ts b/src/server/flow.test.ts new file mode 100644 index 0000000..a6132a2 --- /dev/null +++ b/src/server/flow.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from 'bun:test'; +import { FELL_BEHIND, type FlowLimits, type FlowSocket, OutputPacer } from './flow'; + +const LIMITS: FlowLimits = { + highWater: 100, + lowWater: 40, + chunk: 30, + maxPending: 1000, + legacyBuffered: 500, +}; + +class FakeSocket implements FlowSocket { + readonly OPEN = 1; + readyState = 1; + bufferedAmount = 0; + messages: string[] = []; + closed: { code?: number; reason?: string } | null = null; + send(data: string): void { + this.messages.push(data); + } + close(code?: number, reason?: string): void { + this.closed = { code, reason }; + this.readyState = 3; + } + get received(): string { + return this.messages.join(''); + } +} + +class FakePty { + pauses = 0; + resumes = 0; + pause(): void { + this.pauses++; + } + resume(): void { + this.resumes++; + } +} + +function setup(pty: FakePty | null = new FakePty()) { + const pacer = new OutputPacer(() => pty, LIMITS); + return { pacer, pty }; +} + +describe('OutputPacer', () => { + test('sends straight through under the high-water mark', () => { + const { pacer } = setup(); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(50)); + pacer.broadcast('b'.repeat(40)); + expect(ws.messages).toEqual(['a'.repeat(50), 'b'.repeat(40)]); + }); + + test('queues output once a client reaches the high-water mark', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + pacer.broadcast('b'); + pacer.broadcast('c'); + expect(ws.messages).toEqual(['a'.repeat(100)]); + }); + + test('resumes below the low-water mark, in order and coalesced', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + for (const ch of 'bcdefgh') pacer.broadcast(ch.repeat(10)); + // Still above the low-water mark: nothing more is sent. + pacer.ack(ws, 50); + expect(ws.messages.length).toBe(1); + pacer.ack(ws, 100); + // Chunks of 10 coalesce into messages of up to 30 characters. + expect(ws.messages.slice(1)).toEqual([ + 'b'.repeat(10) + 'c'.repeat(10) + 'd'.repeat(10), + 'e'.repeat(10) + 'f'.repeat(10) + 'g'.repeat(10), + 'h'.repeat(10), + ]); + expect(ws.received).toBe( + `${'a'.repeat(100)}${[...'bcdefgh'].map((c) => c.repeat(10)).join('')}`, + ); + }); + + test('a flush stops when the client blocks again', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + for (let i = 0; i < 20; i++) pacer.broadcast('x'.repeat(10)); + pacer.ack(ws, 100); + // Messages of 30 go out until at least 100 are unacknowledged again. + expect(ws.messages.slice(1).map((m) => m.length)).toEqual([30, 30, 30, 30]); + pacer.ack(ws, 220); + expect(ws.received.length).toBe(300); + }); + + test('never splits a queued chunk', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + pacer.broadcast('😀'.repeat(20)); // 40 code units, over the chunk size + pacer.ack(ws, 100); + expect(ws.messages[1]).toBe('😀'.repeat(20)); + }); + + test('ignores acks beyond what was sent, repeated acks and acks going back', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + pacer.broadcast('b'.repeat(10)); + pacer.ack(ws, 1e9); + expect(ws.received).toBe(`${'a'.repeat(100)}${'b'.repeat(10)}`); + pacer.broadcast('c'.repeat(90)); + // 1e9 was capped at 100 sent, so 100 are unacknowledged again and the client blocks. + pacer.broadcast('d'); + expect(ws.received.endsWith('d')).toBe(false); + pacer.ack(ws, 10); + pacer.ack(ws, Number.NaN); + expect(ws.received.endsWith('d')).toBe(false); + pacer.ack(ws, 200); + expect(ws.received.endsWith('d')).toBe(true); + }); + + test('pauses the PTY while every client is blocked and resumes when one is not', () => { + const { pacer, pty } = setup(); + const a = new FakeSocket(); + const b = new FakeSocket(); + pacer.add(a, true); + pacer.add(b, true); + pacer.send(a, 'x'.repeat(100)); + expect(pty?.pauses).toBe(0); + pacer.send(b, 'y'.repeat(100)); + expect(pty?.pauses).toBe(1); + expect(pacer.isPaused).toBe(true); + pacer.ack(b, 100); + expect(pty?.resumes).toBe(1); + expect(pacer.isPaused).toBe(false); + }); + + test('resumes the PTY when the last blocked client leaves', () => { + const { pacer, pty } = setup(); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('x'.repeat(100)); + expect(pacer.isPaused).toBe(true); + pacer.remove(ws); + expect(pacer.isPaused).toBe(false); + expect(pty?.resumes).toBe(1); + }); + + test('a legacy client never pauses the PTY', () => { + const { pacer, pty } = setup(); + const fast = new FakeSocket(); + const legacy = new FakeSocket(); + pacer.add(fast, true); + pacer.add(legacy, false); + pacer.broadcast('x'.repeat(300)); + expect(pty?.pauses).toBe(0); + expect(legacy.received.length).toBe(300); + }); + + test('a PTY that cannot pause is left running', () => { + const pacer = new OutputPacer(() => ({}), LIMITS); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('x'.repeat(100)); + expect(pacer.isPaused).toBe(false); + }); + + test('a legacy client is sent everything straight through', () => { + const { pacer } = setup(); + const ws = new FakeSocket(); + pacer.add(ws, false); + for (let i = 0; i < 10; i++) pacer.broadcast('z'.repeat(100)); + expect(ws.received.length).toBe(1000); + expect(ws.closed).toBeNull(); + }); + + test('a legacy client is closed once bufferedAmount passes the limit', () => { + const { pacer } = setup(); + const ws = new FakeSocket(); + pacer.add(ws, false); + ws.bufferedAmount = 501; + pacer.broadcast('z'); + expect(ws.closed?.code).toBe(FELL_BEHIND); + pacer.broadcast('after'); + expect(ws.received).toBe('z'); + }); + + test('closes a client whose queue overflows and keeps serving the others', () => { + const { pacer } = setup(); + const slow = new FakeSocket(); + const fast = new FakeSocket(); + pacer.add(slow, true); + pacer.add(fast, true); + let sent = 0; + for (let i = 0; i < 120; i++) { + pacer.broadcast('q'.repeat(10)); + sent += 10; + pacer.ack(fast, sent); + } + expect(slow.closed?.code).toBe(FELL_BEHIND); + expect(fast.received.length).toBe(1200); + expect(fast.closed).toBeNull(); + }); + + test('skips a client that is no longer open', () => { + const { pacer } = setup(); + const ws = new FakeSocket(); + pacer.add(ws, true); + ws.readyState = 2; + pacer.broadcast('x'); + expect(ws.messages).toEqual([]); + }); + + test('a slow client receives the whole stream byte for byte and in order', () => { + const { pacer, pty } = setup(); + const slow = new FakeSocket(); + pacer.add(slow, true); + let expected = ''; + let parsed = 0; + let produced = 0; + for (let step = 0; step < 100_000; step++) { + // The PTY produces only while it is not paused. + if (!pacer.isPaused && produced < 3000) { + const line = `${String(produced++).padStart(5, '0')}\x1b[38;2;1;2;3m╳😀\r\n`; + expected += line; + pacer.broadcast(line); + } + // The client parses a few characters every few steps, and stalls for a while. + if (step % 7 === 0 && !(step > 2000 && step < 6000)) { + parsed = Math.min(slow.received.length, parsed + 25); + pacer.ack(slow, parsed); + } + if (produced === 3000 && parsed === expected.length) break; + } + expect(produced).toBe(3000); + expect(slow.received).toBe(expected); + expect(slow.closed).toBeNull(); + expect(pty?.pauses).toBeGreaterThan(10); + expect(pty?.resumes).toBe(pty?.pauses); + }); +}); diff --git a/src/server/flow.ts b/src/server/flow.ts new file mode 100644 index 0000000..e7ed25d --- /dev/null +++ b/src/server/flow.ts @@ -0,0 +1,181 @@ +/** + * PTY output flow control (ADR 048). Each client acknowledges the characters it has + * parsed; the server stops sending to a client that is too far behind, queues its + * output in order, and pauses the PTY while every client is behind. + */ + +/** Close code for a client whose queued output passed {@link FlowLimits.maxPending}. */ +export const FELL_BEHIND = 4003; + +/** Limits in characters (UTF-16 code units, `string.length`), except `legacyBuffered`. */ +export interface FlowLimits { + /** Unacknowledged characters at which a client is blocked and output is queued. */ + highWater: number; + /** Unacknowledged characters at which a blocked client is unblocked. */ + lowWater: number; + /** Largest message the queue is coalesced into (whole chunks are never split). */ + chunk: number; + /** Queued characters past which the client is closed with {@link FELL_BEHIND}. */ + maxPending: number; + /** `bufferedAmount` (bytes) past which a legacy client is closed with {@link FELL_BEHIND}. */ + legacyBuffered: number; +} + +export const DEFAULT_FLOW_LIMITS: FlowLimits = { + highWater: 2 * 1024 * 1024, + lowWater: 512 * 1024, + chunk: 64 * 1024, + maxPending: 16 * 1024 * 1024, + legacyBuffered: 8 * 1024 * 1024, +}; + +/** The part of a WebSocket the pacer uses. */ +export interface FlowSocket { + readonly readyState: number; + readonly OPEN: number; + readonly bufferedAmount: number; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +/** A PTY that may be able to stop reading output (node-pty can, `Bun.Terminal` cannot). */ +export interface PausablePty { + pause?(): void; + resume?(): void; +} + +interface ClientFlow { + /** Whether the client acknowledges (connected with `?flow=1`). */ + acks: boolean; + sent: number; + acked: number; + blocked: boolean; + pending: string[]; + pendingLength: number; +} + +/** Paces one session's output to its clients. */ +export class OutputPacer { + private flows = new Map(); + private paused = false; + + /** + * @param pty - Returns the session's PTY, or `null` before it spawns. + * @param limits - Flow limits (tests pass small ones). + */ + constructor( + private pty: () => PausablePty | null, + private limits: FlowLimits = DEFAULT_FLOW_LIMITS, + ) {} + + /** Whether the PTY is paused because every client is behind. */ + get isPaused(): boolean { + return this.paused; + } + + /** Starts pacing `ws`. `acks` is true for a client that sends acknowledgements. */ + add(ws: FlowSocket, acks: boolean): void { + this.flows.set(ws, { + acks, + sent: 0, + acked: 0, + blocked: false, + pending: [], + pendingLength: 0, + }); + this.updatePause(); + } + + /** Stops pacing `ws` (closed) and drops its queue. */ + remove(ws: FlowSocket): void { + if (!this.flows.delete(ws)) return; + this.updatePause(); + } + + /** Sends `data` to one client, in order after anything queued for it. */ + send(ws: FlowSocket, data: string): void { + const flow = this.flows.get(ws); + if (flow) this.write(ws, flow, data); + this.updatePause(); + } + + /** Sends `data` to every client. */ + broadcast(data: string): void { + for (const [ws, flow] of this.flows) this.write(ws, flow, data); + this.updatePause(); + } + + /** Records that `ws` has parsed `total` characters since it connected. */ + ack(ws: FlowSocket, total: number): void { + const flow = this.flows.get(ws); + if (!flow?.acks || !Number.isFinite(total)) return; + flow.acked = Math.max(flow.acked, Math.min(total, flow.sent)); + if (flow.blocked && flow.sent - flow.acked <= this.limits.lowWater) { + flow.blocked = false; + this.flush(ws, flow); + } + this.updatePause(); + } + + private write(ws: FlowSocket, flow: ClientFlow, data: string): void { + if (ws.readyState !== ws.OPEN) return; + if (!flow.acks) { + ws.send(data); + if (ws.bufferedAmount > this.limits.legacyBuffered) this.fellBehind(ws); + return; + } + if (!flow.blocked && flow.pending.length === 0) { + this.transmit(ws, flow, data); + return; + } + flow.pending.push(data); + flow.pendingLength += data.length; + if (flow.pendingLength > this.limits.maxPending) this.fellBehind(ws); + } + + private transmit(ws: FlowSocket, flow: ClientFlow, data: string): void { + ws.send(data); + flow.sent += data.length; + if (flow.sent - flow.acked >= this.limits.highWater) flow.blocked = true; + } + + /** Sends the queue in order, coalesced, until it is empty or the client blocks again. */ + private flush(ws: FlowSocket, flow: ClientFlow): void { + let i = 0; + while (i < flow.pending.length && !flow.blocked && ws.readyState === ws.OPEN) { + let message = flow.pending[i++]; + while ( + i < flow.pending.length && + message.length + flow.pending[i].length <= this.limits.chunk + ) { + message += flow.pending[i++]; + } + flow.pendingLength -= message.length; + this.transmit(ws, flow, message); + } + flow.pending.splice(0, i); + } + + private fellBehind(ws: FlowSocket): void { + this.flows.delete(ws); + ws.close(FELL_BEHIND, 'fell behind'); + } + + private updatePause(): void { + let allBlocked = this.flows.size > 0; + for (const flow of this.flows.values()) { + if (!flow.acks || !flow.blocked) { + allBlocked = false; + break; + } + } + const pty = this.pty(); + if (allBlocked && !this.paused && pty?.pause) { + pty.pause(); + this.paused = true; + } else if (!allBlocked && this.paused) { + pty?.resume?.(); + this.paused = false; + } + } +} diff --git a/src/server/session.ts b/src/server/session.ts index a1524f7..e621f31 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os'; import type { WebSocket as WS } from 'ws'; import type { PtyProcess } from '../pty'; +import { OutputPacer } from './flow'; /** A running (or recently created) terminal session. */ export interface Session { @@ -18,6 +19,8 @@ export interface Session { subscribers: Set; /** Accumulated PTY output retained for replay when a new client joins. */ scrollback: string; + /** Paces PTY output to the clients by their acknowledgements (ADR 048). */ + output: OutputPacer; } /** All active sessions, keyed by session ID. */ @@ -70,6 +73,7 @@ export function createSession(id: string, baseDir = homedir()): Session { clients: new Set(), subscribers: new Set(), scrollback: '', + output: new OutputPacer(() => session.pty), }; sessionRegistry.set(id, session); return session; diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 7f05f9a..086320b 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -187,6 +187,49 @@ describe('websocket', () => { expect(m2.join('')).toContain('hello-fanout'); }); + test.skipIf(process.platform === 'win32')( + 'output to an acknowledging client stops at the high-water mark and resumes in order', + async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'ws-test-flow' }), + }); + const { ws, messages } = await connectWs( + `${wsBase}/ws/ws-test-flow/pty?cols=80&rows=24&flow=1`, + ); + const received = () => messages.reduce((n, m) => n + m.length, 0); + const ack = () => ws.send(JSON.stringify({ type: 'ack', chars: received() })); + await waitForPrompt(messages); + ack(); + + // About 3.1 million characters, more than the 2 Mi-character window. + const N = 400_000; + ws.send(`seq 1 ${N}; echo FLOW-$((40+2))${NL}`); + await new Promise((r) => setTimeout(r, 1500)); + const held = received(); + expect(held).toBeGreaterThan(1024 * 1024); + expect(held).toBeLessThan(2 * 1024 * 1024 + 256 * 1024); + expect(messages.join('')).not.toContain('FLOW-42'); + + const deadline = Date.now() + 20000; + while (!messages.join('').includes('FLOW-42') && Date.now() < deadline) { + ack(); + await new Promise((r) => setTimeout(r, 20)); + } + await closeWs(ws); + + const numbers = messages + .join('') + .split('\r\n') + .filter((l) => /^\d+$/.test(l)) + .map(Number); + expect(numbers.length).toBe(N); + expect(numbers.every((n, i) => n === i + 1)).toBe(true); + }, + 30000, + ); + test('session is removed and tab closed when shell exits', async () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST', diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 4ec37ae..1d77810 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -3,6 +3,7 @@ import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; import { loadConfig } from '../config'; import { spawnForSession } from '../pty'; +import { FELL_BEHIND } from './flow'; import type { Session } from './session'; import { sessionRegistry, setLastUsedId } from './session'; @@ -11,6 +12,7 @@ const WS_CLOSE = { BAD_REQUEST: 1008 as const, // RFC 6455: policy violation / bad data SESSION_GONE: 4001 as const, // app-level: session deleted or shell exited PTY_NOT_RUNNING: 4002 as const, // app-level: PTY not running + FELL_BEHIND, // app-level: client fell too far behind the output (ADR 048) } as const; function closeClients(session: Session, code: number, reason: string): void { @@ -186,6 +188,9 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer return; } session.clients.add(ws); + // A client that acknowledges output says so in the URL; one without it predates + // ADR 048 (a tab loaded before an upgrade) and is sent output unpaced. + session.output.add(ws, url.searchParams.get('flow') === '1'); setLastUsedId(id); if (!session.pty) { @@ -202,11 +207,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer session.pty.onData((data: string) => { session.scrollback = (session.scrollback + data).slice(-config.scrollback); - for (const client of session.clients) { - if (client.readyState === client.OPEN) { - client.send(data, { binary: false }); - } - } + session.output.broadcast(data); }); session.pty.onExit(() => { @@ -227,12 +228,12 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer }); const banner = sessionBanner(); - ws.send(banner); + session.output.send(ws, banner); session.scrollback = banner; session.pty.write('\n'); } else { if (session.scrollback) { - ws.send(session.scrollback, { binary: false }); + session.output.send(ws, session.scrollback); } session.pty.resize(cols, rows); } @@ -241,7 +242,16 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer const message = data.toString('utf8'); if (message.startsWith('{')) { try { - const msg = JSON.parse(message) as { type: string; cols: number; rows: number }; + const msg = JSON.parse(message) as { + type: string; + cols: number; + rows: number; + chars: number; + }; + if (msg.type === 'ack') { + session.output.ack(ws, Number(msg.chars)); + return; + } if (msg.type === 'resize') { const c = Math.max(1, Math.min(1000, Math.trunc(msg.cols) || 80)); const r = Math.max(1, Math.min(500, Math.trunc(msg.rows) || 24)); @@ -257,6 +267,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer ws.on('close', () => { session.clients.delete(ws); + session.output.remove(ws); }); ws.on('error', () => {}); From 30667218ed81c2686ea9bbf69fd45d0ac862661c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 20:16:23 -0400 Subject: [PATCH 047/295] feat(client): acknowledge PTY output as it is parsed --- src/client/ack.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++++ src/client/ack.ts | 58 +++++++++++++++++++++++++++++++++ src/client/index.ts | 23 +++++++++++-- 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/client/ack.test.ts create mode 100644 src/client/ack.ts diff --git a/src/client/ack.test.ts b/src/client/ack.test.ts new file mode 100644 index 0000000..123ce92 --- /dev/null +++ b/src/client/ack.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { ACK_EVERY, ACK_IDLE_MS, OutputAck } from './ack'; + +function setup() { + const sent: { type: string; chars: number }[] = []; + const timers: { fn: () => void; ms: number }[] = []; + const ack = new OutputAck((m) => sent.push(JSON.parse(m)), { + set: (fn, ms) => { + const t = { fn, ms }; + timers.push(t); + return t; + }, + clear: (h) => { + const i = timers.indexOf(h as { fn: () => void; ms: number }); + if (i >= 0) timers.splice(i, 1); + }, + }); + const fire = () => { + for (const t of timers.splice(0)) t.fn(); + }; + return { ack, sent, timers, fire }; +} + +describe('OutputAck', () => { + test('acknowledges every ACK_EVERY characters with the running total', () => { + const { ack, sent } = setup(); + for (let i = 0; i < 4; i++) ack.parsed(ACK_EVERY / 2); + expect(sent).toEqual([ + { type: 'ack', chars: ACK_EVERY }, + { type: 'ack', chars: 2 * ACK_EVERY }, + ]); + }); + + test('a large message is acknowledged at once', () => { + const { ack, sent } = setup(); + ack.parsed(3 * ACK_EVERY + 5); + expect(sent).toEqual([{ type: 'ack', chars: 3 * ACK_EVERY + 5 }]); + }); + + test('acknowledges a smaller count after output stops', () => { + const { ack, sent, timers, fire } = setup(); + ack.parsed(10); + ack.parsed(20); + expect(sent).toEqual([]); + expect(timers.length).toBe(1); + expect(timers[0].ms).toBe(ACK_IDLE_MS); + fire(); + expect(sent).toEqual([{ type: 'ack', chars: 30 }]); + }); + + test('an acknowledgement by count cancels the idle one', () => { + const { ack, sent, timers } = setup(); + ack.parsed(10); + ack.parsed(ACK_EVERY); + expect(sent).toEqual([{ type: 'ack', chars: ACK_EVERY + 10 }]); + expect(timers.length).toBe(0); + }); + + test('sends nothing for empty output', () => { + const { ack, sent, timers } = setup(); + ack.parsed(0); + expect(timers.length).toBe(0); + expect(sent).toEqual([]); + }); + + test('dispose stops a pending idle acknowledgement', () => { + const { ack, sent, timers } = setup(); + ack.parsed(10); + ack.dispose(); + expect(timers.length).toBe(0); + expect(sent).toEqual([]); + }); +}); diff --git a/src/client/ack.ts b/src/client/ack.ts new file mode 100644 index 0000000..6754117 --- /dev/null +++ b/src/client/ack.ts @@ -0,0 +1,58 @@ +/** Characters of parsed output between acknowledgements (ADR 048). */ +export const ACK_EVERY = 64 * 1024; +/** How long after the last output a smaller count is acknowledged, in ms. */ +export const ACK_IDLE_MS = 100; + +interface Timers { + set(fn: () => void, ms: number): unknown; + clear(handle: unknown): void; +} + +const windowTimers: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType), +}; + +/** + * Acknowledges parsed PTY output so the server can pace it (ADR 048). Call + * {@link OutputAck.parsed} after each output message has been written to the + * terminal; it sends `{"type":"ack","chars":}`, the characters parsed since + * the connection opened, every {@link ACK_EVERY} characters and + * {@link ACK_IDLE_MS} after output stops. One instance per connection. + */ +export class OutputAck { + private total = 0; + private reported = 0; + private timer: unknown = null; + + constructor( + private send: (message: string) => void, + private timers: Timers = windowTimers, + ) {} + + /** Records that `chars` more characters of output have been parsed. */ + parsed(chars: number): void { + this.total += chars; + if (this.total - this.reported >= ACK_EVERY) { + this.flush(); + } else if (this.timer === null && this.total > this.reported) { + this.timer = this.timers.set(() => { + this.timer = null; + this.flush(); + }, ACK_IDLE_MS); + } + } + + /** Stops the idle timer, for a connection that has closed. */ + dispose(): void { + if (this.timer !== null) this.timers.clear(this.timer); + this.timer = null; + } + + private flush(): void { + this.dispose(); + if (this.total === this.reported) return; + this.reported = this.total; + this.send(JSON.stringify({ type: 'ack', chars: this.total })); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 81873e2..a67114a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,3 +1,4 @@ +import { OutputAck } from './ack'; import { Composer } from './compose'; import { EdgeExtender } from './edge'; import { computeInsets } from './fit'; @@ -199,8 +200,14 @@ const graphics = new KittyGraphics(term, container, (data: string) => { }); function connect(): void { - const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}/pty?cols=${term.cols}&rows=${term.rows}`; - ws = new WebSocket(wsUrl); + // flow=1: this client acknowledges output as it parses it, so the server paces it + // (ADR 048). + const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}/pty?cols=${term.cols}&rows=${term.rows}&flow=1`; + const socket = new WebSocket(wsUrl); + ws = socket; + const ack = new OutputAck((message) => { + if (socket.readyState === WebSocket.OPEN) socket.send(message); + }); const DIM = '\x1b[2m', YELLOW = '\x1b[1;33m', @@ -215,11 +222,23 @@ function connect(): void { }; ws.onmessage = (event: MessageEvent) => { + // term.write() parses synchronously, so the output has been parsed once feed() + // returns: acknowledging it now lets a slow tab slow the server down. graphics.feed(event.data); + ack.parsed(event.data.length); smoothScroll.onOutput(); }; ws.onclose = (event: CloseEvent) => { + ack.dispose(); + if (event.code === 4003) { + // The server dropped this tab for falling too far behind the output. Start + // again from the replay, as a new tab would (ADR 048). + term.reset(); + term.write(msg('Tab fell behind; replaying recent output.')); + connect(); + return; + } if (event.code === 4001) { term.write(msg('Session removed.')); setTimeout(() => window.close(), 500); From 01eda13da0b6090dbe46047584bea247c4823d56 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 20:48:02 -0400 Subject: [PATCH 048/295] docs(webtty): record ADR 048's measurements --- docs/adrs/048.webtty.output-flow-control.md | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md index 1d6d67e..1232699 100644 --- a/docs/adrs/048.webtty.output-flow-control.md +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -44,10 +44,38 @@ Frames stay text frames. The URL parameter replaces the brief's "treat a client ## Consequences - Output reaches every acknowledging client in order, without gaps or duplicates, at any speed, and the server's send buffer per client stays under about 2 Mi characters plus one PTY chunk. -- A hidden tab that stops processing holds its window and its queue. Under node-pty the app is paced by the slowest attached tab, as with tmux or a real terminal on a slow link. Under Bun, a tab that falls 16 Mi characters behind is reset and replayed; what scrolled past in that time is not in its history. +- A hidden tab that stops processing holds its window and its queue. Under node-pty the app is paced by the slowest attached tab, as with tmux or a real terminal on a slow link. Under Bun, a tab that falls 16 Mi characters behind is reset and replayed; what scrolled past in that time is not in its history. That includes an active tab during a heavy flood, because Chrome parses heavy truecolor about half as fast as Bun reads the PTY (Measurements). Pausing under Bun is future work: it needs a pause on `Bun.Terminal`, or another PTY backend under Bun (`@lydell/node-pty` delivered no output under Bun 1.3.11 in a probe). Sending XOFF when the PTY has `IXON` set was not tried, since an app that turns `IXON` off would read it as Ctrl+S. - One small upstream JSON message per 64 Ki characters of output. - The `ws` shim stays in use under Bun. Its bugs are no longer reachable for acknowledging clients; a legacy client can still hit them between 16 MB of native backlog and the 8 MB `bufferedAmount` check (the shim only counts what it queued), until it reloads. +## Measurements + +Taken on macOS with Bun 1.3.11 and Node 24.14 on the built server. Stream checks compare everything a client received with the expected output (`\n` as `\r\n`), and flag any line number that goes backwards or skips, and any SGR fragment outside an escape sequence. + +| Run | Backend | Client | Result | +|---|---|---|---| +| `cat` 47.5 M characters (50 MB, truecolor, box drawing) | Bun | fast | byte-identical, 3.5 s | +| same | Bun | 0.5 ms per message; 2 s stall | in order up to `4003` at 2.2 M and 3.1 M characters (queue over 16 Mi; the PTY cannot pause) | +| same | Node | fast + 0.5 ms + 2 s stall together | fast and 0.5 ms byte-identical; the stalled client `4003` (the fast one keeps the PTY running) | +| same | Node | 2 s stall alone | byte-identical, 6.9 s: the PTY was paused | +| same | Node | 0.5 ms per message alone | byte-identical, 27.3 s instead of 3.5 s: `cat` paced to the client | +| same, before this ADR | Bun | 0.5 ms per message | 22 backward jumps, 2 gaps, 144 extra lines | +| `gen.py heavy 30000 120` (115 M characters) | Bun | fast + 0.5 ms + 2 s stall together | all three identical to each other, 30,000 lines in order, no gaps | + +Peak server RSS for the fast 50 MB `cat` under Bun: 186–207 MB, against 260 MB before. + +Headless Chrome 155 (1400×900, 175×54), 30,000 heavy truecolor lines: the tab's history ends at line 30,000 with no break, no literal escape text and no `ESC` in any row, in every run. History keeps 3,850–3,960 lines, as the 32 MiB ceiling of ADR 046 binds. + +| Backend | Tab | Stream | Time | +|---|---|---|---| +| Node | active | all 115 M characters, no close | 13.5 s | +| Node | stalls 2 s mid-run | all 115 M characters, no close | 14.5 s | +| Bun | active | in order; three `4003` resets (the tab parses about half as fast as Bun reads the PTY) | 5.3 s | +| Bun | stalls 2 s mid-run | in order; two `4003` resets | 4.4 s | +| Bun | build from before this ADR (legacy client) | in order; closed once, reconnected after 2 s, history has one seam at the replay | 5.2 s | + +Typing latency when idle (keystroke to echo, 30 keys, three runs each, interleaved): p50 0.3 / 1.1 / 0.9 ms with this ADR, 0.3 / 1.0 / 1.0 ms before. + ## Considered Options - **Native `Bun.serve` WebSockets with our own queue.** Fixes the shim at its source and exposes `getBufferedAmount()`, but replaces the `node:http` + `ws` server that also has to run on Node (Windows, `server:node`), so it means two WebSocket server paths. It still needs a bound on per-client memory, which takes acknowledgements or a buffer cap anyway. From c3bb34e96a2431694878ffb8e04740981d73c8fc Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:10:00 -0400 Subject: [PATCH 049/295] fix(server): pace the scrollback replay in chunk-sized pieces A replay was sent as one message, so a large scrollback landed in the send buffer at once instead of being paced by the client's acknowledgements. Split it into pieces of the flow chunk size, never cutting a surrogate pair. What the replay queues is allowed on top of maxPending, so a scrollback over that limit does not close every tab as it connects. A legacy client still gets the replay whole. --- docs/adrs/048.webtty.output-flow-control.md | 2 +- docs/specs/webtty.md | 2 +- src/server/flow.test.ts | 49 ++++++++++++++++++++- src/server/flow.ts | 40 +++++++++++++++-- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/docs/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md index 1232699..20584d7 100644 --- a/docs/adrs/048.webtty.output-flow-control.md +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -34,7 +34,7 @@ The server paces output to each client by acknowledgements from that client, and - Per client: characters sent, characters acknowledged, and a queue of pending output. - Sends output straight through while unacknowledged characters are under the high-water mark (2 Mi characters, at most 6 MB of UTF-8, far under the shim's 16 MB). At the mark the client is *blocked*: new output is queued in order. - When acks bring the client down to the low-water mark (512 Ki characters), it is unblocked and the queue is sent in order, coalesced into messages of up to 64 Ki characters, until the client blocks again. Whole PTY chunks are joined and never split, so a surrogate pair is never cut. -- Scrollback replay and the banner go through the same path, so they count against the window. +- Scrollback replay and the banner go through the same path, split into pieces of 64 Ki characters (never cutting a surrogate pair), so they count against the window and a large `scrollback` is paced like live output instead of landing in the send buffer at once. What the replay queues is allowed on top of the 16 Mi limit below, since it is already held as the scrollback and a `scrollback` over the limit would otherwise close every tab as it connects. A legacy client gets the replay as one message, as before. - When every client of a session is blocked and the PTY has `pause()` (node-pty), the PTY is paused and the app blocks on its own writes, as in a real terminal; it resumes as soon as any client is unblocked or leaves. `Bun.Terminal` has no pause or resume (checked on Bun 1.3.11), so under Bun the PTY keeps running while clients are blocked. - A queue over 16 Mi characters closes that client with `4003` (*fell behind*). Under Bun this is what bounds memory. The client resets its terminal, says so in one status line, and reconnects at once, getting the scrollback replay like a new tab. - A client that connects without `?flow=1` is *legacy* (a tab loaded before an upgrade): it is sent output straight through as before, and closed with `4003` once `bufferedAmount` passes 8 MB. The old client reconnects on any code other than `4001` and `1001`, so it recovers through the replay. The client is served by the same server, so a mismatch lasts only until that tab reloads. diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 23d2692..1f0d86c 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -58,7 +58,7 @@ PTY output is paced per client by acknowledgements (ADR 048). A client that conn | Client closed with `4003` (fell behind; it reconnects and gets the replay) | queue over 16 Mi characters | | Legacy client (no `?flow=1`) | sent straight through; closed with `4003` once `bufferedAmount` passes 8 MB | -Scrollback replay and the banner count against the same window. Frames are text frames. +Scrollback replay and the banner count against the same window, sent in pieces of up to 64 Ki characters; what the replay queues is allowed on top of the 16 Mi limit. Frames are text frames. Session IDs appear directly in the URL path (`/s/:id`), so they must be valid URL path segments: diff --git a/src/server/flow.test.ts b/src/server/flow.test.ts index a6132a2..f4e2e01 100644 --- a/src/server/flow.test.ts +++ b/src/server/flow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { FELL_BEHIND, type FlowLimits, type FlowSocket, OutputPacer } from './flow'; +import { FELL_BEHIND, type FlowLimits, type FlowSocket, OutputPacer, splitChunks } from './flow'; const LIMITS: FlowLimits = { highWater: 100, @@ -209,6 +209,53 @@ describe('OutputPacer', () => { expect(fast.closed).toBeNull(); }); + test('paces a replay in chunk-sized pieces and counts it against the window', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + const replay = 'r'.repeat(2500); + pacer.send(ws, replay); + // Pieces of 30 go out until 100 are unacknowledged; the rest is queued. + expect(ws.messages.map((m) => m.length)).toEqual([30, 30, 30, 30]); + // Over maxPending (1000), but a replay is allowed on top of it. + expect(ws.closed).toBeNull(); + pacer.broadcast('live'); + let acked = 0; + while (ws.received.length > acked) { + acked = ws.received.length; + pacer.ack(ws, acked); + } + expect(ws.received).toBe(`${replay}live`); + expect(Math.max(...ws.messages.map((m) => m.length))).toBeLessThanOrEqual(30); + }); + + test('a replay still leaves maxPending for live output', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.send(ws, 'r'.repeat(2500)); + for (let i = 0; i < 99; i++) pacer.broadcast('x'.repeat(10)); + expect(ws.closed).toBeNull(); + pacer.broadcast('x'.repeat(20)); + expect(ws.closed?.code).toBe(FELL_BEHIND); + }); + + test('a legacy client gets a replay whole', () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, false); + pacer.send(ws, 'r'.repeat(2500)); + expect(ws.messages).toEqual(['r'.repeat(2500)]); + }); + + test('splitChunks never cuts a surrogate pair', () => { + const data = `a${'😀'.repeat(5)}`; + const pieces = splitChunks(data, 4); + expect(pieces).toEqual(['a😀', '😀😀', '😀😀']); + expect(splitChunks('😀', 1)).toEqual(['\ud83d', '\ude00']); + expect(splitChunks('', 4)).toEqual([]); + }); + test('skips a client that is no longer open', () => { const { pacer } = setup(); const ws = new FakeSocket(); diff --git a/src/server/flow.ts b/src/server/flow.ts index e7ed25d..df78ce9 100644 --- a/src/server/flow.ts +++ b/src/server/flow.ts @@ -52,6 +52,21 @@ interface ClientFlow { blocked: boolean; pending: string[]; pendingLength: number; + /** Queued characters past which the client is closed: the limit plus what {@link OutputPacer.send} queued. */ + maxPending: number; +} + +/** Splits `data` into pieces of at most `size` characters without cutting a surrogate pair. */ +export function splitChunks(data: string, size: number): string[] { + const pieces: string[] = []; + for (let i = 0; i < data.length; ) { + let end = Math.min(i + size, data.length); + const last = data.charCodeAt(end - 1); + if (end < data.length && end - i > 1 && last >= 0xd800 && last <= 0xdbff) end--; + pieces.push(data.slice(i, end)); + i = end; + } + return pieces; } /** Paces one session's output to its clients. */ @@ -82,6 +97,7 @@ export class OutputPacer { blocked: false, pending: [], pendingLength: 0, + maxPending: this.limits.maxPending, }); this.updatePause(); } @@ -92,10 +108,25 @@ export class OutputPacer { this.updatePause(); } - /** Sends `data` to one client, in order after anything queued for it. */ + /** + * Sends `data` (the banner or the scrollback replay) to one client, in order after + * anything queued for it. An acknowledging client gets it in pieces of + * {@link FlowLimits.chunk}, so a large replay is paced like live output instead of + * filling the send buffer at once. What it queues is allowed on top of + * {@link FlowLimits.maxPending}: the replay is already held as the scrollback, and a + * scrollback over the limit would otherwise close every client that connects. A legacy + * client gets it whole as before: Bun's shim accepts one large message, but drops and + * reorders pieces sent past its 16 MB backlog. + */ send(ws: FlowSocket, data: string): void { const flow = this.flows.get(ws); - if (flow) this.write(ws, flow, data); + if (flow?.acks) { + const queued = flow.pendingLength; + for (const piece of splitChunks(data, this.limits.chunk)) this.write(ws, flow, piece, false); + flow.maxPending += flow.pendingLength - queued; + } else if (flow) { + this.write(ws, flow, data); + } this.updatePause(); } @@ -117,7 +148,8 @@ export class OutputPacer { this.updatePause(); } - private write(ws: FlowSocket, flow: ClientFlow, data: string): void { + /** Sends or queues `data`; `bounded` is false for a queue allowed past `maxPending`. */ + private write(ws: FlowSocket, flow: ClientFlow, data: string, bounded = true): void { if (ws.readyState !== ws.OPEN) return; if (!flow.acks) { ws.send(data); @@ -130,7 +162,7 @@ export class OutputPacer { } flow.pending.push(data); flow.pendingLength += data.length; - if (flow.pendingLength > this.limits.maxPending) this.fellBehind(ws); + if (bounded && flow.pendingLength > flow.maxPending) this.fellBehind(ws); } private transmit(ws: FlowSocket, flow: ClientFlow, data: string): void { From 26f3c0bfb9000d01debc354f4fb0a3e7e749e851 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:15:50 -0400 Subject: [PATCH 050/295] fix(server): send queued output before closing on shell exit When the shell exited, clients were closed with 4001 at once, so output still queued for a client behind the window was dropped, and with it the last lines the shell wrote. The pacer's end() now sends each client its queue as acknowledgements allow and closes it once the queue is empty, or when it goes 2 s without an acknowledgement. The server stops after the last session only once its clients are closed. --- docs/adrs/048.webtty.output-flow-control.md | 1 + docs/specs/webtty.md | 2 +- src/server/flow.test.ts | 75 +++++++++++++++++++++ src/server/flow.ts | 62 ++++++++++++++++- src/server/websocket.test.ts | 30 +++++++++ src/server/websocket.ts | 11 ++- 6 files changed, 173 insertions(+), 8 deletions(-) diff --git a/docs/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md index 20584d7..093865e 100644 --- a/docs/adrs/048.webtty.output-flow-control.md +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -36,6 +36,7 @@ The server paces output to each client by acknowledgements from that client, and - When acks bring the client down to the low-water mark (512 Ki characters), it is unblocked and the queue is sent in order, coalesced into messages of up to 64 Ki characters, until the client blocks again. Whole PTY chunks are joined and never split, so a surrogate pair is never cut. - Scrollback replay and the banner go through the same path, split into pieces of 64 Ki characters (never cutting a surrogate pair), so they count against the window and a large `scrollback` is paced like live output instead of landing in the send buffer at once. What the replay queues is allowed on top of the 16 Mi limit below, since it is already held as the scrollback and a `scrollback` over the limit would otherwise close every tab as it connects. A legacy client gets the replay as one message, as before. - When every client of a session is blocked and the PTY has `pause()` (node-pty), the PTY is paused and the app blocks on its own writes, as in a real terminal; it resumes as soon as any client is unblocked or leaves. `Bun.Terminal` has no pause or resume (checked on Bun 1.3.11), so under Bun the PTY keeps running while clients are blocked. +- When the shell exits, a client with output still queued is sent it as its acknowledgements allow and closed with `4001` once the queue is empty, or as soon as it goes 2 s without an acknowledgement, so the last lines the shell wrote reach the tab. The server stops after the last session only once its clients are closed. - A queue over 16 Mi characters closes that client with `4003` (*fell behind*). Under Bun this is what bounds memory. The client resets its terminal, says so in one status line, and reconnects at once, getting the scrollback replay like a new tab. - A client that connects without `?flow=1` is *legacy* (a tab loaded before an upgrade): it is sent output straight through as before, and closed with `4003` once `bufferedAmount` passes 8 MB. The old client reconnects on any code other than `4001` and `1001`, so it recovers through the replay. The client is served by the same server, so a mismatch lasts only until that tab reloads. diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 1f0d86c..2e3777a 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -58,7 +58,7 @@ PTY output is paced per client by acknowledgements (ADR 048). A client that conn | Client closed with `4003` (fell behind; it reconnects and gets the replay) | queue over 16 Mi characters | | Legacy client (no `?flow=1`) | sent straight through; closed with `4003` once `bufferedAmount` passes 8 MB | -Scrollback replay and the banner count against the same window, sent in pieces of up to 64 Ki characters; what the replay queues is allowed on top of the 16 Mi limit. Frames are text frames. +Scrollback replay and the banner count against the same window, sent in pieces of up to 64 Ki characters; what the replay queues is allowed on top of the 16 Mi limit. When the shell exits, each client is sent what is still queued for it, as its acknowledgements allow, before it is closed with `4001`; one that sends no acknowledgement for 2 s is closed at once. Frames are text frames. Session IDs appear directly in the URL path (`/s/:id`), so they must be valid URL path segments: diff --git a/src/server/flow.test.ts b/src/server/flow.test.ts index f4e2e01..71fc0cd 100644 --- a/src/server/flow.test.ts +++ b/src/server/flow.test.ts @@ -256,6 +256,81 @@ describe('OutputPacer', () => { expect(splitChunks('', 4)).toEqual([]); }); + test('end closes at once a client with nothing queued', async () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + const legacy = new FakeSocket(); + pacer.add(ws, true); + pacer.add(legacy, false); + pacer.broadcast('x'.repeat(50)); + await pacer.end(4001, 'shell exited'); + expect(ws.closed).toEqual({ code: 4001, reason: 'shell exited' }); + expect(legacy.closed?.code).toBe(4001); + }); + + test('end sends the queue as acks allow, then closes', async () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + for (let i = 0; i < 25; i++) pacer.broadcast('b'.repeat(10)); + pacer.broadcast('END'); + let ended = false; + const done = pacer.end(4001, 'shell exited', 60_000).then(() => { + ended = true; + }); + expect(ws.closed).toBeNull(); + pacer.ack(ws, 100); + await Promise.resolve(); + expect(ended).toBe(false); + expect(ws.closed).toBeNull(); + // Still blocked after the first flush. + expect(ws.received.length).toBe(220); + while (!ws.closed) pacer.ack(ws, ws.received.length); + await done; + expect(ws.received).toBe(`${'a'.repeat(100)}${'b'.repeat(250)}END`); + expect(ws.closed?.code).toBe(4001); + }); + + test('end closes a client still behind after the timeout', async () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + pacer.broadcast('lost'); + await pacer.end(4001, 'shell exited', 10); + expect(ws.closed?.code).toBe(4001); + expect(ws.received).toBe('a'.repeat(100)); + }); + + test('end waits for a client that keeps acknowledging', async () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + for (let i = 0; i < 50; i++) pacer.broadcast('c'.repeat(10)); + const done = pacer.end(4001, 'shell exited', 30); + // Each ack comes within the timeout, though the flush takes longer than it. + while (!ws.closed) { + await Bun.sleep(15); + pacer.ack(ws, ws.received.length); + } + await done; + expect(ws.received.length).toBe(500); + expect(ws.messages.length).toBeGreaterThan(8); + }); + + test('end resolves when a client behind closes on its own', async () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.broadcast('a'.repeat(100)); + pacer.broadcast('b'); + const done = pacer.end(4001, 'shell exited', 60_000); + pacer.remove(ws); + await done; + expect(ws.closed).toBeNull(); + }); + test('skips a client that is no longer open', () => { const { pacer } = setup(); const ws = new FakeSocket(); diff --git a/src/server/flow.ts b/src/server/flow.ts index df78ce9..9d83c1b 100644 --- a/src/server/flow.ts +++ b/src/server/flow.ts @@ -21,6 +21,9 @@ export interface FlowLimits { legacyBuffered: number; } +/** How long {@link OutputPacer.end} waits for an acknowledgement before it closes anyway, in ms. */ +export const END_FLUSH_MS = 2000; + export const DEFAULT_FLOW_LIMITS: FlowLimits = { highWater: 2 * 1024 * 1024, lowWater: 512 * 1024, @@ -54,6 +57,8 @@ interface ClientFlow { pendingLength: number; /** Queued characters past which the client is closed: the limit plus what {@link OutputPacer.send} queued. */ maxPending: number; + /** Set by {@link OutputPacer.end}: the client is closed with `code` once its queue is sent. */ + ending: { code: number; reason: string; wait: () => void; done: () => void } | null; } /** Splits `data` into pieces of at most `size` characters without cutting a surrogate pair. */ @@ -98,16 +103,58 @@ export class OutputPacer { pending: [], pendingLength: 0, maxPending: this.limits.maxPending, + ending: null, }); this.updatePause(); } /** Stops pacing `ws` (closed) and drops its queue. */ remove(ws: FlowSocket): void { - if (!this.flows.delete(ws)) return; + const flow = this.flows.get(ws); + if (!flow) return; + this.flows.delete(ws); + flow.ending?.done(); this.updatePause(); } + /** + * Closes every client with `code` once the output queued for it has been sent, as + * its acknowledgements allow, so the tail of the output is not lost when the shell + * exits. A client that does not acknowledge for `ms` is closed anyway; the queue + * no longer grows, so a client that keeps acknowledging is closed within + * {@link FlowLimits.maxPending}. + * + * @returns A promise that resolves once every client is closed. + */ + end(code: number, reason: string, ms = END_FLUSH_MS): Promise { + const waits: Promise[] = []; + for (const [ws, flow] of this.flows) { + if (flow.pending.length === 0) { + this.close(ws, code, reason); + continue; + } + waits.push( + new Promise((resolve) => { + let timer: ReturnType | undefined; + flow.ending = { + code, + reason, + wait: () => { + clearTimeout(timer); + timer = setTimeout(() => this.close(ws, code, reason), ms); + }, + done: () => { + clearTimeout(timer); + resolve(); + }, + }; + flow.ending.wait(); + }), + ); + } + return Promise.all(waits).then(() => {}); + } + /** * Sends `data` (the banner or the scrollback replay) to one client, in order after * anything queued for it. An acknowledging client gets it in pieces of @@ -145,6 +192,11 @@ export class OutputPacer { flow.blocked = false; this.flush(ws, flow); } + if (flow.ending && flow.pending.length === 0) { + this.close(ws, flow.ending.code, flow.ending.reason); + return; + } + flow.ending?.wait(); this.updatePause(); } @@ -193,6 +245,14 @@ export class OutputPacer { ws.close(FELL_BEHIND, 'fell behind'); } + private close(ws: FlowSocket, code: number, reason: string): void { + const flow = this.flows.get(ws); + this.flows.delete(ws); + if (ws.readyState === ws.OPEN) ws.close(code, reason); + flow?.ending?.done(); + this.updatePause(); + } + private updatePause(): void { let allBlocked = this.flows.size > 0; for (const flow of this.flows.values()) { diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 086320b..feca333 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -230,6 +230,36 @@ describe('websocket', () => { 30000, ); + test.skipIf(process.platform === 'win32')( + 'a client behind the output is sent the rest before the shell-exit close', + async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'ws-test-exit-tail' }), + }); + const { ws, messages } = await connectWs( + `${wsBase}/ws/ws-test-exit-tail/pty?cols=80&rows=24&flow=1`, + ); + const received = () => messages.reduce((n, m) => n + m.length, 0); + const ack = () => ws.send(JSON.stringify({ type: 'ack', chars: received() })); + await waitForPrompt(messages); + ack(); + const closeCode = new Promise((resolve) => ws.on('close', (c) => resolve(c))); + + // More than the 2 Mi-character window, so the tail is still queued when the shell exits. + ws.send(`seq 1 400000; echo TAIL-$((40+2)); exit${NL}`); + await new Promise((r) => setTimeout(r, 1000)); + expect(messages.join('')).not.toContain('TAIL-42'); + ws.on('message', ack); + ack(); + + expect(await closeCode).toBe(4001); + expect(messages.join('')).toContain('TAIL-42'); + }, + 15000, + ); + test('session is removed and tab closed when shell exits', async () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST', diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 1d77810..0c5b5cf 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -212,11 +212,6 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer session.pty.onExit(() => { sessionRegistry.delete(session.id); - for (const client of session.clients) { - if (client.readyState === client.OPEN) { - client.close(WS_CLOSE.SESSION_GONE, 'shell exited'); - } - } for (const sub of session.subscribers) { if (sub.readyState === sub.OPEN) { sub.close(WS_CLOSE.SESSION_GONE, 'shell exited'); @@ -224,7 +219,11 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer } session.subscribers.clear(); session.pty = null; - if (sessionRegistry.size === 0) onLastSessionClosed?.(); + // A client behind the output is sent what is queued for it before it is closed, + // so the last lines the shell wrote are not lost (ADR 048). + session.output.end(WS_CLOSE.SESSION_GONE, 'shell exited').then(() => { + if (sessionRegistry.size === 0) onLastSessionClosed?.(); + }); }); const banner = sessionBanner(); From e7c85e4561ea2752efd3bdec81577fa238e35f38 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:21:41 -0400 Subject: [PATCH 051/295] test(server): accept the tty's doubled CR in the flow test Under load the PTY sometimes writes \r\r\n for a newline, which made the high-water test count one number short now and then, on the commit that added it too. Split lines on any run of CRs before a newline. --- src/server/websocket.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index feca333..3d1a051 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -219,9 +219,11 @@ describe('websocket', () => { } await closeWs(ws); + // Under load the PTY sometimes writes `\r\r\n` for a newline (seen on macOS); + // the extra `\r` comes from the tty, not from the server. const numbers = messages .join('') - .split('\r\n') + .split(/\r+\n/) .filter((l) => /^\d+$/.test(l)) .map(Number); expect(numbers.length).toBe(N); From 0b5a81c16440de03045c3e0e266ab646b6d3b992 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:23:09 -0400 Subject: [PATCH 052/295] docs(webtty): a slow legacy client sees 1006 under Bun, not 4003 The server closes a legacy client with 4003 past 8 MB of bufferedAmount, but under Bun the shim's bufferedAmount grows only once native has 16 MB queued, and the close frame cannot get past that backlog: the tab sees 1006 a few seconds later. Under Node it gets the 4003. Either way the old client reconnects to the replay. Say so in ADR 048, the spec and the limit's comment. --- docs/adrs/048.webtty.output-flow-control.md | 4 ++-- docs/specs/webtty.md | 2 +- src/server/flow.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md index 093865e..f2cec03 100644 --- a/docs/adrs/048.webtty.output-flow-control.md +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -38,7 +38,7 @@ The server paces output to each client by acknowledgements from that client, and - When every client of a session is blocked and the PTY has `pause()` (node-pty), the PTY is paused and the app blocks on its own writes, as in a real terminal; it resumes as soon as any client is unblocked or leaves. `Bun.Terminal` has no pause or resume (checked on Bun 1.3.11), so under Bun the PTY keeps running while clients are blocked. - When the shell exits, a client with output still queued is sent it as its acknowledgements allow and closed with `4001` once the queue is empty, or as soon as it goes 2 s without an acknowledgement, so the last lines the shell wrote reach the tab. The server stops after the last session only once its clients are closed. - A queue over 16 Mi characters closes that client with `4003` (*fell behind*). Under Bun this is what bounds memory. The client resets its terminal, says so in one status line, and reconnects at once, getting the scrollback replay like a new tab. -- A client that connects without `?flow=1` is *legacy* (a tab loaded before an upgrade): it is sent output straight through as before, and closed with `4003` once `bufferedAmount` passes 8 MB. The old client reconnects on any code other than `4001` and `1001`, so it recovers through the replay. The client is served by the same server, so a mismatch lasts only until that tab reloads. +- A client that connects without `?flow=1` is *legacy* (a tab loaded before an upgrade): it is sent output straight through as before, and closed with `4003` once `bufferedAmount` passes 8 MB. Under Node the tab gets the `4003`. Under Bun it does not: the shim's `bufferedAmount` grows only once native has 16 MB queued, and the close frame cannot get past that backlog, so the connection ends and the tab sees `1006` a few seconds later (measured: closed at 30.7 M characters sent, `1006` 2.4 s later). A `4003` cannot be sent reliably there, since the native backlog is not visible before it is too late. The old client reconnects on any code other than `4001` and `1001`, so it recovers through the replay either way. The client is served by the same server, so a mismatch lasts only until that tab reloads. Frames stay text frames. The URL parameter replaces the brief's "treat a client that has never acked as legacy": the server knows the protocol at connect, before any output, instead of guessing from silence. @@ -73,7 +73,7 @@ Headless Chrome 155 (1400×900, 175×54), 30,000 heavy truecolor lines: the tab' | Node | stalls 2 s mid-run | all 115 M characters, no close | 14.5 s | | Bun | active | in order; three `4003` resets (the tab parses about half as fast as Bun reads the PTY) | 5.3 s | | Bun | stalls 2 s mid-run | in order; two `4003` resets | 4.4 s | -| Bun | build from before this ADR (legacy client) | in order; closed once, reconnected after 2 s, history has one seam at the replay | 5.2 s | +| Bun | build from before this ADR (legacy client) | in order; closed once (`1006`), reconnected after 2 s, history has one seam at the replay | 5.2 s | Typing latency when idle (keystroke to echo, 30 keys, three runs each, interleaved): p50 0.3 / 1.1 / 0.9 ms with this ADR, 0.3 / 1.0 / 1.0 ms before. diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 2e3777a..92c3cfc 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -56,7 +56,7 @@ PTY output is paced per client by acknowledgements (ADR 048). A client that conn | Client unblocked (queue sent, coalesced into ≤ 64 Ki-character messages) | 512 Ki characters unacknowledged | | PTY paused | while every client is blocked, if the PTY can pause (node-pty; `Bun.Terminal` cannot) | | Client closed with `4003` (fell behind; it reconnects and gets the replay) | queue over 16 Mi characters | -| Legacy client (no `?flow=1`) | sent straight through; closed with `4003` once `bufferedAmount` passes 8 MB | +| Legacy client (no `?flow=1`) | sent straight through; closed with `4003` once `bufferedAmount` passes 8 MB (under Bun the close frame is stuck behind the shim's 16 MB backlog and the tab sees `1006`; either way it reconnects to the replay) | Scrollback replay and the banner count against the same window, sent in pieces of up to 64 Ki characters; what the replay queues is allowed on top of the 16 Mi limit. When the shell exits, each client is sent what is still queued for it, as its acknowledgements allow, before it is closed with `4001`; one that sends no acknowledgement for 2 s is closed at once. Frames are text frames. diff --git a/src/server/flow.ts b/src/server/flow.ts index 9d83c1b..e432815 100644 --- a/src/server/flow.ts +++ b/src/server/flow.ts @@ -17,7 +17,10 @@ export interface FlowLimits { chunk: number; /** Queued characters past which the client is closed with {@link FELL_BEHIND}. */ maxPending: number; - /** `bufferedAmount` (bytes) past which a legacy client is closed with {@link FELL_BEHIND}. */ + /** + * `bufferedAmount` (bytes) past which a legacy client is closed with {@link FELL_BEHIND}. + * Under Bun the close frame is stuck behind the native backlog and the client sees 1006. + */ legacyBuffered: number; } From 40499111dce46cde96d22fbbd7997eb1bdb1d3c8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:23:24 -0400 Subject: [PATCH 053/295] docs(webtty): note that input shaped like an ack is consumed An input frame that is exactly an ack's JSON is taken as an ack and never reaches the PTY, as with the resize message. Record it in ADR 048, and why the framing change it would take to tell them apart is not worth it. --- docs/adrs/048.webtty.output-flow-control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adrs/048.webtty.output-flow-control.md b/docs/adrs/048.webtty.output-flow-control.md index f2cec03..93c8574 100644 --- a/docs/adrs/048.webtty.output-flow-control.md +++ b/docs/adrs/048.webtty.output-flow-control.md @@ -46,7 +46,7 @@ Frames stay text frames. The URL parameter replaces the brief's "treat a client - Output reaches every acknowledging client in order, without gaps or duplicates, at any speed, and the server's send buffer per client stays under about 2 Mi characters plus one PTY chunk. - A hidden tab that stops processing holds its window and its queue. Under node-pty the app is paced by the slowest attached tab, as with tmux or a real terminal on a slow link. Under Bun, a tab that falls 16 Mi characters behind is reset and replayed; what scrolled past in that time is not in its history. That includes an active tab during a heavy flood, because Chrome parses heavy truecolor about half as fast as Bun reads the PTY (Measurements). Pausing under Bun is future work: it needs a pause on `Bun.Terminal`, or another PTY backend under Bun (`@lydell/node-pty` delivered no output under Bun 1.3.11 in a probe). Sending XOFF when the PTY has `IXON` set was not tried, since an app that turns `IXON` off would read it as Ctrl+S. -- One small upstream JSON message per 64 Ki characters of output. +- One small upstream JSON message per 64 Ki characters of output. Acks share the input channel, as the `resize` message does: an input frame that is exactly an ack's JSON (a paste of `{"type":"ack","chars":1}`, say) is taken as an ack and never reaches the PTY. Telling them apart for certain would need a framing change for all input (a prefix byte or binary frames), which is not worth it for input that already collides with `resize` the same way. - The `ws` shim stays in use under Bun. Its bugs are no longer reachable for acknowledging clients; a legacy client can still hit them between 16 MB of native backlog and the 8 MB `bufferedAmount` check (the shim only counts what it queued), until it reloads. ## Measurements From 87342c416269cbf0a3409344bea430cfc28ed4ee Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:29:12 -0400 Subject: [PATCH 054/295] fix(client): keep the kitty overlay at 0x0 while it has no image A new canvas is 300x150, so the tick's nothing-to-draw check never passed: the overlay grew to the terminal's size (19.2 MiB at 1400x900, DPR 2) and read layout every frame with no image placed. Start it at 0x0 and shrink it back when the last placement goes. --- src/client/graphics.test.ts | 65 ++++++++++++++++++++++++++++++++++--- src/client/graphics.ts | 12 +++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/client/graphics.test.ts b/src/client/graphics.test.ts index 8ec42e0..131f07c 100644 --- a/src/client/graphics.test.ts +++ b/src/client/graphics.test.ts @@ -5,7 +5,7 @@ import { deviceCellSize } from './kitty'; // KittyGraphics needs a DOM, a canvas and image decoding. These tests stub just // enough of them to drive its state handling: what is placed, what is answered, -// and what is freed. Drawing is not exercised. +// and what is freed. Drawing is exercised only as far as the overlay's size. const ESC = '\x1b'; const apc = (keys: string, payload = ''): string => `${ESC}_G${keys};${payload}${ESC}\\`; @@ -32,6 +32,9 @@ interface FakeBitmap { let bitmaps: FakeBitmap[] = []; let listeners = new Map void>(); let visibility = 'visible'; +// The overlay canvas the last KittyGraphics created, and its frame callback. +let overlay: { width: number; height: number } | null = null; +let frame: (() => void) | null = null; let gate: Promise | null = null; // Gates for the next decodes, in the order they start: lets a test finish them in any order. let gates: Array> = []; @@ -80,10 +83,19 @@ beforeEach(() => { get visibilityState() { return visibility; }, - createElement: () => ({ dataset: {}, style: {}, width: 0, height: 0, getContext: () => null }), + // A new canvas is 300x150, as in a browser + createElement: () => { + const ctx = { setTransform() {}, clearRect() {}, drawImage() {} }; + overlay = { dataset: {}, style: {}, width: 300, height: 150, getContext: () => ctx } as never; + return overlay; + }, }; g.window = { devicePixelRatio: 1 }; - g.requestAnimationFrame = () => 0; + frame = null; + g.requestAnimationFrame = (cb: () => void) => { + frame = cb; + return 0; + }; g.ImageData = class { constructor( readonly data: Uint8ClampedArray, @@ -125,6 +137,7 @@ interface Harness { function harness( limits: Partial = {}, cell: { width: number; height: number } = { width: 10, height: 20 }, + canvas: object | null = null, ): Harness { const written: string[] = []; const sent: string[] = []; @@ -147,7 +160,7 @@ function harness( wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0 }, renderer: { getMetrics: () => cell }, }; - const container = { appendChild: () => {}, querySelector: () => null }; + const container = { appendChild: () => {}, querySelector: () => canvas }; const g = new KittyGraphics(term as never, container as never, (d) => sent.push(d), limits); const internals = g as unknown as { placements: Map; @@ -722,6 +735,50 @@ describe('overlay repaint when the tab is shown again', () => { }); }); +describe('overlay backing store', () => { + // The terminal's canvas, counting the layout reads the tick makes of it. + function terminalCanvas(): { reads: number } { + const box = { reads: 0, offsetLeft: 0, offsetTop: 0 }; + for (const [key, value] of [ + ['offsetWidth', 800], + ['offsetHeight', 600], + ] as const) { + Object.defineProperty(box, key, { + get: () => { + box.reads++; + return value; + }, + }); + } + return box; + } + + test('a new overlay is empty and reads no layout while there is no image', () => { + const canvas = terminalCanvas(); + harness({}, undefined, canvas); + expect(overlay).toMatchObject({ width: 0, height: 0 }); + frame?.(); + frame?.(); + expect(canvas.reads).toBe(0); + expect(overlay).toMatchObject({ width: 0, height: 0 }); + }); + + test('the overlay grows for an image and shrinks back to empty when the last one goes', async () => { + const canvas = terminalCanvas(); + const h = harness({}, undefined, canvas); + h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); + await flush(); + frame?.(); + expect(overlay).toMatchObject({ width: 800, height: 600 }); + h.g.feed(apc('a=d,d=I,i=1')); + frame?.(); + expect(overlay).toMatchObject({ width: 0, height: 0 }); + canvas.reads = 0; + frame?.(); + expect(canvas.reads).toBe(0); + }); +}); + describe('a header that only looks like a PNG', () => { test.each([ ['signature', (b: Uint8Array) => (b[0] = 0x00)], diff --git a/src/client/graphics.ts b/src/client/graphics.ts index 3d8003c..aa68f99 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -204,6 +204,11 @@ export class KittyGraphics { this.assembler = new ChunkAssembler(this.limits.maxTransmissionBytes); this.overlay = document.createElement('canvas'); this.overlay.dataset.kitty = ''; + // A new canvas is 300x150, which the tick reads as something to draw. At + // 0x0 the overlay holds no backing store and costs no layout read per + // frame until an image is placed. + this.overlay.width = 0; + this.overlay.height = 0; this.overlay.style.cssText = 'position:absolute;pointer-events:none;'; container.appendChild(this.overlay); requestAnimationFrame(this.tick); @@ -603,6 +608,13 @@ export class KittyGraphics { const w = canvas.offsetWidth; const h = canvas.offsetHeight; const o = this.overlay; + if (this.placements.size === 0) { + // Nothing left to draw: free the backing store, which at full size is + // the size of the terminal's canvas, and let the tick go idle. + o.width = 0; + o.height = 0; + return; + } if (o.width !== Math.round(w * dpr) || o.height !== Math.round(h * dpr)) { o.width = Math.round(w * dpr); o.height = Math.round(h * dpr); From 4f4c5b55390b3c2354f22f7efd35ccb0b92de4b7 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:36:41 -0400 Subject: [PATCH 055/295] fix(client): repaint long runs in chunks on a small scratch canvas The scratch canvas was sized for the longest run drawn on it, so the first clear or screenful of output grew it to the view (19.2 MiB at 1400x900, DPR 2) and it kept that size. Repaint a run in chunks of at most 8 rows, each with the line below it as before, so it stays 9 rows tall (2.9 MiB). A row's pixels depend only on its own line and the next, so every row is left as drawing the run at once leaves it. Record the numbers in ADR 043. --- .../043.client.scroll-copy-and-overscan.md | 6 +-- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/renderer.ts | 42 +++++++++++++++--- src/client/vendor/renderer.test.ts | 44 +++++++++++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/docs/adrs/043.client.scroll-copy-and-overscan.md b/docs/adrs/043.client.scroll-copy-and-overscan.md index 38f9201..d0cf6ed 100644 --- a/docs/adrs/043.client.scroll-copy-and-overscan.md +++ b/docs/adrs/043.client.scroll-copy-and-overscan.md @@ -47,7 +47,7 @@ Screen scrolling caused by output is not copied. The engines report which rows c The terminal's canvas element keeps its layout box and the mouse events, and draws nothing. The drawing canvas is inserted before it, absolutely positioned with no offsets, so it sits at the element's static position wherever fit's padding moves it. It ignores the mouse and is excluded from the terminal canvas lookups like the edge strips. Fit, the edge strips, Kitty graphics and the IME anchor still measure the element. Hit testing (`terminal.ts`, `selection-manager.ts`) subtracts `scrollOffset`, because viewport row 0 starts that far below the element's top. Kitty images are drawn that far lower too, so they move with the text. The edge strips copy the drawing canvas (`visibleArea`): the right strip the column of the part in view, the bottom strip the last pixel line of the last row in view, which at a fraction of a line is below the clip. The line the clip ends on runs through that row's glyphs, and stretched across the padding it streaks on every frame. An offset set for a taller cell, before a font change, is shown as at most a line, so the row above always covers the gap. Fit re-splits the scroll position after every DPR and font change, as under ADR 040, so the offset is snapped to the new device-pixel grid and cell height instead of waiting for the next wheel event. -Each canvas row has a state: invalid, *joined* (its own cells plus what the next line draws into it, as a full redraw leaves a row), or *cut* (its own cells only). Every frame, the rows in view are brought to the state a full redraw of the viewport leaves them in. The last row is cut, the others are joined, and at a fraction of a line the row above the viewport is cut, as ADR 040's strip drew it alone. Rows out of view are not drawn until they come into view, so a move draws only the rows that came into view plus the edge rows that change between joined and cut, and no frame draws the whole band. `repaintRows` draws a run of rows on the scratch canvas, which is now only as tall as the run plus the next line. It draws on the canvas directly only when the run is cut and the rows around it are invalid, as after a full redraw. Changes invalidate rows instead of repainting them: +Each canvas row has a state: invalid, *joined* (its own cells plus what the next line draws into it, as a full redraw leaves a row), or *cut* (its own cells only). Every frame, the rows in view are brought to the state a full redraw of the viewport leaves them in. The last row is cut, the others are joined, and at a fraction of a line the row above the viewport is cut, as ADR 040's strip drew it alone. Rows out of view are not drawn until they come into view, so a move draws only the rows that came into view plus the edge rows that change between joined and cut, and no frame draws the whole band. `repaintRows` draws a run of rows on the scratch canvas in chunks of at most `REPAINT_CHUNK_ROWS` (8) rows, each with the line below it, so the scratch canvas is at most nine rows tall. A row's pixels depend only on its own line and the next, so a chunked run leaves every row as drawing it at once does. It draws on the canvas directly only when the run is cut and the rows around it are invalid, as after a full redraw. Changes invalidate rows instead of repainting them: - A changed row (engine, selection, link hover) invalidates itself and, when it is joined, the row above. - An overlay (the cursor, the scrollbar) invalidates only its own row. The scrollbar is painted over the rows in view; rows it no longer covers are invalidated. @@ -133,13 +133,13 @@ Unit tests (`renderer.test.ts`) check the rows in view against the step 1 render - Scrolling through scrollback draws only the rows that came into view. A terminal parked in scrollback draws nothing. - The canvas is the same as a full redraw after every partial frame, including glyph overflow from the row below. An exception is a glyph that reaches more than one row up. A later browser check never read the canvas back: after each of 39 states on the Coder engine and 45 on the official one (whole-line and trackpad scrolling, selection, cursor shapes, typing, output while scrolled, clear, alt screen, resize), with emoji, ZWJ sequences, flags, stacked combining marks, Devanagari and wide characters, it compared a screenshot with a screenshot after a forced full redraw. All were identical. The first version drew runs clipped on the canvas and failed this check (see Considered Options). -- The scratch canvas is as large as the canvas: about 20 MB more at 175×64 cells, DPR 2. Each partial repaint adds one copy of the repainted rows. +- Under step 1 the scratch canvas is as large as the canvas: about 20 MB more at 175×64 cells, DPR 2. Each partial repaint adds one copy of the repainted rows. - A blinking cursor no longer redraws its row on every frame, only when it toggles. - A frame that drops the scrollbar repaints every row. That is once per scrollbar fade-out, and once after a resize or font change, which now pass the current scrollbar opacity instead of drawing it at full opacity. - Output that scrolls the screen still redraws every dirty row, which is every row. - Pixel-smooth scroll moves the canvas by CSS across up to 16 lines and draws only the rows that come into view and the edge rows. It no longer draws a row on every wheel event or copies the whole canvas on every line. - The drawing canvas is a second element next to the terminal's canvas. Code that reads the terminal's pixels must use `visibleArea`, and code that maps mouse positions to rows must subtract `scrollOffset`. -- The canvas is 1.5 times as tall (about 30 MB at 175×64, DPR 2). The scratch canvas is only as tall as the largest run drawn on it. +- The canvas is 1.5 times as tall (about 30 MB at 175×64, DPR 2). The scratch canvas is at most nine rows tall. Sized for the largest run drawn on it, it grew to the view after the first `clear` or a screenful of output and kept that size: 19.2 MiB (2800×1800) in headless Chrome at 1400×900, DPR 2. Chunked, it stays at 2.9 MiB (2800×270). Screenshots of 10 states (streamed output, line and fractional scrolls, a jump far up and back, `clear`, output while scrolled) were identical before and after the change. The drawing canvas, 92 rows tall with the overscan, is 29.5 MiB there. - Self-copy `drawImage`, the `copy` operator and step 2's moved and clipped canvas were checked in Chrome only. Firefox and Safari still need the same check. ## Related Decisions diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 4bb0ca7..b0f4b40 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -23,7 +23,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `index.ts`: the terminal runs on Ghostty's official libghostty-vt wasm (ADR 042, ADR 047). Upstream's `GhosttyTerminal` class, which ran the Coder wasm, is replaced: `GhosttyTerminal` and `KeyEncoder` are interfaces, implemented by `OfficialTerminal` and `OfficialKeyEncoder`, and `Ghostty.load(path)` loads the official wasm (by default from `../ghostty-vt/ghostty-vt-small.wasm`). The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` and `readEffects`, and drops upstream's `isDirty`, `getGrapheme` and `getScrollbackGrapheme`, which nothing calls: graphemes are read as strings (`getGraphemeString`, `getScrollbackGraphemeString`). `GhosttyTerminalConfig` drops `scrollbackLimit`, and its colours are undefined when unset instead of 0, so `#000000` is black. The binding drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. Upstream's Coder-only types (`GhosttyWasmExports`, the struct sizes, `TerminalHandle`) are removed. - `terminal.ts`, `renderer.ts`: the terminal side of the official binding (ADR 042, ADR 047). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; bell and title come from `readEffects` instead of upstream's stream scan (`checkForTitleChange` is removed); `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours; the `scrollback` option is removed; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder wasm). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. -- `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. +- `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in, in chunks of at most `REPAINT_CHUNK_ROWS` rows so the scratch canvas stays a few rows high), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), a hyperlink hover change repaints the rows out of view too, and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 9e29335..0127702 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -81,6 +81,13 @@ const ROW_JOINED = 1; // Its own cells only, as a full redraw leaves the last row const ROW_CUT = 2; +/** + * Rows a partial repaint draws on the scratch canvas at once, besides the row below: a + * longer run is repainted in chunks of this many rows, so the scratch canvas stays a few + * rows high instead of growing to the canvas' size. (webtty, ADR 043) + */ +export const REPAINT_CHUNK_ROWS = 8; + /** * The ratio the canvas is drawn at: the device pixel ratio rounded up to a whole * number. A fractional ratio (browser zoom, Windows 125%/150%) would put cell edges @@ -788,6 +795,9 @@ export class CanvasRenderer { * ends before a row that is kept. Drawing them clipped to the rows is * not the same: Chrome skips a glyph whose box is outside the clip, so a combining mark * of the next line loses the antialiased edge it draws on the last pixel line. + * A long run is drawn on the scratch canvas in chunks of `REPAINT_CHUNK_ROWS` rows, each + * with the next line, which leaves every row as the whole run drawn at once would: a + * row's pixels depend only on its own line and the next. * (webtty, ADR 043) */ private repaintRows( @@ -799,23 +809,41 @@ export class CanvasRenderer { ): void { const state = this.rowState; const total = state.length; - const end = cut ? last : last + 1; const free = (k: number) => k < 0 || k >= total || state[k] === ROW_INVALID; // Drawn on the canvas, the row above gets the first row's overflow and the row below // the last one's - const direct = cut && free(first - 1) && free(last + 1); + if (cut && free(first - 1) && free(last + 1)) { + for (let k = first; k <= last; k++) this.drawRow(k, cols, rowAt(k)); + } else { + for (let from = first; from <= last; from += REPAINT_CHUNK_ROWS) { + const to = Math.min(from + REPAINT_CHUNK_ROWS - 1, last); + this.repaintChunk(from, to, cut && to === last, cols, rowAt); + } + } + for (let k = first; k <= last; k++) state[k] = ROW_JOINED; + state[last] = cut ? ROW_CUT : ROW_JOINED; + } + + /** + * Draws canvas rows `first` to `last`, plus the next line unless `cut`, on the scratch + * canvas and copies rows `first` to `last` into the canvas. (webtty, ADR 043) + */ + private repaintChunk( + first: number, + last: number, + cut: boolean, + cols: number, + rowAt: (k: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + ): void { + const end = cut ? last : last + 1; const own = this.ctx; const rowHeight = this.metrics.height * this.devicePixelRatio; - const scratch = direct ? null : this.scratchContext(first, end - first + 1); - if (scratch) this.ctx = scratch; + this.ctx = this.scratchContext(first, end - first + 1); try { for (let k = first; k <= end; k++) this.drawRow(k, cols, rowAt(k)); } finally { this.ctx = own; } - for (let k = first; k <= last; k++) state[k] = ROW_JOINED; - state[last] = cut ? ROW_CUT : ROW_JOINED; - if (direct) return; // Replace the rows' pixels in device pixels: cleared first, the copy is not blended // over them, so a translucent background does not darken const width = this.canvas.width; diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 6d11bc8..632057a 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -5,6 +5,7 @@ import { Ghostty } from './ghostty-web/ghostty'; import { CanvasRenderer, frameLines, + REPAINT_CHUNK_ROWS, watchDevicePixelRatio, wholeDevicePixelRatio, } from './ghostty-web/renderer'; @@ -697,6 +698,49 @@ describe('overscan and copied scrolls (ADR 043)', () => { term.free(); }); + test(`long runs are repainted in chunks on a small scratch canvas, as a full redraw`, async () => { + const tall = 3 * REPAINT_CHUNK_ROWS + 5; + const ghostty = await Ghostty.load(WASM); + const term = ghostty.createTerminal(cols, tall, { scrollbackLines: 10000 }); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; + // Every line overflows into the rows above and below, so each chunk's edges matter + const lines = (from: number, count: number) => + Array.from( + { length: count }, + (_, i) => `\x1b[4${(from + i) % 8}m${from + i} e\u0301 g\x1b[0m`, + ); + term.write(lines(0, tall * 2).join('\r\n')); + for (const overscan of [1, 4]) { + const live = renderer(2, { overscan }); + live.r.resize(cols, tall); + const scratch = (live.r as unknown as { scratch: HTMLCanvasElement }).scratch; + const rowHeight = live.r.getMetrics().height * 2; + live.r.render(term, true, 0, provider, 1); + // New output moves every row, a clear blanks them, and scrolls copy and draw rows + for (const [output, y] of [ + [`\r\n${lines(100, 3).join('\r\n')}`, 0], + ['', 2 * REPAINT_CHUNK_ROWS + 1], + ['', 4], + ['', 0], + ['\x1b[2J\x1b[H', 0], + [lines(200, tall - 1).join('\r\n'), 0], + ] as const) { + term.write(output); + live.r.render(term, false, y, provider, 1); + const full = renderer(2, { overscan }); + full.r.resize(cols, tall); + full.r.render(term, true, y, provider, 1); + expect(live.shown()).toEqual(full.shown()); + expect(scratch.height).toBeLessThanOrEqual((REPAINT_CHUNK_ROWS + 1) * rowHeight); + } + } + term.free(); + }); + test(`a hovered link is repainted out of view too`, async () => { const { term, provider } = await terminal(); const live = renderer(2, { overscan: 4 }); From b3d781bd9fc65b359db1d950d662ca9b28836fee Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:40:06 -0400 Subject: [PATCH 056/295] fix(client): underline only the hovered OSC 8 link The official engine's hyperlink_id is a 1-bit has-a-link flag, so matching cells by it underlined every OSC 8 link on screen and repainted every row holding one on each hover change. Underline the range the OSC 8 provider found, as for regex links, and repaint only its rows. --- src/client/vendor/ghostty-web/PROVENANCE.md | 3 +- src/client/vendor/ghostty-web/renderer.ts | 55 +++------------------ src/client/vendor/ghostty-web/terminal.ts | 51 +------------------ src/client/vendor/renderer.test.ts | 55 +++++++++------------ src/client/vendor/terminal.test.ts | 48 ++++++++++++++++++ 5 files changed, 82 insertions(+), 130 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index b0f4b40..d7fd9f1 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -24,7 +24,8 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `renderer.ts`: the terminal side of the official binding (ADR 042, ADR 047). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; bell and title come from `readEffects` instead of upstream's stream scan (`checkForTitleChange` is removed); `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours; the `scrollback` option is removed; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder wasm). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in, in chunks of at most `REPAINT_CHUNK_ROWS` rows so the scratch canvas stays a few rows high), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. -- `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), a hyperlink hover change repaints the rows out of view too, and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. +- `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. +- `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 0127702..0000c5b 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -299,11 +299,9 @@ export class CanvasRenderer { // The selection the canvas rows were drawn with, in canvas rows (webtty, ADR 043) private drawnSelection: CellRange | null = null; - // Link rendering state - private hoveredHyperlinkId: number = 0; - private previousHoveredHyperlinkId: number = 0; - - // Regex link hover tracking (for links without hyperlink_id), in viewport rows + // The hovered link's range (OSC 8 or regex), in viewport rows. The engine's + // hyperlink_id is a 1-bit "has a link" flag, not an id, so the range is the only + // thing that tells one link from another. (webtty) private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null = null; // The regex link range this frame draws and the one the canvas rows were drawn with, @@ -635,27 +633,10 @@ export class CanvasRenderer { this.drawnSelection = this.currentSelectionCoords; this.selectionManager?.clearDirtySelectionRows(); - // Rows with the old or new hovered hyperlink. Rows not drawn yet are drawn with it. const rowAt = (k: number) => this.rowAt(k - this.overscan, band, rows, getLine, buffer, scrollbackProvider, scrollbackLength); - if (this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId) { - for (let k = 0; k < total; k++) { - if (state[k] === ROW_INVALID) continue; - const line = rowAt(k).line; - if ( - line?.some( - (cell) => - cell.hyperlink_id === this.hoveredHyperlinkId || - cell.hyperlink_id === this.previousHoveredHyperlinkId - ) - ) { - changed(k); - } - } - this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; - } - // A regex link underline is drawn at viewport rows, which are other canvas rows after a + // A link underline is drawn at viewport rows, which are other canvas rows after a // scroll: clear it where it was drawn and draw it where it is. this.currentLinkRange = moveRange(this.hoveredLinkRange, top); if (JSON.stringify(this.currentLinkRange) !== JSON.stringify(this.drawnLinkRange)) { @@ -1123,23 +1104,8 @@ export class CanvasRenderer { this.ctx.stroke(); } - // Draw hyperlink underline (for OSC8 hyperlinks) - if (cell.hyperlink_id > 0) { - const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId; - - // Only show underline when hovered (cleaner look) - if (isHovered) { - const underlineY = cellY + this.metrics.baseline + 2; - this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover - this.ctx.lineWidth = 1; - this.ctx.beginPath(); - this.ctx.moveTo(cellX, underlineY); - this.ctx.lineTo(cellX + cellWidth, underlineY); - this.ctx.stroke(); - } - } - - // Draw regex link underline (for plain text URLs), in canvas rows (webtty, ADR 043) + // Draw the hovered link's underline (OSC 8 or plain text URL), in canvas rows + // (webtty, ADR 043) if (this.currentLinkRange) { const range = this.currentLinkRange; // Check if this cell is within the hovered link range @@ -1446,14 +1412,7 @@ export class CanvasRenderer { } /** - * Set the currently hovered hyperlink ID for rendering underlines - */ - public setHoveredHyperlinkId(hyperlinkId: number): void { - this.hoveredHyperlinkId = hyperlinkId; - } - - /** - * Set the currently hovered link range for rendering underlines (for regex-detected URLs) + * Set the currently hovered link range for rendering underlines (OSC 8 or regex links) * Pass null to clear the hover state */ public setHoveredLinkRange( diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 283c47c..a28e1b2 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -1503,47 +1503,7 @@ export class Terminal implements ITerminalCore { (e.clientY - rect.top - this.renderer.scrollOffset) / this.renderer.charHeight ); - // Get hyperlink_id directly from the cell at this position - // Must account for viewportY (scrollback position) const viewportRow = y; // Row in the viewport (0 to rows-1) - let hyperlinkId = 0; - - // When scrolled, fetch from scrollback or screen based on position - // NOTE: viewportY may be fractional during smooth scrolling. The renderer - // uses Math.floor(viewportY) when mapping viewport rows to scrollback vs - // screen; we mirror that logic here so link hit-testing matches what the - // user sees on screen. - let line: GhosttyCell[] | null = null; - const rawViewportY = this.getViewportY(); - const viewportY = Math.max(0, Math.floor(rawViewportY)); - if (viewportY > 0) { - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - if (viewportRow < viewportY) { - // Mouse is over scrollback content - const scrollbackOffset = scrollbackLength - viewportY + viewportRow; - line = this.wasmTerm.getScrollbackLine(scrollbackOffset); - } else { - // Mouse is over screen content (bottom part of viewport) - const screenRow = viewportRow - viewportY; - line = this.wasmTerm.getLine(screenRow); - } - } else { - // At bottom - just use screen buffer - line = this.wasmTerm.getLine(viewportRow); - } - - if (line && x >= 0 && x < line.length) { - hyperlinkId = line[x].hyperlink_id; - } - - // Update renderer for underline rendering - const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; - if (hyperlinkId !== previousHyperlinkId) { - this.renderer.setHoveredHyperlinkId(hyperlinkId); - - // The 60fps render loop will pick up the change automatically - // No need to force a render - this keeps performance smooth - } // Check if there's a link at this position (for click handling and cursor) // Buffer API expects absolute buffer coordinates (including scrollback) @@ -1594,7 +1554,7 @@ export class Terminal implements ITerminalCore { this.canvas.style.cursor = cursorStyle; } - // Update renderer for underline (for regex URLs without hyperlink_id) + // Update renderer for underline: the link's own range, OSC 8 or regex (webtty) if (this.renderer) { if (link) { // Convert buffer coordinates to viewport coordinates @@ -1633,15 +1593,8 @@ export class Terminal implements ITerminalCore { * Handle mouse leave to clear link hover */ private handleMouseLeave = (): void => { - // Clear hyperlink underline + // Clear the link underline if (this.renderer && this.wasmTerm) { - const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; - if (previousHyperlinkId > 0) { - this.renderer.setHoveredHyperlinkId(0); - - // The 60fps render loop will pick up the change automatically - } - // Clear regex link underline this.renderer.setHoveredLinkRange(null); } diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 632057a..8e29efe 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -636,14 +636,6 @@ describe('overscan and copied scrolls (ADR 043)', () => { clearDirtySelectionRows: () => {}, } as unknown as Parameters[0]; let range: { startX: number; startY: number; endX: number; endY: number } | null = null; - // The OSC 8 link ids, and none - const ids = new Set([0]); - for (let o = 0; o < term.getScrollbackLength(); o++) { - for (const cell of term.getScrollbackLine(o) ?? []) ids.add(cell.hyperlink_id); - } - const links = [...ids]; - expect(links.length).toBeGreaterThan(1); - let hovered = 0; for (const overscan of [1, 3]) { const options = { overscan }; const live = renderer(2, options); @@ -673,23 +665,19 @@ describe('overscan and copied scrolls (ADR 043)', () => { endCol: Math.floor(random() * cols), endLine: line + Math.floor(random() * 6), }; - } else if (op < 0.9) { + } else { const y = Math.floor(random() * rows); range = random() < 0.3 ? null : { startX: 2, startY: y, endX: 6, endY: y }; - } else { - hovered = links[Math.floor(random() * links.length)]; } position = Math.max(0, Math.min(term.getScrollbackLength(), position)); const { line, offset } = split(position, h, 2); view = line; live.r.setHoveredLinkRange(range); - live.r.setHoveredHyperlinkId(hovered); live.r.setScrollOffset(offset); live.r.render(term, false, view, provider, opacity); const full = renderer(2, options); full.r.setSelectionManager(manager); full.r.setHoveredLinkRange(range); - full.r.setHoveredHyperlinkId(hovered); full.r.setScrollOffset(offset); full.r.render(term, true, view, provider, opacity); expect(live.shown()).toEqual(full.shown()); @@ -741,25 +729,28 @@ describe('overscan and copied scrolls (ADR 043)', () => { term.free(); }); - test(`a hovered link is repainted out of view too`, async () => { - const { term, provider } = await terminal(); - const live = renderer(2, { overscan: 4 }); - live.r.render(term, true, 0, provider, 0); - // Each line in turn is the last row, then just below the viewport while the hover - // changes, then the last row again. - let hovered = 0; - for (let y = 0; y < 24; y++) { - live.r.render(term, false, y, provider, 0); - live.r.render(term, false, y + 1, provider, 0); - hovered = 1 - hovered; - live.r.setHoveredHyperlinkId(hovered); - live.r.render(term, false, y + 1, provider, 0); - live.r.render(term, false, y, provider, 0); - const full = renderer(2, { overscan: 4 }); - full.r.setHoveredHyperlinkId(hovered); - full.r.render(term, true, y, provider, 0); - expect(live.shown()).toEqual(full.shown()); - } + test(`a hovered OSC 8 link underlines and repaints only its own range`, async () => { + const ghostty = await Ghostty.load(WASM); + const term = ghostty.createTerminal(cols, rows); + // Two links, on rows 1 and 4: the engine gives every link cell the same hyperlink_id + term.write('a\r\n\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\nb\r\nc\r\n'); + term.write('\x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); + expect(term.getLine(1)?.[0].hyperlink_id).toBe(term.getLine(4)?.[0].hyperlink_id); + const live = renderer(1, { overscan: 2 }); + live.r.render(term, true, 0, undefined, 0); + live.drawn.length = 0; + live.r.setHoveredLinkRange({ startX: 0, startY: 4, endX: 5, endY: 4 }); + live.r.render(term, false, 0, undefined, 0); + // The hovered row, the row above it, which may hold its overflow, and the row below, + // which overflows into it (overscan rows first) + expect([...new Set(live.drawn)].sort()).toEqual([3, 4, 5].map((y) => y + 2)); + const width = cols * 8; + const underlined = (y: number) => + live + .shown() + .slice(y * 15 * width, (y + 1) * 15 * width) + .some((p) => p.startsWith('line #4A90E2')); + expect([0, 1, 2, 3, 4, 5].map(underlined)).toEqual([false, false, false, false, true, false]); term.free(); }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 1930a95..1d8c5a6 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -1,4 +1,9 @@ import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty } from './ghostty-web/ghostty'; +import { LinkDetector } from './ghostty-web/link-detector'; +import { OSC8LinkProvider } from './ghostty-web/providers/osc8-link-provider'; import { cursorCellOffset, decscusrShape, @@ -9,6 +14,11 @@ import { } from './ghostty-web/terminal'; import type { GhosttyTerminalConfig, ILink } from './ghostty-web/types'; +const WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'ghostty-vt/ghostty-vt-small.wasm', +); + describe('mouseCursorFor', () => { const link: ILink = { text: 'https://example.com', @@ -210,3 +220,41 @@ describe('Terminal bell and title', () => { expect(titles).toEqual(['vim']); }); }); + +describe('Terminal link hover', () => { + // The engine's hyperlink_id is a 1-bit "has a link" flag, so every OSC 8 cell carries + // the same value: the hover is the hovered link's range, never an id. + test('hovering an OSC 8 link gives the renderer that link range only', async () => { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(30, 4); + wasmTerm.write('\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\n'); + wasmTerm.write('xx \x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); + const term = new Terminal({ ghostty: {} as never, cols: 30, rows: 4 }); + const calls: [string, unknown][] = []; + const renderer = new Proxy( + { charWidth: 8, charHeight: 16, scrollOffset: 0 }, + { + get: (t, k) => + k in t ? t[k as keyof typeof t] : (arg: unknown) => calls.push([String(k), arg]), + }, + ); + const internals = term as unknown as { + wasmTerm: unknown; + renderer: unknown; + canvas: unknown; + linkDetector: LinkDetector; + processMouseMove(e: { clientX: number; clientY: number }): void; + }; + internals.wasmTerm = wasmTerm; + internals.renderer = renderer; + internals.canvas = { getBoundingClientRect: () => ({ left: 0, top: 0 }), style: {} }; + internals.linkDetector = new LinkDetector(term); + internals.linkDetector.registerProvider(new OSC8LinkProvider(term)); + + // Over "second", on row 1 + internals.processMouseMove({ clientX: 5 * 8 + 1, clientY: 16 + 1 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toEqual([['setHoveredLinkRange', { startX: 3, startY: 1, endX: 8, endY: 1 }]]); + wasmTerm.free(); + }); +}); From 7261ca2b15ebadd719155d071f7712770034f917 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:41:58 -0400 Subject: [PATCH 057/295] fix(client): copy a selection once, only with copyOnSelect The selection manager copied on every mouse selection and the client copied again on onSelectionChange, so the clipboard was written twice, the text built twice, and copyOnSelect: false still copied. The terminal now takes copyOnSelect and the selection manager is the only copy. --- docs/specs/client.md | 2 +- src/client/index.ts | 13 +- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/interfaces.ts | 3 + .../vendor/ghostty-web/selection-manager.ts | 36 +++--- src/client/vendor/ghostty-web/terminal.ts | 1 + src/client/vendor/selection-manager.test.ts | 120 ++++++++++++++++++ 7 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 src/client/vendor/selection-manager.test.ts diff --git a/docs/specs/client.md b/docs/specs/client.md index e2ac8eb..4e54ec6 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -152,7 +152,7 @@ Controlled by two config keys from `GET /api/config`: | Config | Default | Behavior | |--------|---------|----------| -| `copyOnSelect: true` | on | Auto-copies selection to clipboard on mouseup via `term.onSelectionChange`. Context menu untouched. | +| `copyOnSelect: true` | on | Auto-copies a mouse selection (drag, double or triple click) to clipboard once, on mouseup, through the Terminal's `copyOnSelect` option; off, nothing is copied. Context menu untouched. | | `rightClickBehavior: "copyPaste"` | off | Right-click with selection copies + clears selection. `e.preventDefault()` only fires when selection exists. | Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these are the only reliable copy paths. diff --git a/src/client/index.ts b/src/client/index.ts index a67114a..89ccab7 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -83,6 +83,8 @@ const term = new Terminal({ fontFamily: config.fontFamily, scrollbackLines: config.scrollbackLines, mouseScrollSpeed: config.mouseScrollSpeed, + // The terminal copies a mouse selection on mouseup, once, only when this is on (ADR 011) + copyOnSelect: config.copyOnSelect, theme: terminalTheme, }); @@ -350,17 +352,6 @@ term.onResize(({ cols, rows }: { cols: number; rows: number }) => { } }); -// Copy the selected text to the clipboard whenever the selection changes. -if (config.copyOnSelect) { - term.onSelectionChange(() => { - const selection = term.getSelection() as string; - if (!selection) return; - navigator.clipboard.writeText(selection).catch(() => { - /* empty */ - }); - }); -} - // Copy selected text to clipboard on right-click when copyPaste mode is active. if (config.rightClickBehavior === 'copyPaste') { container.addEventListener('contextmenu', (e: MouseEvent) => { diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index d7fd9f1..22494a3 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -26,6 +26,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in, in chunks of at most `REPAINT_CHUNK_ROWS` rows so the scratch canvas stays a few rows high), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. +- `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index e1cfc9a..278ca94 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -22,6 +22,9 @@ export interface ITerminalOptions { // Scrolling options mouseScrollSpeed?: number; // Wheel ticks per line/page wheel event when the app tracks the mouse (default: 1) + // Selection options + copyOnSelect?: boolean; // Copy a mouse selection to the clipboard when it is made (default: true) (webtty) + // Internal: Ghostty WASM instance (optional, for test isolation) // If not provided, uses the module-level instance from init() ghostty?: Ghostty; diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 69b421e..f412c40 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -5,7 +5,7 @@ * - Mouse drag selection * - Double-click word selection * - Text extraction from terminal buffer - * - Automatic clipboard copy + * - Clipboard copy on select (the terminal's copyOnSelect option) * - Visual selection highlighting (integrated into CanvasRenderer cell rendering) * - Auto-scroll during drag selection */ @@ -608,13 +608,7 @@ export class SelectionManager { return; } - if (this.hasSelection()) { - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - this.selectionChangedEmitter.fire(); - } - } + if (this.hasSelection()) this.selectionMade(); } }; document.addEventListener('mouseup', this.boundMouseUpHandler); @@ -633,12 +627,7 @@ export class SelectionManager { this.selectionStart = { col: word.startCol, absoluteRow }; this.selectionEnd = { col: word.endCol, absoluteRow }; this.requestRender(); - - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - this.selectionChangedEmitter.fire(); - } + this.selectionMade(); } } else if (e.detail >= 3) { // Triple-click (or more) - select line content (like native Ghostty) @@ -674,12 +663,7 @@ export class SelectionManager { this.selectionStart = { col: 0, absoluteRow }; this.selectionEnd = { col: endCol, absoluteRow }; this.requestRender(); - - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - this.selectionChangedEmitter.fire(); - } + this.selectionMade(); } } }); @@ -960,6 +944,18 @@ export class SelectionManager { return { startCol, endCol }; } + /** + * A mouse selection is made: copy it when the terminal's copyOnSelect is on, building + * its text once, and report the change. (webtty) + */ + private selectionMade(): void { + if (this.terminal.options.copyOnSelect) { + const text = this.getSelection(); + if (text) this.copyToClipboard(text); + } + this.selectionChangedEmitter.fire(); + } + /** * Copy text to clipboard * diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index a28e1b2..4871b05 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -262,6 +262,7 @@ export class Terminal implements ITerminalCore { convertEol: options.convertEol ?? false, disableStdin: options.disableStdin ?? false, mouseScrollSpeed: options.mouseScrollSpeed ?? 1, + copyOnSelect: options.copyOnSelect ?? true, }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts new file mode 100644 index 0000000..b343527 --- /dev/null +++ b/src/client/vendor/selection-manager.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; +import type { CanvasRenderer } from './ghostty-web/renderer'; +import { SelectionManager } from './ghostty-web/selection-manager'; +import type { Terminal } from './ghostty-web/terminal'; + +const WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'ghostty-vt/ghostty-vt-small.wasm', +); + +type Listener = (e: object) => void; + +/** An event target that keeps its listeners, so a test can dispatch to them by hand. */ +function target() { + const listeners = new Map(); + return { + listeners, + addEventListener: (type: string, listener: Listener) => + listeners.set(type, [...(listeners.get(type) ?? []), listener]), + removeEventListener: () => {}, + dispatch: (type: string, e: object) => { + for (const listener of listeners.get(type) ?? []) listener(e); + }, + }; +} + +describe('SelectionManager copy on select', () => { + const doc = globalThis.document; + const nav = globalThis.navigator; + let documentTarget: ReturnType; + let writes: string[]; + let wasmTerm: GhosttyTerminal; + + beforeEach(async () => { + documentTarget = target(); + globalThis.document = documentTarget as unknown as Document; + writes = []; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + clipboard: { + writeText: async (text: string) => { + writes.push(text); + }, + }, + }, + }); + const ghostty = await Ghostty.load(WASM); + wasmTerm = ghostty.createTerminal(20, 4); + wasmTerm.write('hello world\r\nsecond line'); + }); + + afterEach(() => { + wasmTerm.free(); + globalThis.document = doc; + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: nav }); + }); + + /** A manager over the wasm terminal, counting the times it builds the selected text. */ + function manager(copyOnSelect: boolean) { + const canvas = target(); + const terminal = { + cols: 20, + rows: 4, + options: { copyOnSelect }, + getViewportY: () => 0, + } as unknown as Terminal; + const renderer = { + getCanvas: () => canvas, + getMetrics: () => ({ width: 8, height: 16 }), + scrollOffset: 0, + } as unknown as CanvasRenderer; + const textarea = { style: {} } as HTMLTextAreaElement; + const m = new SelectionManager(terminal, renderer, wasmTerm, textarea); + let builds = 0; + const getSelection = m.getSelection.bind(m); + m.getSelection = () => { + builds++; + return getSelection(); + }; + let changes = 0; + m.onSelectionChange(() => changes++); + return { m, canvas, builds: () => builds, changes: () => changes }; + } + + /** Drags across "hello" on row 0 and releases. */ + function drag(canvas: ReturnType) { + canvas.dispatch('mousedown', { button: 0, offsetX: 1, offsetY: 4 }); + canvas.dispatch('mousemove', { offsetX: 4 * 8 + 1, offsetY: 4 }); + documentTarget.dispatch('mouseup', {}); + } + + test('a drag copies its text once, building it once', () => { + const { canvas, builds, changes } = manager(true); + drag(canvas); + expect(writes).toEqual(['hello']); + expect(builds()).toBe(1); + expect(changes()).toBe(1); + }); + + test('a double and a triple click copy once each', () => { + const { canvas } = manager(true); + canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); + canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); + expect(writes).toEqual(['world', 'second line']); + }); + + test('nothing is copied or built with copyOnSelect off, but the change is reported', () => { + const { canvas, builds, changes } = manager(false); + drag(canvas); + canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); + canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); + expect(writes).toEqual([]); + expect(builds()).toBe(0); + expect(changes()).toBe(3); + }); +}); From 36c5a0baeffd48305395847576bb0b7aae7e128b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:43:04 -0400 Subject: [PATCH 058/295] fix(client): send a wheel tick over the padding once The input handler's own wheel listener fired when the padding was the target, which the terminal's capture listener does not stop, so the tick was sent twice and the second one unpaced. The terminal's paced wheel handler is now the only path. --- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- .../vendor/ghostty-web/input-handler.ts | 30 ++---------- src/client/vendor/input-handler.test.ts | 47 ++++++++++++++++++- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 22494a3..f17e9d5 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -13,7 +13,7 @@ webtty edits to these files are tracked in git from the commit that added them; ## Local changes - `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. -- `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, and motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`). +- `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`), and its own `wheel` listener is removed: the terminal's paced `handleWheel` is the only wheel path, where a wheel over the padding (the container itself the target, so the terminal's capture listener did not stop it) was sent a second time, unpaced. - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 0ac505f..4fc7de3 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -219,7 +219,6 @@ export class InputHandler { private mousedownListener: ((e: MouseEvent) => void) | null = null; private mouseupListener: ((e: MouseEvent) => void) | null = null; private mousemoveListener: ((e: MouseEvent) => void) | null = null; - private wheelListener: ((e: WheelEvent) => void) | null = null; private isComposing = false; private isDisposed = false; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting @@ -334,8 +333,9 @@ export class InputHandler { this.mousemoveListener = this.handleMouseMove.bind(this); this.container.addEventListener('mousemove', this.mousemoveListener); - this.wheelListener = this.handleWheel.bind(this); - this.container.addEventListener('wheel', this.wheelListener, { passive: false }); + // Wheel input is the terminal's alone (Terminal.handleWheel, paced). A listener here + // also fired for a wheel over the padding, where the container itself is the target, + // and sent that tick a second time, unpaced. (webtty) } /** @@ -913,25 +913,6 @@ export class InputHandler { this.sendMouseEvent(button, cell.col, cell.row, false, event); } - /** - * Handle wheel event (scroll) - */ - private handleWheel(event: WheelEvent): void { - if (this.isDisposed) return; - if (!this.mouseConfig?.hasMouseTracking()) return; - - const cell = this.pixelToCell(event); - if (!cell) return; - - // Wheel events: button 64 = scroll up, button 65 = scroll down - const button = event.deltaY < 0 ? 64 : 65; - - this.sendMouseEvent(button, cell.col, cell.row, false, event); - - // Prevent default scrolling when mouse tracking is active - event.preventDefault(); - } - /** * Emit paste data with bracketed paste support */ @@ -1115,11 +1096,6 @@ export class InputHandler { this.mousemoveListener = null; } - if (this.wheelListener) { - this.container.removeEventListener('wheel', this.wheelListener); - this.wheelListener = null; - } - this.isDisposed = true; } diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index 3f1855b..c7218d1 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { isNewMouseCell, mouseMotionButton } from './ghostty-web/input-handler'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty } from './ghostty-web/ghostty'; +import { InputHandler, isNewMouseCell, mouseMotionButton } from './ghostty-web/input-handler'; + +const WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'ghostty-vt/ghostty-vt-small.wasm', +); describe('mouseMotionButton', () => { test('encodes hover (no button held) as 35', () => { @@ -41,3 +49,40 @@ describe('isNewMouseCell', () => { expect(isNewMouseCell({ col: 8, row: 4 }, { col: 8, row: 5 })).toBe(true); }); }); + +describe('InputHandler wheel', () => { + // The terminal's own wheel listener, paced, is the only wheel path: one here also fired + // for a wheel over the padding, where the container is the target, and sent the tick + // twice. + test('sends nothing for a wheel event on the container, even with mouse tracking', async () => { + const ghostty = await Ghostty.load(WASM); + const listeners = new Map void)[]>(); + const container = { + addEventListener: (type: string, listener: (e: object) => void) => + listeners.set(type, [...(listeners.get(type) ?? []), listener]), + removeEventListener: () => {}, + } as unknown as HTMLElement; + const sent: string[] = []; + const handler = new InputHandler( + ghostty, + container, + (data) => sent.push(data), + () => {}, + undefined, + undefined, + undefined, + undefined, + undefined, + { + hasMouseTracking: () => true, + hasSgrMouseMode: () => true, + getCellDimensions: () => ({ width: 8, height: 16 }), + getCanvasOffset: () => ({ left: 10, top: 10 }), + }, + ); + const wheel = { deltaY: 40, clientX: 2, clientY: 2, preventDefault: () => {} }; + for (const listener of listeners.get('wheel') ?? []) listener(wheel); + expect(sent).toEqual([]); + handler.dispose(); + }); +}); From 3c51bc5082be8cb03ec35b620fd2294a5711ad41 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:48:01 -0400 Subject: [PATCH 059/295] fix(client): encode navigation and function keys with the key encoder A fixed table sent Home, End, PageUp, PageDown, Insert, Delete and F1-F12, unmodified or with Shift, ignoring application cursor mode (Home and End sent CSI H/F instead of ESC O H/F under DECCKM) and dropping Shift. Every key now goes through the official KeyEncoder, as the arrows did, except Shift+Enter and Shift+Escape, which keep xterm's CR and ESC instead of Ghostty's modifyOtherKeys CSI 27;2;...~. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/input-handler.ts | 112 ++++----------- src/client/vendor/input-handler.test.ts | 130 +++++++++++++++++- 3 files changed, 156 insertions(+), 87 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index f17e9d5..c5cd92f 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -26,6 +26,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in, in chunks of at most `REPAINT_CHUNK_ROWS` rows so the scratch canvas stays a few rows high), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. +- `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). - `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 4fc7de3..183eb49 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -172,6 +172,23 @@ export interface MouseTrackingConfig { getCanvasOffset: () => { left: number; top: number }; } +/** + * The sequence for a key the official encoder does not send as xterm does, or null to + * encode the key. For Shift+Enter and Shift+Escape the encoder sends Ghostty's + * modifyOtherKeys `CSI 27;2;13~` and `CSI 27;2;27~`, which most apps do not read; xterm + * sends CR and ESC, as upstream did. Every other key is encoded: navigation and function + * keys follow the cursor key mode (Home is `ESC O H` under DECCKM) and the modifiers + * (Shift+Home is `CSI 1;2 H`). (webtty: upstream sent a fixed table for Enter, Tab, + * Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, which + * ignored both.) + */ +export function plainKeySequence(key: Key, mods: Mods): string | null { + if (mods !== Mods.SHIFT) return null; + if (key === Key.ENTER) return '\r'; + if (key === Key.ESCAPE) return '\x1b'; + return null; +} + /** * Button code for a mouse motion report, from the pressed-button bitmask * (bit 0 left, bit 1 middle, bit 2 right). Motion adds 32 to the button, and @@ -448,92 +465,15 @@ export class InputHandler { // Extract modifiers const mods = this.extractModifiers(event); - // Handle simple special keys that produce standard sequences - if (mods === Mods.NONE || mods === Mods.SHIFT) { - let simpleOutput: string | null = null; - - switch (key) { - case Key.ENTER: - simpleOutput = '\r'; // Carriage return - break; - case Key.TAB: - if (mods === Mods.SHIFT) { - simpleOutput = '\x1b[Z'; // Backtab - } else { - simpleOutput = '\t'; // Tab - } - break; - case Key.BACKSPACE: - simpleOutput = '\x7F'; // DEL (most terminals use 0x7F for backspace) - break; - case Key.ESCAPE: - simpleOutput = '\x1B'; // ESC - break; - // Arrow keys are handled by the encoder (respects application cursor mode) - // Navigation keys - case Key.HOME: - simpleOutput = '\x1B[H'; - break; - case Key.END: - simpleOutput = '\x1B[F'; - break; - case Key.INSERT: - simpleOutput = '\x1B[2~'; - break; - case Key.DELETE: - simpleOutput = '\x1B[3~'; - break; - case Key.PAGE_UP: - simpleOutput = '\x1B[5~'; - break; - case Key.PAGE_DOWN: - simpleOutput = '\x1B[6~'; - break; - // Function keys - case Key.F1: - simpleOutput = '\x1BOP'; - break; - case Key.F2: - simpleOutput = '\x1BOQ'; - break; - case Key.F3: - simpleOutput = '\x1BOR'; - break; - case Key.F4: - simpleOutput = '\x1BOS'; - break; - case Key.F5: - simpleOutput = '\x1B[15~'; - break; - case Key.F6: - simpleOutput = '\x1B[17~'; - break; - case Key.F7: - simpleOutput = '\x1B[18~'; - break; - case Key.F8: - simpleOutput = '\x1B[19~'; - break; - case Key.F9: - simpleOutput = '\x1B[20~'; - break; - case Key.F10: - simpleOutput = '\x1B[21~'; - break; - case Key.F11: - simpleOutput = '\x1B[23~'; - break; - case Key.F12: - simpleOutput = '\x1B[24~'; - break; - } - - if (simpleOutput !== null) { - event.preventDefault(); - this.onDataCallback(simpleOutput); - this.recordKeyDownData(simpleOutput); - return; - } + // The few keys the encoder does not send as xterm does; every other key, navigation + // and function keys included, is encoded below, which follows the cursor key mode + // and the modifiers (webtty) + const plain = plainKeySequence(key, mods); + if (plain !== null) { + event.preventDefault(); + this.onDataCallback(plain); + this.recordKeyDownData(plain); + return; } // Determine action (we only care about PRESS for now, not RELEASE or REPEAT) diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index c7218d1..e6bb222 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Ghostty } from './ghostty-web/ghostty'; -import { InputHandler, isNewMouseCell, mouseMotionButton } from './ghostty-web/input-handler'; +import { + InputHandler, + isNewMouseCell, + mouseMotionButton, + plainKeySequence, +} from './ghostty-web/input-handler'; +import { Key, Mods } from './ghostty-web/types'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), @@ -86,3 +92,125 @@ describe('InputHandler wheel', () => { handler.dispose(); }); }); + +describe('plainKeySequence', () => { + test('sends CR and ESC for Shift+Enter and Shift+Escape, as xterm does', () => { + expect(plainKeySequence(Key.ENTER, Mods.SHIFT)).toBe('\r'); + expect(plainKeySequence(Key.ESCAPE, Mods.SHIFT)).toBe('\x1b'); + }); + + test('leaves every other key to the encoder', () => { + for (const key of [Key.ENTER, Key.ESCAPE, Key.TAB, Key.BACKSPACE, Key.HOME, Key.F1]) { + expect(plainKeySequence(key, Mods.NONE)).toBeNull(); + expect(plainKeySequence(key, Mods.CTRL)).toBeNull(); + } + for (const key of [Key.TAB, Key.BACKSPACE, Key.HOME, Key.END, Key.PAGE_UP, Key.F5]) { + expect(plainKeySequence(key, Mods.SHIFT)).toBeNull(); + } + }); +}); + +describe('InputHandler keys', () => { + /** Sends a keydown through a handler and returns what it wrote, under DECCKM if asked. */ + async function keys(appCursor = false) { + const ghostty = await Ghostty.load(WASM); + const listeners = new Map void)[]>(); + const container = { + addEventListener: (type: string, listener: (e: object) => void) => + listeners.set(type, [...(listeners.get(type) ?? []), listener]), + removeEventListener: () => {}, + } as unknown as HTMLElement; + let sent: string[] = []; + new InputHandler( + ghostty, + container, + (data) => sent.push(data), + () => {}, + undefined, + undefined, + (mode) => mode === 1 && appCursor, + ); + return (code: string, key: string, mods: string[] = []) => { + sent = []; + const e = { + code, + key, + keyCode: 0, + isComposing: false, + shiftKey: mods.includes('shift'), + ctrlKey: mods.includes('ctrl'), + altKey: mods.includes('alt'), + metaKey: mods.includes('meta'), + preventDefault: () => {}, + stopPropagation: () => {}, + }; + for (const listener of listeners.get('keydown') ?? []) listener(e); + return sent.join(''); + }; + } + + test('Home and End follow application cursor mode, like the arrows', async () => { + const normal = await keys(false); + expect(normal('Home', 'Home')).toBe('\x1b[H'); + expect(normal('End', 'End')).toBe('\x1b[F'); + expect(normal('ArrowUp', 'ArrowUp')).toBe('\x1b[A'); + const app = await keys(true); + expect(app('Home', 'Home')).toBe('\x1bOH'); + expect(app('End', 'End')).toBe('\x1bOF'); + expect(app('ArrowUp', 'ArrowUp')).toBe('\x1bOA'); + }); + + test('navigation keys carry their modifiers', async () => { + const send = await keys(); + expect(send('Home', 'Home', ['shift'])).toBe('\x1b[1;2H'); + expect(send('End', 'End', ['ctrl'])).toBe('\x1b[1;5F'); + expect(send('PageUp', 'PageUp')).toBe('\x1b[5~'); + expect(send('PageDown', 'PageDown')).toBe('\x1b[6~'); + expect(send('PageUp', 'PageUp', ['shift'])).toBe('\x1b[5;2~'); + expect(send('Insert', 'Insert')).toBe('\x1b[2~'); + expect(send('Delete', 'Delete')).toBe('\x1b[3~'); + expect(send('Delete', 'Delete', ['shift'])).toBe('\x1b[3;2~'); + expect(send('Delete', 'Delete', ['alt'])).toBe('\x1b[3;3~'); + }); + + test('function keys carry their modifiers', async () => { + const send = await keys(); + expect(send('F1', 'F1')).toBe('\x1bOP'); + expect(send('F4', 'F4')).toBe('\x1bOS'); + expect(send('F5', 'F5')).toBe('\x1b[15~'); + expect(send('F12', 'F12')).toBe('\x1b[24~'); + expect(send('F1', 'F1', ['shift'])).toBe('\x1b[1;2P'); + expect(send('F5', 'F5', ['ctrl'])).toBe('\x1b[15;5~'); + }); + + test('Enter, Tab and Shift+Tab', async () => { + const send = await keys(); + expect(send('Enter', 'Enter')).toBe('\r'); + expect(send('Enter', 'Enter', ['shift'])).toBe('\r'); + expect(send('Enter', 'Enter', ['alt'])).toBe('\x1b\r'); + expect(send('Tab', 'Tab')).toBe('\t'); + expect(send('Tab', 'Tab', ['shift'])).toBe('\x1b[Z'); + }); + + test('Backspace sends DEL, ESC DEL with Alt and BS with Ctrl', async () => { + const send = await keys(); + expect(send('Backspace', 'Backspace')).toBe('\x7f'); + expect(send('Backspace', 'Backspace', ['shift'])).toBe('\x7f'); + expect(send('Backspace', 'Backspace', ['alt'])).toBe('\x1b\x7f'); + expect(send('Backspace', 'Backspace', ['ctrl'])).toBe('\b'); + }); + + test('Escape sends ESC, unmodified or with Shift', async () => { + const send = await keys(); + expect(send('Escape', 'Escape')).toBe('\x1b'); + expect(send('Escape', 'Escape', ['shift'])).toBe('\x1b'); + expect(send('Escape', 'Escape', ['alt'])).toBe('\x1b\x1b'); + }); + + test('keypad digits and operators send their characters', async () => { + const send = await keys(); + expect(send('Numpad1', '1')).toBe('1'); + expect(send('NumpadAdd', '+')).toBe('+'); + expect(send('NumpadEnter', 'Enter')).toBe('\r'); + }); +}); From d2fa4e8589fb80f90ac5787aecb057c1b6b9fe4a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:53:11 -0400 Subject: [PATCH 060/295] refactor(client): remove the input handler's paste path webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no insertFromPaste, so the handler's paste listeners, handlePaste and paste de-duplication never ran. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/input-handler.ts | 109 +----------------- src/client/vendor/input-handler.test.ts | 42 +++++++ 3 files changed, 46 insertions(+), 106 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index c5cd92f..cd445e1 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -27,6 +27,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. - `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). +- `input-handler.ts`: the paste path is removed (the `paste` listeners, `handlePaste`, the `insertFromPaste` case of `handleBeforeInput`, `emitPasteData` and the paste de-duplication). It never ran: webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no `insertFromPaste`. - `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 183eb49..4108969 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -228,7 +228,6 @@ export class InputHandler { private mouseConfig?: MouseTrackingConfig; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; - private pasteListener: ((e: ClipboardEvent) => void) | null = null; private beforeInputListener: ((e: InputEvent) => void) | null = null; private compositionStartListener: ((e: CompositionEvent) => void) | null = null; private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null; @@ -242,9 +241,6 @@ export class InputHandler { private lastMouseCell: { col: number; row: number } | null = null; // Cell of the last mouse report private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; - private lastPasteData: string | null = null; - private lastPasteTime = 0; - private lastPasteSource: 'paste' | 'beforeinput' | null = null; private lastCompositionData: string | null = null; private lastCompositionTime = 0; private lastBeforeInputData: string | null = null; @@ -320,11 +316,9 @@ export class InputHandler { this.keydownListener = this.handleKeyDown.bind(this); this.container.addEventListener('keydown', this.keydownListener); - this.pasteListener = this.handlePaste.bind(this); - this.container.addEventListener('paste', this.pasteListener); - if (this.inputElement && this.inputElement !== this.container) { - this.inputElement.addEventListener('paste', this.pasteListener); - } + // Paste is not handled here: webtty's capture listener on the container takes every + // paste event first and sends it (ADR 037), and a cancelled paste fires no + // insertFromPaste beforeinput. (webtty) if (this.inputElement) { this.beforeInputListener = this.handleBeforeInput.bind(this); @@ -522,39 +516,6 @@ export class InputHandler { } } - /** - * Handle paste event from clipboard - * @param event - ClipboardEvent - */ - private handlePaste(event: ClipboardEvent): void { - if (this.isDisposed) return; - - // Prevent default paste behavior - event.preventDefault(); - event.stopPropagation(); - - // Get clipboard data - const clipboardData = event.clipboardData; - if (!clipboardData) { - console.warn('No clipboard data available'); - return; - } - - // Get text from clipboard - const text = clipboardData.getData('text/plain'); - if (!text) { - console.warn('No text in clipboard'); - return; - } - - if (this.shouldIgnorePasteEvent(text, 'paste')) { - return; - } - - this.emitPasteData(text); - this.recordPasteData(text, 'paste'); - } - /** * Handle beforeinput event (mobile/IME input) * @param event - InputEvent @@ -585,20 +546,6 @@ export class InputHandler { case 'deleteContentForward': output = '\x1B[3~'; break; - case 'insertFromPaste': - if (!data) { - return; - } - if (this.shouldIgnorePasteEvent(data, 'beforeinput')) { - event.preventDefault(); - event.stopPropagation(); - return; - } - event.preventDefault(); - event.stopPropagation(); - this.emitPasteData(data); - this.recordPasteData(data, 'beforeinput'); - return; default: return; } @@ -853,19 +800,6 @@ export class InputHandler { this.sendMouseEvent(button, cell.col, cell.row, false, event); } - /** - * Emit paste data with bracketed paste support - */ - private emitPasteData(text: string): void { - const hasBracketedPaste = this.getModeCallback?.(2004) ?? false; - - if (hasBracketedPaste) { - this.onDataCallback('\x1b[200~' + text + '\x1b[201~'); - } else { - this.onDataCallback(text); - } - } - /** * Record keydown data for beforeinput de-duplication */ @@ -874,15 +808,6 @@ export class InputHandler { this.lastKeyDownTime = this.getNow(); } - /** - * Record paste data for beforeinput de-duplication - */ - private recordPasteData(data: string, source: 'paste' | 'beforeinput'): void { - this.lastPasteData = data; - this.lastPasteTime = this.getNow(); - this.lastPasteSource = source; - } - /** * Check if beforeinput should be ignored due to a recent keydown */ @@ -948,26 +873,6 @@ export class InputHandler { this.lastCompositionTime = this.getNow(); } - /** - * Check if paste should be ignored due to a recent paste event from another source - */ - private shouldIgnorePasteEvent(data: string, source: 'paste' | 'beforeinput'): boolean { - if (!this.lastPasteData) { - return false; - } - if (this.lastPasteSource === source) { - return false; - } - const now = this.getNow(); - const isDuplicate = - now - this.lastPasteTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastPasteData === data; - if (isDuplicate) { - this.lastPasteData = null; - this.lastPasteSource = null; - } - return isDuplicate; - } - /** * Get current time in milliseconds */ @@ -993,14 +898,6 @@ export class InputHandler { this.keypressListener = null; } - if (this.pasteListener) { - this.container.removeEventListener('paste', this.pasteListener); - if (this.inputElement && this.inputElement !== this.container) { - this.inputElement.removeEventListener('paste', this.pasteListener); - } - this.pasteListener = null; - } - if (this.beforeInputListener && this.inputElement) { this.inputElement.removeEventListener('beforeinput', this.beforeInputListener); this.beforeInputListener = null; diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index e6bb222..87323df 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -93,6 +93,48 @@ describe('InputHandler wheel', () => { }); }); +describe('InputHandler paste', () => { + // webtty's capture listener on the container takes every paste and stops it (ADR 037), + // so the handler's own paste path never ran. + test('sends nothing for a paste or an insertFromPaste beforeinput', async () => { + const ghostty = await Ghostty.load(WASM); + const listeners = new Map void)[]>(); + const element = { + addEventListener: (type: string, listener: (e: object) => void) => + listeners.set(type, [...(listeners.get(type) ?? []), listener]), + removeEventListener: () => {}, + } as unknown as HTMLElement; + const sent: string[] = []; + const handler = new InputHandler( + ghostty, + element, + (data) => sent.push(data), + () => {}, + undefined, + undefined, + () => true, + undefined, + element, + ); + const paste = { + clipboardData: { getData: () => 'a\nb' }, + preventDefault: () => {}, + stopPropagation: () => {}, + }; + for (const listener of listeners.get('paste') ?? []) listener(paste); + const input = { + inputType: 'insertFromPaste', + data: 'a\nb', + isComposing: false, + preventDefault: () => {}, + stopPropagation: () => {}, + }; + for (const listener of listeners.get('beforeinput') ?? []) listener(input); + expect(sent).toEqual([]); + handler.dispose(); + }); +}); + describe('plainKeySequence', () => { test('sends CR and ESC for Shift+Enter and Shift+Escape, as xterm does', () => { expect(plainKeySequence(Key.ENTER, Mods.SHIFT)).toBe('\r'); From 9c7ffc35f913379808df805c2cc51d0553abf849 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 21:59:48 -0400 Subject: [PATCH 061/295] refactor(client): remove the terminal API webtty does not use Nothing in webtty calls the terminal's unicode, onKey, onRender, onCursorMove, writeln, paste, input, clear, blur, programmatic selection, custom key handler, link provider registration, scrollPages, scrollToTop, hasBracketedPaste or hasFocusEvents, the selection manager's matching methods and dirty-row set, or init(). The drag auto-scroll, the one caller of scrollLines, now calls it typed so the call is visible. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/index.ts | 53 ---- .../vendor/ghostty-web/input-handler.ts | 42 --- src/client/vendor/ghostty-web/interfaces.ts | 29 +- src/client/vendor/ghostty-web/renderer.ts | 1 - .../vendor/ghostty-web/selection-manager.ts | 194 +------------- src/client/vendor/ghostty-web/terminal.ts | 247 +----------------- src/client/vendor/input-handler.test.ts | 9 - src/client/vendor/renderer.test.ts | 2 - src/client/vendor/selection-manager.test.ts | 21 +- 10 files changed, 31 insertions(+), 568 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index cd445e1..efee57f 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -31,4 +31,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. +- `terminal.ts`, `input-handler.ts`, `selection-manager.ts`, `interfaces.ts`, `index.ts`, `renderer.ts`: xterm.js API nothing in webtty reaches is removed. `terminal.ts`: `unicode`, `onKey`, `onRender` (never fired), `onCursorMove` and its per-frame cursor check, `writeln`, `paste`, `input`, `clear`, `blur`, `hasSelection`, `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `registerLinkProvider`, `scrollPages`, `scrollToTop`, `hasBracketedPaste` and `hasFocusEvents`. `input-handler.ts`: the `onBell`, `onKey` and `customKeyEventHandler` constructor arguments, `setCustomKeyEventHandler` and `isActive`. `selection-manager.ts`: `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `deselect`, `focus`, `getSelectionCoords`, `normalizeSelection` and the dirty-row set nothing read (`getDirtySelectionRows`, `clearDirtySelectionRows`, `markCurrentSelectionDirty`); the drag auto-scroll calls the terminal's `scrollLines` typed instead of through `any`. `interfaces.ts`: `IKeyEvent`, `IUnicodeVersionProvider` and `IBufferRange`. `index.ts`: `init()` and its module-level instance, so `ITerminalOptions.ghostty` is required. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index 0e12003..2a9b4af 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -4,56 +4,6 @@ * Main entry point following xterm.js conventions */ -import { Ghostty } from './ghostty'; - -// Module-level Ghostty instance (initialized by init()) -let ghosttyInstance: Ghostty | null = null; - -/** - * Initialize the ghostty-web library by loading the WASM module. - * Must be called before creating any Terminal instances. - * - * This creates a shared WASM instance that all Terminal instances will use. - * For test isolation, pass a Ghostty instance directly to Terminal constructor. - * - * @example - * ```typescript - * import { init, Terminal } from 'ghostty-web'; - * - * await init(); - * const term = new Terminal(); - * term.open(document.getElementById('terminal')); - * ``` - */ -export async function init(): Promise { - if (ghosttyInstance) { - return; // Already initialized - } - ghosttyInstance = await Ghostty.load(); -} - -/** - * Get the initialized Ghostty instance. - * Throws if init() hasn't been called. - * @internal - */ -export function getGhostty(): Ghostty { - if (!ghosttyInstance) { - throw new Error( - 'ghostty-web not initialized. Call init() before creating Terminal instances.\n' + - 'Example:\n' + - ' import { init, Terminal } from "ghostty-web";\n' + - ' await init();\n' + - ' const term = new Terminal();\n\n' + - 'For tests, pass a Ghostty instance directly:\n' + - ' import { Ghostty, Terminal } from "ghostty-web";\n' + - ' const ghostty = await Ghostty.load();\n' + - ' const term = new Terminal({ ghostty });' - ); - } - return ghosttyInstance; -} - // Main Terminal class export { Terminal } from './terminal'; @@ -65,9 +15,6 @@ export type { ITerminalCore, IDisposable, IEvent, - IBufferRange, - IKeyEvent, - IUnicodeVersionProvider, } from './interfaces'; // Ghostty WASM components (for advanced usage) diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 4108969..c6157a5 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -15,7 +15,6 @@ import type { Ghostty } from './ghostty'; import type { KeyEncoder } from './ghostty'; -import type { IKeyEvent } from './interfaces'; import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; /** @@ -220,9 +219,6 @@ export class InputHandler { private container: HTMLElement; private inputElement?: HTMLElement; private onDataCallback: (data: string) => void; - private onBellCallback: () => void; - private onKeyCallback?: (keyEvent: IKeyEvent) => void; - private customKeyEventHandler?: (event: KeyboardEvent) => boolean; private getModeCallback?: (mode: number) => boolean; private onCopyCallback?: () => boolean; private mouseConfig?: MouseTrackingConfig; @@ -252,9 +248,6 @@ export class InputHandler { * @param ghostty - Ghostty instance (for creating KeyEncoder) * @param container - DOM element to attach listeners to * @param onData - Callback for terminal data (escape sequences to send to PTY) - * @param onBell - Callback for bell/beep event - * @param onKey - Optional callback for raw key events - * @param customKeyEventHandler - Optional custom key event handler * @param getMode - Optional callback to query terminal mode state (for application cursor mode) * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) * @param inputElement - Optional input element for beforeinput events @@ -264,9 +257,6 @@ export class InputHandler { ghostty: Ghostty, container: HTMLElement, onData: (data: string) => void, - onBell: () => void, - onKey?: (keyEvent: IKeyEvent) => void, - customKeyEventHandler?: (event: KeyboardEvent) => boolean, getMode?: (mode: number) => boolean, onCopy?: () => boolean, inputElement?: HTMLElement, @@ -276,9 +266,6 @@ export class InputHandler { this.container = container; this.inputElement = inputElement; this.onDataCallback = onData; - this.onBellCallback = onBell; - this.onKeyCallback = onKey; - this.customKeyEventHandler = customKeyEventHandler; this.getModeCallback = getMode; this.onCopyCallback = onCopy; this.mouseConfig = mouseConfig; @@ -287,13 +274,6 @@ export class InputHandler { this.attach(); } - /** - * Set custom key event handler (for runtime updates) - */ - setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void { - this.customKeyEventHandler = handler; - } - /** * Attach keyboard event listeners to container */ @@ -407,21 +387,6 @@ export class InputHandler { return; } - // Emit onKey event first (before any processing) - if (this.onKeyCallback) { - this.onKeyCallback({ key: event.key, domEvent: event }); - } - - // Check custom key event handler - if (this.customKeyEventHandler) { - const handled = this.customKeyEventHandler(event); - if (handled) { - // Custom handler consumed the event - event.preventDefault(); - return; - } - } - // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault) if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { // Let the browser's native paste event fire @@ -935,11 +900,4 @@ export class InputHandler { this.isDisposed = true; } - - /** - * Check if handler is disposed - */ - isActive(): boolean { - return !this.isDisposed; - } } diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 278ca94..3113010 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -25,9 +25,9 @@ export interface ITerminalOptions { // Selection options copyOnSelect?: boolean; // Copy a mouse selection to the clipboard when it is made (default: true) (webtty) - // Internal: Ghostty WASM instance (optional, for test isolation) - // If not provided, uses the module-level instance from init() - ghostty?: Ghostty; + // The Ghostty WASM instance, from Ghostty.load() (webtty: upstream's init() and its + // module-level instance are removed) + ghostty: Ghostty; } export interface ITheme { @@ -75,29 +75,6 @@ export interface ITerminalCore { textarea?: HTMLTextAreaElement; } -/** - * Buffer range for selection coordinates - */ -export interface IBufferRange { - start: { x: number; y: number }; - end: { x: number; y: number }; -} - -/** - * Keyboard event with key and DOM event - */ -export interface IKeyEvent { - key: string; - domEvent: KeyboardEvent; -} - -/** - * Unicode version provider (xterm.js compatibility) - */ -export interface IUnicodeVersionProvider { - readonly activeVersion: string; -} - // ============================================================================ // Buffer API Interfaces (xterm.js compatibility) // ============================================================================ diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 0000c5b..d0fff68 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -631,7 +631,6 @@ export class CanvasRenderer { } } this.drawnSelection = this.currentSelectionCoords; - this.selectionManager?.clearDirtySelectionRows(); const rowAt = (k: number) => this.rowAt(k - this.overscan, band, rows, getLine, buffer, scrollbackProvider, scrollbackLength); diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index f412c40..4fc7716 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -48,11 +48,6 @@ export class SelectionManager { private dragThresholdMet: boolean = false; private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred - // Track rows that need redraw for clearing old selection - // Using a Set prevents the overwrite bug where mousemove would clobber - // the rows marked by clearSelection() - private dirtySelectionRows: Set = new Set(); - // Event emitter private selectionChangedEmitter = new EventEmitter(); @@ -239,14 +234,6 @@ export class SelectionManager { clearSelection(): void { if (!this.hasSelection()) return; - // Mark current selection rows as dirty for redraw - const coords = this.normalizeSelection(); - if (coords) { - for (let row = coords.startRow; row <= coords.endRow; row++) { - this.dirtySelectionRows.add(row); - } - } - this.selectionStart = null; this.selectionEnd = null; this.isSelecting = false; @@ -256,117 +243,8 @@ export class SelectionManager { } /** - * Select all text in the terminal - */ - selectAll(): void { - const dims = this.wasmTerm.getDimensions(); - const viewportY = this.getViewportY(); - this.selectionStart = { col: 0, absoluteRow: viewportY }; - this.selectionEnd = { col: dims.cols - 1, absoluteRow: viewportY + dims.rows - 1 }; - this.requestRender(); - this.selectionChangedEmitter.fire(); - } - - /** - * Select text at specific column and row with length - * xterm.js compatible API - */ - select(column: number, row: number, length: number): void { - // Clamp to valid ranges - const dims = this.wasmTerm.getDimensions(); - row = Math.max(0, Math.min(row, dims.rows - 1)); - column = Math.max(0, Math.min(column, dims.cols - 1)); - - // Calculate end position - let endRow = row; - let endCol = column + length - 1; - - // Handle wrapping if selection extends past end of line - while (endCol >= dims.cols) { - endCol -= dims.cols; - endRow++; - } - - // Clamp end row - endRow = Math.min(endRow, dims.rows - 1); - - // Convert viewport rows to absolute rows - const viewportY = this.getViewportY(); - this.selectionStart = { col: column, absoluteRow: viewportY + row }; - this.selectionEnd = { col: endCol, absoluteRow: viewportY + endRow }; - this.requestRender(); - this.selectionChangedEmitter.fire(); - } - - /** - * Select entire lines from start to end - * xterm.js compatible API - */ - selectLines(start: number, end: number): void { - const dims = this.wasmTerm.getDimensions(); - - // Clamp to valid row ranges - start = Math.max(0, Math.min(start, dims.rows - 1)); - end = Math.max(0, Math.min(end, dims.rows - 1)); - - // Ensure start <= end - if (start > end) { - [start, end] = [end, start]; - } - - // Convert viewport rows to absolute rows - this.selectionStart = { col: 0, absoluteRow: this.viewportRowToAbsolute(start) }; - this.selectionEnd = { col: dims.cols - 1, absoluteRow: this.viewportRowToAbsolute(end) }; - this.requestRender(); - this.selectionChangedEmitter.fire(); - } - - /** - * Get selection position as buffer range - * xterm.js compatible API - */ - getSelectionPosition(): - | { start: { x: number; y: number }; end: { x: number; y: number } } - | undefined { - const coords = this.normalizeSelection(); - if (!coords) return undefined; - - return { - start: { x: coords.startCol, y: coords.startRow }, - end: { x: coords.endCol, y: coords.endRow }, - }; - } - - /** - * Deselect all text - * xterm.js compatible API - */ - deselect(): void { - this.clearSelection(); - this.selectionChangedEmitter.fire(); - } - - /** - * Focus the terminal (make it receive keyboard input) - */ - focus(): void { - const canvas = this.renderer.getCanvas(); - if (canvas.parentElement) { - canvas.parentElement.focus(); - } - } - - /** - * Get current selection coordinates (for rendering) - */ - getSelectionCoords(): SelectionCoordinates | null { - return this.normalizeSelection(); - } - - /** - * The selection in viewport rows like getSelectionCoords, but not clamped to the - * viewport: rows above it are negative. The renderer also draws rows out of view. - * (webtty, ADR 043) + * The selection in viewport rows, not clamped to the viewport: rows above it are + * negative. The renderer also draws rows out of view. (webtty, ADR 043) */ getSelectionRange(): SelectionCoordinates | null { if (!this.selectionStart || !this.selectionEnd) return null; @@ -388,20 +266,6 @@ export class SelectionManager { }; } - /** - * Get dirty selection rows that need redraw (for clearing old highlight) - */ - getDirtySelectionRows(): Set { - return this.dirtySelectionRows; - } - - /** - * Clear the dirty selection rows tracking (after redraw) - */ - clearDirtySelectionRows(): void { - this.dirtySelectionRows.clear(); - } - /** * Get selection change event accessor */ @@ -501,9 +365,6 @@ export class SelectionManager { this.dragThresholdMet = true; } - // Mark current selection rows as dirty before updating - this.markCurrentSelectionDirty(); - const cell = this.pixelToCell(e.offsetX, e.offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionEnd = { col: cell.col, absoluteRow }; @@ -577,9 +438,6 @@ export class SelectionManager { // Only update selection position if NOT auto-scrolling // During auto-scroll, the scroll handler extends the selection if (this.autoScrollDirection === 0) { - // Mark current selection rows as dirty before updating - this.markCurrentSelectionDirty(); - const cell = this.pixelToCell(offsetX, offsetY); const absoluteRow = this.viewportRowToAbsolute(cell.row); this.selectionEnd = { col: cell.col, absoluteRow }; @@ -760,18 +618,6 @@ export class SelectionManager { document.addEventListener('click', this.boundClickHandler); } - /** - * Mark current selection rows as dirty for redraw - */ - private markCurrentSelectionDirty(): void { - const coords = this.normalizeSelection(); - if (coords) { - for (let row = coords.startRow; row <= coords.endRow; row++) { - this.dirtySelectionRows.add(row); - } - } - } - /** * Update auto-scroll based on mouse Y position within canvas */ @@ -816,7 +662,7 @@ export class SelectionManager { // scrollLines convention: negative = scroll up into history, positive = scroll down to newer // So direction maps directly to scrollLines sign const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection; - (this.terminal as any).scrollLines(scrollAmount); + this.terminal.scrollLines(scrollAmount); // Extend selection in the scroll direction // Key insight: we need to EXTEND the selection, not reset it to viewport edge @@ -871,37 +717,6 @@ export class SelectionManager { }; } - /** - * Normalize selection coordinates (handle backward selection) - * Returns coordinates in VIEWPORT space for rendering, clamped to visible area - */ - private normalizeSelection(): SelectionCoordinates | null { - const range = this.getSelectionRange(); - if (!range) return null; - let { startCol, startRow, endCol, endRow } = range; - - // Clamp to visible viewport range - const dims = this.wasmTerm.getDimensions(); - const maxRow = dims.rows - 1; - - // If entire selection is outside viewport, return null - if (endRow < 0 || startRow > maxRow) { - return null; - } - - // Clamp rows to visible range, adjusting columns for partial rows - if (startRow < 0) { - startRow = 0; - startCol = 0; // Selection starts from beginning of first visible row - } - if (endRow > maxRow) { - endRow = maxRow; - endCol = dims.cols - 1; // Selection extends to end of last visible row - } - - return { startCol, startRow, endCol, endRow }; - } - /** * Get word boundaries at a cell position */ @@ -1060,8 +875,5 @@ export class SelectionManager { private requestRender(): void { // The render loop will automatically pick up the new selection state // and redraw the affected lines. This happens at 60fps. - // - // Note: When clearSelection() is called, it adds dirty rows to dirtySelectionRows - // which the renderer can use to know which lines to redraw. } } diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 4871b05..370f67a 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -5,10 +5,9 @@ * * Usage: * ```typescript - * import { init, Terminal } from 'ghostty-web'; + * import { Ghostty, Terminal } from 'ghostty-web'; * - * await init(); - * const term = new Terminal(); + * const term = new Terminal({ ghostty: await Ghostty.load() }); * term.open(document.getElementById('container')); * term.write('Hello, World!\n'); * term.onData(data => console.log('User typed:', data)); @@ -18,26 +17,22 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; -import { getGhostty } from './index'; import { AT_ONCE, type Pace } from './glide'; import { InputHandler, type MouseTrackingConfig } from './input-handler'; import type { IBufferNamespace, - IBufferRange, IDisposable, IEvent, - IKeyEvent, ITerminalAddon, ITerminalCore, ITerminalOptions, - IUnicodeVersionProvider, } from './interfaces'; import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; -import type { ILink, ILinkProvider } from './types'; +import type { ILink } from './types'; import { TickPacer, type WheelAccumulator, @@ -156,13 +151,6 @@ export class Terminal implements ITerminalCore { // Buffer API (xterm.js compatibility) public readonly buffer: IBufferNamespace; - // Unicode API (xterm.js compatibility) - public readonly unicode: IUnicodeVersionProvider = { - get activeVersion(): string { - return '15.1'; // Ghostty supports Unicode 15.1 - }, - }; - // Options (public for xterm.js compatibility) public readonly options!: Required; @@ -185,21 +173,15 @@ export class Terminal implements ITerminalCore { private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); private bellEmitter = new EventEmitter(); private selectionChangeEmitter = new EventEmitter(); - private keyEmitter = new EventEmitter(); private titleChangeEmitter = new EventEmitter(); private scrollEmitter = new EventEmitter(); - private renderEmitter = new EventEmitter<{ start: number; end: number }>(); - private cursorMoveEmitter = new EventEmitter(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent = this.dataEmitter.event; public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; public readonly onBell: IEvent = this.bellEmitter.event; public readonly onSelectionChange: IEvent = this.selectionChangeEmitter.event; - public readonly onKey: IEvent = this.keyEmitter.event; public readonly onTitleChange: IEvent = this.titleChangeEmitter.event; public readonly onScroll: IEvent = this.scrollEmitter.event; - public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event; - public readonly onCursorMove: IEvent = this.cursorMoveEmitter.event; // Lifecycle state private isOpen = false; @@ -211,9 +193,6 @@ export class Terminal implements ITerminalCore { // Addons private addons: ITerminalAddon[] = []; - // Phase 1: Custom event handlers - private customKeyEventHandler?: (event: KeyboardEvent) => boolean; - // Phase 1: Title tracking private currentTitle: string = ''; @@ -225,7 +204,6 @@ export class Terminal implements ITerminalCore { private wheelPace?: (event: WheelEvent) => Pace; private beforeRender?: (now: number) => void; private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); - private lastCursorY: number = 0; // Track cursor position for onCursorMove // DECSCUSR state: the app's cursor shape (undefined = options) and the // unfinished sequence at the end of the last write private appCursorShape?: CursorShape; @@ -244,9 +222,8 @@ export class Terminal implements ITerminalCore { private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation - constructor(options: ITerminalOptions = {}) { - // Use provided Ghostty instance (for test isolation) or get module-level instance - this.ghostty = options.ghostty ?? getGhostty(); + constructor(options: ITerminalOptions) { + this.ghostty = options.ghostty; // Create base options object with all defaults (excluding ghostty) const baseOptions = { @@ -585,15 +562,6 @@ export class Terminal implements ITerminalCore { // Input handler fires data events this.dataEmitter.fire(data); }, - () => { - // Input handler can also fire bell - this.bellEmitter.fire(); - }, - (keyEvent: IKeyEvent) => { - // Forward key events - this.keyEmitter.fire(keyEvent); - }, - this.customKeyEventHandler, (mode: number) => { // Query terminal mode state (e.g., mode 1 for application cursor mode) return this.wasmTerm?.getMode(mode, false) ?? false; @@ -730,69 +698,6 @@ export class Terminal implements ITerminalCore { // Render will happen on next animation frame } - /** - * Write data with newline - */ - writeln(data: string | Uint8Array, callback?: () => void): void { - if (typeof data === 'string') { - this.write(data + '\r\n', callback); - } else { - // Append \r\n to Uint8Array - const newData = new Uint8Array(data.length + 2); - newData.set(data); - newData[data.length] = 0x0d; // \r - newData[data.length + 1] = 0x0a; // \n - this.write(newData, callback); - } - } - - /** - * Paste text into terminal (triggers bracketed paste if supported) - */ - paste(data: string): void { - this.assertOpen(); - - // Don't paste if stdin is disabled - if (this.options.disableStdin) { - return; - } - - this.awaitingEcho = true; - - // Check if terminal has bracketed paste mode enabled - if (this.wasmTerm!.hasBracketedPaste()) { - // Wrap with bracketed paste sequences (DEC mode 2004) - this.dataEmitter.fire('\x1b[200~' + data + '\x1b[201~'); - } else { - // Send data directly - this.dataEmitter.fire(data); - } - } - - /** - * Input data into terminal (as if typed by user) - * - * @param data - Data to input - * @param wasUserInput - If true, triggers onData event (default: false for compat with some apps) - */ - input(data: string, wasUserInput: boolean = false): void { - this.assertOpen(); - - // Don't input if stdin is disabled - if (this.options.disableStdin) { - return; - } - - if (wasUserInput) { - this.awaitingEcho = true; - // Trigger onData event as if user typed it - this.dataEmitter.fire(data); - } else { - // Just write to terminal without triggering onData - this.write(data); - } - } - /** * Resize terminal */ @@ -871,15 +776,6 @@ export class Terminal implements ITerminalCore { }; } - /** - * Clear terminal screen - */ - clear(): void { - this.assertOpen(); - // Send ANSI clear screen and cursor home sequences - this.wasmTerm!.write('\x1b[2J\x1b[H'); - } - /** * Reset terminal state */ @@ -933,15 +829,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Blur terminal (remove focus) - */ - blur(): void { - if (this.isOpen && this.element) { - this.element.blur(); - } - } - /** * Load an addon */ @@ -961,13 +848,6 @@ export class Terminal implements ITerminalCore { return this.selectionManager?.getSelection() || ''; } - /** - * Check if there's an active selection - */ - public hasSelection(): boolean { - return this.selectionManager?.hasSelection() || false; - } - /** * Clear the current selection */ @@ -984,29 +864,6 @@ export class Terminal implements ITerminalCore { } /** - * Select all text in the terminal - */ - public selectAll(): void { - this.selectionManager?.selectAll(); - } - - /** - * Select text at specific column and row with length - */ - public select(column: number, row: number, length: number): void { - this.selectionManager?.select(column, row, length); - } - - /** - * Select entire lines from start to end - */ - public selectLines(start: number, end: number): void { - this.selectionManager?.selectLines(start, end); - } - - /** - * Get selection position as buffer range - */ /** * Get the current viewport Y position. * @@ -1017,28 +874,10 @@ export class Terminal implements ITerminalCore { return this.viewportY; } - public getSelectionPosition(): IBufferRange | undefined { - return this.selectionManager?.getSelectionPosition(); - } - // ========================================================================== // Phase 1: Custom Event Handlers // ========================================================================== - /** - * Attach a custom keyboard event handler - * Returns true to prevent default handling - */ - public attachCustomKeyEventHandler( - customKeyEventHandler: (event: KeyboardEvent) => boolean - ): void { - this.customKeyEventHandler = customKeyEventHandler; - // Update input handler if already created - if (this.inputHandler) { - this.inputHandler.setCustomKeyEventHandler(customKeyEventHandler); - } - } - /** * Attach a custom wheel event handler (Phase 2) * Returns true to prevent default handling @@ -1066,31 +905,6 @@ export class Terminal implements ITerminalCore { this.beforeRender = callback; } - // ========================================================================== - // Link Detection Methods - // ========================================================================== - - /** - * Register a custom link provider - * Multiple providers can be registered to detect different types of links - * - * @example - * ```typescript - * term.registerLinkProvider({ - * provideLinks(y, callback) { - * // Detect URLs, file paths, etc. - * callback(detectedLinks); - * } - * }); - * ``` - */ - public registerLinkProvider(provider: ILinkProvider): void { - if (!this.linkDetector) { - throw new Error('Terminal must be opened before registering link providers'); - } - this.linkDetector.registerProvider(provider); - } - // ========================================================================== // Phase 2: Scrolling Methods // ========================================================================== @@ -1126,26 +940,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Scroll viewport by a number of pages - * @param amount Number of pages to scroll (positive = down, negative = up) - */ - public scrollPages(amount: number): void { - this.scrollLines(amount * this.rows); - } - - /** - * Scroll viewport to the top of the scrollback buffer - */ - public scrollToTop(): void { - const scrollbackLength = this.getScrollbackLength(); - if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) { - this.viewportY = scrollbackLength; - this.scrollEmitter.fire(this.viewportY); - this.showScrollbar(); - } - } - /** * Scroll viewport to the bottom (current output) */ @@ -1221,11 +1015,8 @@ export class Terminal implements ITerminalCore { this.resizeEmitter.dispose(); this.bellEmitter.dispose(); this.selectionChangeEmitter.dispose(); - this.keyEmitter.dispose(); this.titleChangeEmitter.dispose(); this.scrollEmitter.dispose(); - this.renderEmitter.dispose(); - this.cursorMoveEmitter.dispose(); } // ========================================================================== @@ -1317,21 +1108,9 @@ export class Terminal implements ITerminalCore { // Replies that arose outside write() reach the PTY within a frame (webtty, ADR 042) if (this.wasmTerm!.hasResponse()) this.processTerminalResponses(); - // Check for cursor movement (Phase 2: onCursorMove event) - // Note: getCursor() reads from already-updated render state (from render() above) - const cursor = this.wasmTerm!.getCursor(); - if (cursor.y !== this.lastCursorY) { - this.lastCursorY = cursor.y; - this.cursorMoveEmitter.fire(); - } - // Keep the input textarea on the cursor cell (webtty) this.anchorTextarea(); - // Note: onRender event is intentionally not fired in the render loop - // to avoid performance issues. For now, consumers can use requestAnimationFrame - // if they need frame-by-frame updates. - this.animationFrameId = requestAnimationFrame(loop); } }; @@ -1985,22 +1764,6 @@ export class Terminal implements ITerminalCore { return this.wasmTerm!.getMode(mode, isAnsi); } - /** - * Check if bracketed paste mode is enabled - */ - public hasBracketedPaste(): boolean { - this.assertOpen(); - return this.wasmTerm!.hasBracketedPaste(); - } - - /** - * Check if focus event reporting is enabled - */ - public hasFocusEvents(): boolean { - this.assertOpen(); - return this.wasmTerm!.hasFocusEvents(); - } - /** * Check if mouse tracking is enabled */ diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index 87323df..e8e494c 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -73,9 +73,6 @@ describe('InputHandler wheel', () => { ghostty, container, (data) => sent.push(data), - () => {}, - undefined, - undefined, undefined, undefined, undefined, @@ -109,9 +106,6 @@ describe('InputHandler paste', () => { ghostty, element, (data) => sent.push(data), - () => {}, - undefined, - undefined, () => true, undefined, element, @@ -167,9 +161,6 @@ describe('InputHandler keys', () => { ghostty, container, (data) => sent.push(data), - () => {}, - undefined, - undefined, (mode) => mode === 1 && appCursor, ); return (code: string, key: string, mods: string[] = []) => { diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 8e29efe..7226a11 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -633,7 +633,6 @@ describe('overscan and copied scrolls (ADR 043)', () => { endRow: selection.endLine - sb + view, }; }, - clearDirtySelectionRows: () => {}, } as unknown as Parameters[0]; let range: { startX: number; startY: number; endX: number; endY: number } | null = null; for (const overscan of [1, 3]) { @@ -861,7 +860,6 @@ describe('overscan and copied scrolls (ADR 043)', () => { endCol: selection.endCol, endRow: selection.endLine - sb + view, }), - clearDirtySelectionRows: () => {}, } as unknown as Parameters[0]; const range = { startX: 2, startY: 6, endX: 9, endY: 6 }; for (const overscan of [1, 4]) { diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index b343527..f4659aa 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -60,13 +60,17 @@ describe('SelectionManager copy on select', () => { }); /** A manager over the wasm terminal, counting the times it builds the selected text. */ - function manager(copyOnSelect: boolean) { - const canvas = target(); + function manager(copyOnSelect: boolean, scrolls: number[] = []) { + const canvas = { + ...target(), + getBoundingClientRect: () => ({ left: 0, top: 0, right: 160, bottom: 64 }), + }; const terminal = { cols: 20, rows: 4, options: { copyOnSelect }, getViewportY: () => 0, + scrollLines: (amount: number) => scrolls.push(amount), } as unknown as Terminal; const renderer = { getCanvas: () => canvas, @@ -108,6 +112,19 @@ describe('SelectionManager copy on select', () => { expect(writes).toEqual(['world', 'second line']); }); + // The terminal's scrollLines is reached only from here, so a call graph from + // src/client/index.ts does not show it. + test('a drag below the terminal scrolls it down through scrollLines', async () => { + const scrolls: number[] = []; + const { canvas } = manager(true, scrolls); + canvas.dispatch('mousedown', { button: 0, offsetX: 1, offsetY: 4 }); + documentTarget.dispatch('mousemove', { clientX: 40, clientY: 100 }); + await Bun.sleep(80); + documentTarget.dispatch('mouseup', {}); + expect(scrolls.length).toBeGreaterThan(0); + expect(scrolls.every((amount) => amount === 3)).toBe(true); + }); + test('nothing is copied or built with copyOnSelect off, but the change is reported', () => { const { canvas, builds, changes } = manager(false); drag(canvas); From 5ec10af1b1bcf4147dcce017f8a0d896d399f310 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:01:06 -0400 Subject: [PATCH 062/295] refactor(client): trim the buffer API to what the client reads The link providers and the kitty graphics read the active buffer's type, cursor, length and lines, and each cell's codepoint and hyperlink id. Remove the rest: onBufferChange, viewportY, baseY, getNullCell, isWrapped (which cost an isRowWrapped call per line), translateToString, the cell's text, colour and style getters, and types.ts's unused buffer interfaces. --- src/client/vendor/buffer.test.ts | 43 +++++ src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/buffer.ts | 187 +------------------- src/client/vendor/ghostty-web/interfaces.ts | 59 ------ src/client/vendor/ghostty-web/types.ts | 58 ------ 5 files changed, 49 insertions(+), 299 deletions(-) create mode 100644 src/client/vendor/buffer.test.ts diff --git a/src/client/vendor/buffer.test.ts b/src/client/vendor/buffer.test.ts new file mode 100644 index 0000000..bc0ade0 --- /dev/null +++ b/src/client/vendor/buffer.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BufferNamespace } from './ghostty-web/buffer'; +import { Ghostty } from './ghostty-web/ghostty'; +import type { Terminal } from './ghostty-web/terminal'; + +const WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'ghostty-vt/ghostty-vt-small.wasm', +); + +// The buffer API is what the link providers and the kitty graphics read: the +// cursor, the line count, and each cell's codepoint and hyperlink id. +describe('BufferNamespace', () => { + test('reads the cursor, scrollback and screen lines, codepoints and links', async () => { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(10, 2); + wasmTerm.write('old\r\nmid\r\n\x1b]8;;https://a.b\x1b\\ln\x1b]8;;\x1b\\k'); + const buffer = new BufferNamespace({ wasmTerm } as unknown as Terminal); + const active = buffer.active; + expect(active.type).toBe('normal'); + expect(active.length).toBe(3); + expect([active.cursorX, active.cursorY]).toEqual([3, 1]); + const text = (y: number) => { + const line = active.getLine(y); + if (!line) return undefined; + return Array.from({ length: 3 }, (_, x) => + String.fromCodePoint(line.getCell(x)?.getCodepoint() || 32), + ).join(''); + }; + expect([text(0), text(1), text(2), text(3)]).toEqual(['old', 'mid', 'lnk', undefined]); + const links = Array.from({ length: 3 }, (_, x) => + active.getLine(2)?.getCell(x)?.getHyperlinkId(), + ); + expect(links.map((id) => (id ?? 0) > 0)).toEqual([true, true, false]); + expect(active.getLine(2)?.getCell(10)).toBeUndefined(); + wasmTerm.write('\x1b[?1049h'); + expect(buffer.active.type).toBe('alternate'); + expect(buffer.active.length).toBe(2); + wasmTerm.free(); + }); +}); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index efee57f..04bda54 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -32,4 +32,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`, `interfaces.ts`, `index.ts`, `renderer.ts`: xterm.js API nothing in webtty reaches is removed. `terminal.ts`: `unicode`, `onKey`, `onRender` (never fired), `onCursorMove` and its per-frame cursor check, `writeln`, `paste`, `input`, `clear`, `blur`, `hasSelection`, `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `registerLinkProvider`, `scrollPages`, `scrollToTop`, `hasBracketedPaste` and `hasFocusEvents`. `input-handler.ts`: the `onBell`, `onKey` and `customKeyEventHandler` constructor arguments, `setCustomKeyEventHandler` and `isActive`. `selection-manager.ts`: `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `deselect`, `focus`, `getSelectionCoords`, `normalizeSelection` and the dirty-row set nothing read (`getDirtySelectionRows`, `clearDirtySelectionRows`, `markCurrentSelectionDirty`); the drag auto-scroll calls the terminal's `scrollLines` typed instead of through `any`. `interfaces.ts`: `IKeyEvent`, `IUnicodeVersionProvider` and `IBufferRange`. `index.ts`: `init()` and its module-level instance, so `ITerminalOptions.ghostty` is required. +- `buffer.ts`, `interfaces.ts`, `types.ts`: the buffer API keeps what the link providers and webtty's kitty graphics read (`active`, `normal`, `alternate`, a buffer's `type`, `cursorX`, `cursorY`, `length` and `getLine`, a line's `length` and `getCell`, a cell's `getHyperlinkId` and `getCodepoint`). Removed: `onBufferChange`, `viewportY`, `baseY`, `getNullCell`, a line's `isWrapped` (and the per-line `isRowWrapped` call that filled it) and `translateToString`, and the cell's text, colour and style getters. `types.ts` loses its unused copies of `IBuffer`, `IBufferLine`, `IBufferCell` and `IBufferManager`. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/buffer.ts b/src/client/vendor/ghostty-web/buffer.ts index 031a493..24cd72f 100644 --- a/src/client/vendor/ghostty-web/buffer.ts +++ b/src/client/vendor/ghostty-web/buffer.ts @@ -7,7 +7,7 @@ * Usage: * ```typescript * const cell = term.buffer.active.getLine(0)?.getCell(5); - * console.log(cell?.getChars(), cell?.isBold()); + * console.log(cell?.getCodepoint(), cell?.getHyperlinkId()); * * if (term.buffer.active.type === 'alternate') { * console.log('Full-screen app running'); @@ -15,17 +15,8 @@ * ``` */ -import { EventEmitter } from './event-emitter'; import type { GhosttyTerminal } from './ghostty'; -import { CellFlags } from './ghostty'; -import type { - IBuffer, - IBufferCell, - IBufferLine, - IBufferNamespace, - IDisposable, - IEvent, -} from './interfaces'; +import type { IBuffer, IBufferCell, IBufferLine, IBufferNamespace } from './interfaces'; import type { Terminal } from './terminal'; import type { GhosttyCell } from './types'; @@ -39,7 +30,6 @@ import type { GhosttyCell } from './types'; */ export class BufferNamespace implements IBufferNamespace { private terminal: Terminal; - private bufferChangeEmitter = new EventEmitter(); // Lazy-initialized buffer wrappers (stateless, so we can cache them) private _normalBuffer?: Buffer; @@ -72,18 +62,6 @@ export class BufferNamespace implements IBufferNamespace { } return this._alternateBuffer; } - - get onBufferChange(): IEvent { - return this.bufferChangeEmitter.event; - } - - /** - * Internal: Fire buffer change event when screen switches - * Should be called by Terminal when detecting screen change - */ - _fireBufferChange(buffer: IBuffer): void { - this.bufferChangeEmitter.fire(buffer); - } } // ============================================================================ @@ -96,27 +74,10 @@ export class BufferNamespace implements IBufferNamespace { export class Buffer implements IBuffer { private terminal: Terminal; private bufferType: 'normal' | 'alternate'; - private nullCell: BufferCell; constructor(terminal: Terminal, type: 'normal' | 'alternate') { this.terminal = terminal; this.bufferType = type; - - // Create a null cell (codepoint=0, default colors, no flags) - const nullCellData: GhosttyCell = { - codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, - bg_r: 0, - bg_g: 0, - bg_b: 0, - flags: 0, - width: 1, - hyperlink_id: 0, - grapheme_len: 0, - }; - this.nullCell = new BufferCell(nullCellData, 0); } get type(): 'normal' | 'alternate' { @@ -135,18 +96,6 @@ export class Buffer implements IBuffer { return wasmTerm.getCursor().y; } - get viewportY(): number { - // Get viewport offset from Terminal - // For now, return 0 (no scrollback navigation implemented yet) - return 0; - } - - get baseY(): number { - // For normal buffer: 0 - // For alternate buffer: 0 (alternate has no scrollback) - return 0; - } - get length(): number { const wasmTerm = this.getWasmTerm(); if (!wasmTerm) return 0; @@ -173,8 +122,6 @@ export class Buffer implements IBuffer { // Determine if accessing scrollback or visible screen const scrollbackLength = wasmTerm.getScrollbackLength(); let cells: GhosttyCell[] | null; - let lineNumber: number; - let isWrapped: boolean; if (this.bufferType === 'normal' && y < scrollbackLength) { // Accessing scrollback @@ -183,25 +130,16 @@ export class Buffer implements IBuffer { // So scrollbackOffset = y directly! const scrollbackOffset = y; cells = wasmTerm.getScrollbackLine(scrollbackOffset); - // TODO: We'd need WASM API to check if scrollback line is wrapped - // For now, assume not wrapped - isWrapped = false; } else { // Accessing visible screen - lineNumber = this.bufferType === 'normal' ? y - scrollbackLength : y; - cells = wasmTerm.getLine(lineNumber); - isWrapped = wasmTerm.isRowWrapped(lineNumber); + cells = wasmTerm.getLine(this.bufferType === 'normal' ? y - scrollbackLength : y); } if (!cells) { return undefined; } - return new BufferLine(cells, isWrapped, wasmTerm.cols); - } - - getNullCell(): IBufferCell { - return this.nullCell; + return new BufferLine(cells, wasmTerm.cols); } private getWasmTerm(): GhosttyTerminal | undefined { @@ -218,12 +156,10 @@ export class Buffer implements IBuffer { */ export class BufferLine implements IBufferLine { private cells: GhosttyCell[]; - private _isWrapped: boolean; private _length: number; - constructor(cells: GhosttyCell[], isWrapped: boolean, length: number) { + constructor(cells: GhosttyCell[], length: number) { this.cells = cells; - this._isWrapped = isWrapped; this._length = length; } @@ -231,10 +167,6 @@ export class BufferLine implements IBufferLine { return this._length; } - get isWrapped(): boolean { - return this._isWrapped; - } - getCell(x: number): IBufferCell | undefined { if (x < 0 || x >= this._length) { return undefined; @@ -262,27 +194,6 @@ export class BufferLine implements IBufferLine { return new BufferCell(this.cells[x], x); } - - translateToString(trimRight = false, startColumn = 0, endColumn = this._length): string { - // Clamp bounds - const start = Math.max(0, Math.min(startColumn, this._length)); - const end = Math.max(start, Math.min(endColumn, this._length)); - - let result = ''; - for (let x = start; x < end; x++) { - const cell = this.getCell(x); - if (cell) { - const chars = cell.getChars(); - result += chars; - } - } - - if (trimRight) { - result = result.trimEnd(); - } - - return result; - } } // ============================================================================ @@ -301,86 +212,6 @@ export class BufferCell implements IBufferCell { this.x = x; } - getChars(): string { - const codepoint = this.cell.codepoint; - - // Return empty string for null character or invalid codepoints - if (codepoint === 0) { - return ''; - } - - // Validate codepoint is within valid Unicode range - // Valid: 0x0000 to 0x10FFFF, excluding surrogates (0xD800-0xDFFF) - if (codepoint < 0 || codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)) { - // Return replacement character for invalid codepoints - return '\uFFFD'; - } - - return String.fromCodePoint(codepoint); - } - - getCode(): number { - return this.cell.codepoint; - } - - getWidth(): number { - return this.cell.width; - } - - getFgColorMode(): number { - // Return -1 for RGB (we always use RGB in our WASM implementation) - // xterm.js uses different values: - // 0 = default, 1 = palette 16, 2 = palette 256, 3 = RGB - // For simplicity, we return -1 for RGB - return -1; - } - - getBgColorMode(): number { - return -1; - } - - getFgColor(): number { - // Pack RGB into a single number: 0xRRGGBB - return (this.cell.fg_r << 16) | (this.cell.fg_g << 8) | this.cell.fg_b; - } - - getBgColor(): number { - // Pack RGB into a single number: 0xRRGGBB - return (this.cell.bg_r << 16) | (this.cell.bg_g << 8) | this.cell.bg_b; - } - - isBold(): number { - return (this.cell.flags & CellFlags.BOLD) !== 0 ? 1 : 0; - } - - isItalic(): number { - return (this.cell.flags & CellFlags.ITALIC) !== 0 ? 1 : 0; - } - - isUnderline(): number { - return (this.cell.flags & CellFlags.UNDERLINE) !== 0 ? 1 : 0; - } - - isStrikethrough(): number { - return (this.cell.flags & CellFlags.STRIKETHROUGH) !== 0 ? 1 : 0; - } - - isBlink(): number { - return (this.cell.flags & CellFlags.BLINK) !== 0 ? 1 : 0; - } - - isInverse(): number { - return (this.cell.flags & CellFlags.INVERSE) !== 0 ? 1 : 0; - } - - isInvisible(): number { - return (this.cell.flags & CellFlags.INVISIBLE) !== 0 ? 1 : 0; - } - - isFaint(): number { - return (this.cell.flags & CellFlags.FAINT) !== 0 ? 1 : 0; - } - /** * Get hyperlink ID for this cell (0 = no link) * Used by link detection system @@ -396,12 +227,4 @@ export class BufferCell implements IBufferCell { getCodepoint(): number { return this.cell.codepoint; } - - /** - * Check if cell has dim/faint attribute - * Added for IBufferCell compatibility - */ - isDim(): boolean { - return (this.cell.flags & CellFlags.FAINT) !== 0; - } } diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 3113010..8d0e202 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -90,9 +90,6 @@ export interface IBufferNamespace { readonly normal: IBuffer; /** The alternate buffer (used by full-screen apps like vim) */ readonly alternate: IBuffer; - - /** Event fired when buffer changes (normal ↔ alternate) */ - readonly onBufferChange: IEvent; } /** @@ -105,10 +102,6 @@ export interface IBuffer { readonly cursorX: number; /** Cursor Y position (0-indexed, relative to viewport) */ readonly cursorY: number; - /** Viewport Y position (scroll offset, 0 = bottom of scrollback) */ - readonly viewportY: number; - /** Base Y position (always 0 for normal buffer, may vary for alternate) */ - readonly baseY: number; /** Total buffer length (rows + scrollback for normal, just rows for alternate) */ readonly length: number; @@ -118,11 +111,6 @@ export interface IBuffer { * @returns Line object or undefined if out of bounds */ getLine(y: number): IBufferLine | undefined; - - /** - * Get the null cell (used for empty/uninitialized cells) - */ - getNullCell(): IBufferCell; } /** @@ -131,8 +119,6 @@ export interface IBuffer { export interface IBufferLine { /** Length of the line (in columns) */ readonly length: number; - /** Whether this line wraps to the next line */ - readonly isWrapped: boolean; /** * Get a cell from this line @@ -140,59 +126,14 @@ export interface IBufferLine { * @returns Cell object or undefined if out of bounds */ getCell(x: number): IBufferCell | undefined; - - /** - * Translate the line to a string - * @param trimRight Whether to trim trailing whitespace (default: false) - * @param startColumn Start column (default: 0) - * @param endColumn End column (default: length) - * @returns String representation of the line - */ - translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; } /** * A single cell in the buffer */ export interface IBufferCell { - /** Character(s) in this cell (may be empty, single char, or emoji) */ - getChars(): string; - /** Unicode codepoint (0 for null cell) */ - getCode(): number; - /** Character width (1 = normal, 2 = wide/emoji, 0 = combining) */ - getWidth(): number; - - /** Foreground color index (for palette colors) or -1 for RGB */ - getFgColorMode(): number; - /** Background color index (for palette colors) or -1 for RGB */ - getBgColorMode(): number; - /** Foreground RGB color (or 0 for default) */ - getFgColor(): number; - /** Background RGB color (or 0 for default) */ - getBgColor(): number; - - /** Whether cell has bold style */ - isBold(): number; - /** Whether cell has italic style */ - isItalic(): number; - /** Whether cell has underline style */ - isUnderline(): number; - /** Whether cell has strikethrough style */ - isStrikethrough(): number; - /** Whether cell has blink style */ - isBlink(): number; - /** Whether cell has inverse video style */ - isInverse(): number; - /** Whether cell has invisible style */ - isInvisible(): number; - /** Whether cell has faint/dim style */ - isFaint(): number; - - // Link detection support /** Get hyperlink ID for this cell (0 = no link) */ getHyperlinkId(): number; /** Get the Unicode codepoint for this cell */ getCodepoint(): number; - /** Whether cell has dim/faint attribute (boolean version) */ - isDim(): boolean; } diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index de5fcd5..28c3d85 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -517,64 +517,6 @@ export interface ILinkProvider { dispose?(): void; } -/** - * Simplified buffer line interface for link providers - */ -export interface IBufferLine { - /** Number of cells in this line */ - length: number; - - /** Get cell at position */ - getCell(x: number): IBufferCell; - - /** Get text content of the line */ - translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; -} - -/** - * Simplified buffer cell interface for link providers - */ -export interface IBufferCell { - /** Get the character codepoint */ - getCodepoint(): number; - - /** Get the hyperlink ID (0 = no link) */ - getHyperlinkId(): number; - - /** Get the width of the character (1 or 2 for wide chars) */ - getWidth(): number; - - /** Check if cell has specific flags */ - isBold(): boolean; - isItalic(): boolean; - isDim(): boolean; -} - -/** - * Simplified terminal buffer interface for link providers - */ -export interface IBuffer { - /** Number of rows in the buffer (viewport + scrollback) */ - length: number; - - /** Get line at absolute buffer position */ - getLine(y: number): IBufferLine; -} - -/** - * Terminal buffer manager (active vs alternate screen) - */ -export interface IBufferManager { - /** Currently active buffer */ - active: IBuffer; - - /** Normal screen buffer */ - normal: IBuffer; - - /** Alternate screen buffer (for fullscreen apps) */ - alternate: IBuffer; -} - /** * Event system interface (xterm.js compatible) */ From 7d4c7c9728b3df7db772676e84867cd075bd0527 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:02:41 -0400 Subject: [PATCH 063/295] refactor(client): remove unused engine types and binding methods Nothing in webtty calls the binding's getColors, hasBracketedPaste, hasFocusEvents or setKittyFlags, or uses types.ts's SGR types, KittyKeyFlags, RenderStateColors, RGB, Cursor, TerminalConfig, TerminalMode or its second EventEmitter. The refresh no longer reads the cursor colour only getColors returned. The binding tests check colours through OSC 10/11 and cells, and modes through getMode. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 40 ---- src/client/vendor/ghostty-web/index.ts | 2 +- src/client/vendor/ghostty-web/types.ts | 206 -------------------- src/client/vendor/official-terminal.test.ts | 28 +-- 5 files changed, 5 insertions(+), 272 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 04bda54..1d7ebac 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -33,4 +33,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`, `interfaces.ts`, `index.ts`, `renderer.ts`: xterm.js API nothing in webtty reaches is removed. `terminal.ts`: `unicode`, `onKey`, `onRender` (never fired), `onCursorMove` and its per-frame cursor check, `writeln`, `paste`, `input`, `clear`, `blur`, `hasSelection`, `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `registerLinkProvider`, `scrollPages`, `scrollToTop`, `hasBracketedPaste` and `hasFocusEvents`. `input-handler.ts`: the `onBell`, `onKey` and `customKeyEventHandler` constructor arguments, `setCustomKeyEventHandler` and `isActive`. `selection-manager.ts`: `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `deselect`, `focus`, `getSelectionCoords`, `normalizeSelection` and the dirty-row set nothing read (`getDirtySelectionRows`, `clearDirtySelectionRows`, `markCurrentSelectionDirty`); the drag auto-scroll calls the terminal's `scrollLines` typed instead of through `any`. `interfaces.ts`: `IKeyEvent`, `IUnicodeVersionProvider` and `IBufferRange`. `index.ts`: `init()` and its module-level instance, so `ITerminalOptions.ghostty` is required. - `buffer.ts`, `interfaces.ts`, `types.ts`: the buffer API keeps what the link providers and webtty's kitty graphics read (`active`, `normal`, `alternate`, a buffer's `type`, `cursorX`, `cursorY`, `length` and `getLine`, a line's `length` and `getCell`, a cell's `getHyperlinkId` and `getCodepoint`). Removed: `onBufferChange`, `viewportY`, `baseY`, `getNullCell`, a line's `isWrapped` (and the per-line `isRowWrapped` call that filled it) and `translateToString`, and the cell's text, colour and style getters. `types.ts` loses its unused copies of `IBuffer`, `IBufferLine`, `IBufferCell` and `IBufferManager`. +- `ghostty.ts`, `types.ts`, `index.ts`: engine API nothing in webtty reaches is removed: `getColors` (and the render-state cursor colour only it returned), `hasBracketedPaste` and `hasFocusEvents` (`getMode(2004)` and `getMode(1004)` remain), and the key encoder's `setKittyFlags`; in `types.ts` the SGR types (`SgrAttributeTag`, `SgrAttribute`, `RGBColor`), `KittyKeyFlags`, `KeyEvent`'s `consumedMods`, `composing` and `unshiftedCodepoint` (the binding never read them), `RenderStateColors`, `RGB`, `Cursor`, the Coder-era `TerminalConfig`, `TerminalMode`, and a second, unused `EventEmitter` with its `IEvent` and `IDisposable`. The `Key`, `KeyAction`, `KeyEncoderOption` and `Mods` enums are kept whole: the binding translates them to the official enums by name at load. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 59b9ff5..0b159ec 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -14,7 +14,6 @@ import { FnTable, type InstalledFn, WasmValType } from './fn-table'; import { CellFlags, - type Cursor, DirtyState, type GhosttyCell, type GhosttyTerminalConfig, @@ -22,22 +21,16 @@ import { KeyAction, KeyEncoderOption, type KeyEvent, - type KittyKeyFlags, - type RGB, - type RenderStateColors, type RenderStateCursor, } from './types'; // Re-export types for convenience export { CellFlags, - type Cursor, DirtyState, type GhosttyCell, type GhosttyTerminalConfig, KeyEncoderOption, - type RGB, - type RenderStateColors, type RenderStateCursor, }; @@ -73,7 +66,6 @@ export interface GhosttyTerminal { // Render state update(): DirtyState; getCursor(): RenderStateCursor; - getColors(): RenderStateColors; isRowDirty(y: number): boolean; markClean(): void; getViewport(): GhosttyCell[]; @@ -84,8 +76,6 @@ export interface GhosttyTerminal { // Modes isAlternateScreen(): boolean; - hasBracketedPaste(): boolean; - hasFocusEvents(): boolean; hasMouseTracking(): boolean; getMode(mode: number, isAnsi?: boolean): boolean; @@ -108,7 +98,6 @@ export interface GhosttyTerminal { /** Converts keyboard events into terminal escape sequences. */ export interface KeyEncoder { setOption(option: KeyEncoderOption, value: boolean | number): void; - setKittyFlags(flags: KittyKeyFlags): void; encode(event: KeyEvent): Uint8Array; dispose(): void; } @@ -485,8 +474,6 @@ function resolveOfficialLayout(layout: VtLayout) { sizeField: layout.offset('GhosttyRenderStateColors', 'size'), background: layout.offset('GhosttyRenderStateColors', 'background'), foreground: layout.offset('GhosttyRenderStateColors', 'foreground'), - cursor: layout.offset('GhosttyRenderStateColors', 'cursor'), - cursorHasValue: layout.offset('GhosttyRenderStateColors', 'cursor_has_value'), palette: layout.offset('GhosttyRenderStateColors', 'palette'), rgbSize: layout.size('GhosttyColorRgb'), r: layout.offset('GhosttyColorRgb', 'r'), @@ -802,10 +789,6 @@ function setRgb(cell: GhosttyCell, fg: number, bg: number): void { cell.bg_b = bg & 0xff; } -function rgbObject(rgb: number): RGB { - return { r: (rgb >>> 16) & 0xff, g: (rgb >>> 8) & 0xff, b: rgb & 0xff }; -} - /** * Whether one `WRITE_PTY` reply is the kitty keyboard query reply `ESC [ ? u` * (`queryKittyKeyboard` in Ghostty's `stream_terminal.zig`). No other reply has @@ -909,7 +892,6 @@ export class OfficialTerminal implements GhosttyTerminal { private cursorStyle: RenderStateCursor['style'] = 'block'; private defFg = 0xffffff; private defBg = 0; - private cursorColor: number | null = null; private readonly palette = new Uint8Array(256 * 3); // The view's offset: lines scrolled up from the bottom. Ghostty's viewport stays at @@ -1124,15 +1106,6 @@ export class OfficialTerminal implements GhosttyTerminal { }; } - getColors(): RenderStateColors { - this.refresh(); - return { - background: rgbObject(this.defBg), - foreground: rgbObject(this.defFg), - cursor: this.cursorColor === null ? null : rgbObject(this.cursorColor), - }; - } - isRowDirty(y: number): boolean { this.refresh(); return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); @@ -1176,14 +1149,6 @@ export class OfficialTerminal implements GhosttyTerminal { return this.altScreen; } - hasBracketedPaste(): boolean { - return this.getMode(2004, false); - } - - hasFocusEvents(): boolean { - return this.getMode(1004, false); - } - hasMouseTracking(): boolean { const at = this.at; const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MOUSE_TRACKING, at.u8); @@ -1585,7 +1550,6 @@ export class OfficialTerminal implements GhosttyTerminal { let changed = defBg !== this.defBg || defFg !== this.defFg; this.defBg = defBg; this.defFg = defFg; - this.cursorColor = u8[at.colors + K.cursorHasValue] ? this.rgbAt(at.colors + K.cursor) : null; const pal = at.colors + K.palette; const out = this.palette; for (let i = 0; i < 256; i++) { @@ -1974,10 +1938,6 @@ class OfficialKeyEncoder implements KeyEncoder { this.ex.ghostty_key_encoder_setopt(this.encoder, official, this.value); } - setKittyFlags(flags: KittyKeyFlags): void { - this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags); - } - encode(event: KeyEvent): Uint8Array { const ex = this.ex; const vt = this.vt; diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index 2a9b4af..cf1272f 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -27,7 +27,7 @@ export { } from './ghostty'; export type { GhosttyTerminal, KeyEncoder, TerminalEffects } from './ghostty'; export { Key, KeyAction, Mods } from './types'; -export type { KeyEvent, GhosttyCell, RGB, Cursor } from './types'; +export type { KeyEvent, GhosttyCell } from './types'; // Low-level components (for custom integrations) export { CanvasRenderer } from './renderer'; diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index 28c3d85..29a091d 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -3,105 +3,10 @@ * Based on include/ghostty/vt/*.h from Ghostty repository */ -// ============================================================================ -// SGR (Select Graphic Rendition) Types -// ============================================================================ - -/** - * SGR attribute tags - identifies the type of attribute - * From include/ghostty/vt/sgr.h - */ -export enum SgrAttributeTag { - UNSET = 0, - UNKNOWN = 1, - BOLD = 2, - RESET_BOLD = 3, - ITALIC = 4, - RESET_ITALIC = 5, - FAINT = 6, - RESET_FAINT = 7, - UNDERLINE = 8, - RESET_UNDERLINE = 9, - BLINK = 10, - RESET_BLINK = 11, - INVERSE = 12, - RESET_INVERSE = 13, - INVISIBLE = 14, - RESET_INVISIBLE = 15, - STRIKETHROUGH = 16, - RESET_STRIKETHROUGH = 17, - FG_8 = 18, // 8-color (0-7) - FG_16 = 19, // 16-color (0-15) - FG_256 = 20, // 256-color palette - FG_RGB = 21, // RGB color - FG_DEFAULT = 22, // Reset to default - BG_8 = 23, // Background 8-color - BG_16 = 24, // Background 16-color - BG_256 = 25, // Background 256-color - BG_RGB = 26, // Background RGB - BG_DEFAULT = 27, // Reset background - UNDERLINE_COLOR_8 = 28, - UNDERLINE_COLOR_16 = 29, - UNDERLINE_COLOR_256 = 30, - UNDERLINE_COLOR_RGB = 31, - UNDERLINE_COLOR_DEFAULT = 32, -} - -export interface RGBColor { - r: number; - g: number; - b: number; -} - -export type SgrAttribute = - | { tag: SgrAttributeTag.BOLD } - | { tag: SgrAttributeTag.RESET_BOLD } - | { tag: SgrAttributeTag.ITALIC } - | { tag: SgrAttributeTag.RESET_ITALIC } - | { tag: SgrAttributeTag.FAINT } - | { tag: SgrAttributeTag.RESET_FAINT } - | { tag: SgrAttributeTag.UNDERLINE } - | { tag: SgrAttributeTag.RESET_UNDERLINE } - | { tag: SgrAttributeTag.BLINK } - | { tag: SgrAttributeTag.RESET_BLINK } - | { tag: SgrAttributeTag.INVERSE } - | { tag: SgrAttributeTag.RESET_INVERSE } - | { tag: SgrAttributeTag.INVISIBLE } - | { tag: SgrAttributeTag.RESET_INVISIBLE } - | { tag: SgrAttributeTag.STRIKETHROUGH } - | { tag: SgrAttributeTag.RESET_STRIKETHROUGH } - | { tag: SgrAttributeTag.FG_8; color: number } - | { tag: SgrAttributeTag.FG_16; color: number } - | { tag: SgrAttributeTag.FG_256; color: number } - | { tag: SgrAttributeTag.FG_RGB; color: RGBColor } - | { tag: SgrAttributeTag.FG_DEFAULT } - | { tag: SgrAttributeTag.BG_8; color: number } - | { tag: SgrAttributeTag.BG_16; color: number } - | { tag: SgrAttributeTag.BG_256; color: number } - | { tag: SgrAttributeTag.BG_RGB; color: RGBColor } - | { tag: SgrAttributeTag.BG_DEFAULT } - | { tag: SgrAttributeTag.UNDERLINE_COLOR_RGB; color: RGBColor } - | { tag: SgrAttributeTag.UNDERLINE_COLOR_DEFAULT } - | { tag: SgrAttributeTag.UNKNOWN; params: number[] }; - // ============================================================================ // Key Encoder Types // ============================================================================ -/** - * Kitty keyboard protocol flags - * From include/ghostty/vt/key/encoder.h - */ -export enum KittyKeyFlags { - DISABLED = 0, - DISAMBIGUATE = 1 << 0, // Disambiguate escape codes - REPORT_EVENTS = 1 << 1, // Report press and release - REPORT_ALTERNATES = 1 << 2, // Report alternate key codes - REPORT_ALL = 1 << 3, // Report all events - REPORT_ASSOCIATED = 1 << 4, // Report associated text - ALL = 0x1f, // All flags enabled -} - /** * Key encoder options */ @@ -343,10 +248,7 @@ export interface KeyEvent { action: KeyAction; key: Key; mods: Mods; - consumedMods?: Mods; - composing?: boolean; utf8?: string; - unshiftedCodepoint?: number; } // ============================================================================ @@ -375,15 +277,6 @@ export interface RenderStateCursor { style: 'block' | 'underline' | 'bar'; } -/** - * Colors from RenderState - */ -export interface RenderStateColors { - background: RGB; - foreground: RGB; - cursor: RGB | null; -} - /** * Terminal configuration, applied when the terminal is created. Colours are * 0xRRGGBB; undefined leaves one unset, so 0 is black. (webtty, ADR 047) @@ -419,15 +312,6 @@ export interface GhosttyCell { grapheme_len: number; // u8 (number of extra codepoints beyond first) } -/** - * RGB color - */ -export interface RGB { - r: number; - g: number; - b: number; -} - /** * Cell style flags (bitfield) */ @@ -442,24 +326,6 @@ export enum CellFlags { FAINT = 1 << 7, } -/** - * Cursor position and visibility - */ -export interface Cursor { - x: number; - y: number; - visible: boolean; -} - -/** - * Terminal configuration (passed to ghostty_terminal_new_with_config) - */ -export interface TerminalConfig { - scrollback_limit: number; // Number of scrollback lines (default: 10,000) - fg_color: RGB; // Default foreground color - bg_color: RGB; // Default background color -} - // ============================================================================ // Link Detection System // ============================================================================ @@ -516,75 +382,3 @@ export interface ILinkProvider { /** Optional: called when terminal is disposed */ dispose?(): void; } - -/** - * Event system interface (xterm.js compatible) - */ -export type IEvent = (listener: (data: T) => void) => IDisposable; - -export interface IDisposable { - dispose(): void; -} - -/** - * Event emitter for custom events - */ -export class EventEmitter { - private listeners: Array<(data: T) => void> = []; - - /** Subscribe to events */ - public readonly event: IEvent = (listener: (data: T) => void): IDisposable => { - this.listeners.push(listener); - return { - dispose: () => { - const index = this.listeners.indexOf(listener); - if (index !== -1) { - this.listeners.splice(index, 1); - } - }, - }; - }; - - /** Emit an event to all listeners */ - public fire(data: T): void { - for (const listener of this.listeners) { - listener(data); - } - } - - /** Remove all listeners */ - public dispose(): void { - this.listeners = []; - } -} - -/** - * Terminal mode identifiers - * - * ANSI modes (use with is_ansi = true): - * - INSERT = 4 - * - * DEC modes (use with is_ansi = false): - * - CURSOR_VISIBLE = 25 - * - MOUSE_TRACKING_NORMAL = 1000 - * - MOUSE_TRACKING_BUTTON = 1002 - * - MOUSE_TRACKING_ANY = 1003 - * - FOCUS_EVENTS = 1004 - * - ALT_SCREEN = 1047 - * - ALT_SCREEN_WITH_CURSOR = 1049 - * - BRACKETED_PASTE = 2004 - */ -export enum TerminalMode { - // ANSI modes - INSERT = 4, - - // DEC modes - CURSOR_VISIBLE = 25, - MOUSE_TRACKING_NORMAL = 1000, - MOUSE_TRACKING_BUTTON = 1002, - MOUSE_TRACKING_ANY = 1003, - FOCUS_EVENTS = 1004, - ALT_SCREEN = 1047, - ALT_SCREEN_WITH_CURSOR = 1049, - BRACKETED_PASTE = 2004, -} diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 0810769..35b26b5 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -7,14 +7,7 @@ import { type GhosttyTerminal, type OfficialTerminal, } from './ghostty-web/ghostty'; -import { - CellFlags, - Key, - KeyAction, - KeyEncoderOption, - KittyKeyFlags, - Mods, -} from './ghostty-web/types'; +import { CellFlags, Key, KeyAction, KeyEncoderOption, Mods } from './ghostty-web/types'; // The official libghostty-vt binding (ADR 042) on the vendored wasm, loaded from disk. @@ -162,7 +155,6 @@ describe('official engine: cells', () => { test('uses an explicitly black theme background instead of the default', () => { const t = term(40, 5, { fgColor: FG, bgColor: 0 }); - expect(t.getColors().background).toEqual({ r: 0, g: 0, b: 0 }); t.write('\x1b]11;?\x07'); expect(t.readResponse()).toBe('\x1b]11;rgb:0000/0000/0000\x07'); t.free(); @@ -185,21 +177,16 @@ describe('official engine: cells', () => { // Ghostty's render state copies the default colours only when both are set, so the // binding always sets both, falling back to webtty's default 0xcccccc on black. - const hex = (c: { r: number; g: number; b: number } | null) => - c ? (c.r << 16) | (c.g << 8) | c.b : null; const defaults = (t: GhosttyTerminal) => { t.write('\x1b[1;1HA'); t.update(); const cell = (t.getLine(0) ?? [])[0]; - const colors = t.getColors(); t.readResponse(); t.write('\x1b]10;?\x07'); const osc10 = t.readResponse(); t.write('\x1b]11;?\x07'); const osc11 = t.readResponse(); return { - fg: hex(colors.foreground), - bg: hex(colors.background), cellFg: rgb(cell), cellBg: bgRgb(cell), osc10, @@ -211,8 +198,6 @@ describe('official engine: cells', () => { for (const config of [{ fgColor: FG }, { fgColor: FG, bgColor: undefined }]) { const t = term(10, 3, config); expect(defaults(t)).toEqual({ - fg: FG, - bg: 0, cellFg: FG, cellBg: 0, osc10: '\x1b]10;rgb:d4d4/d4d4/d4d4\x07', @@ -225,8 +210,6 @@ describe('official engine: cells', () => { test('falls back to the default foreground when only the background is given', () => { const t = term(10, 3, { bgColor: 0x202020 }); expect(defaults(t)).toEqual({ - fg: 0xcccccc, - bg: 0x202020, cellFg: 0xcccccc, cellBg: 0x202020, osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', @@ -239,8 +222,6 @@ describe('official engine: cells', () => { for (const config of [undefined, { scrollbackLines: 1000 }]) { const t = vt.createTerminal(10, 3, config) as OfficialTerminal; expect(defaults(t)).toEqual({ - fg: 0xcccccc, - bg: 0, cellFg: 0xcccccc, cellBg: 0, osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', @@ -249,7 +230,6 @@ describe('official engine: cells', () => { t.write('\x1b]11;rgb:20/40/60\x1b\\'); t.update(); expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0x204060); - expect(hex(t.getColors().background)).toBe(0x204060); t.write('\x1b]111\x1b\\\x1b[?5h'); t.update(); expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0xcccccc); @@ -311,8 +291,8 @@ describe('official engine: cursor and modes', () => { t.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l'); expect(t.hasMouseTracking()).toBe(false); t.write('\x1b[?2004h\x1b[?1004h\x1b[?1h\x1b[4h'); - expect(t.hasBracketedPaste()).toBe(true); - expect(t.hasFocusEvents()).toBe(true); + expect(t.getMode(2004)).toBe(true); + expect(t.getMode(1004)).toBe(true); expect(t.getMode(1)).toBe(true); expect(t.getMode(4, true)).toBe(true); expect(t.getMode(4, false)).toBe(false); @@ -706,8 +686,6 @@ describe('official engine: key encoder', () => { expect(encode(Key.PASTE)).toBe(''); enc.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, true); expect(encode(Key.UP)).toBe('\x1bOA'); - enc.setKittyFlags(KittyKeyFlags.DISAMBIGUATE); - expect(encode(Key.ESCAPE)).toBe('\x1b[27u'); enc.dispose(); }); }); From 8de5aba06794e6586fb6953be34a7744816c81fa Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:03:42 -0400 Subject: [PATCH 064/295] refactor(client): remove unused fit, link and renderer code webtty resizes from FitAddon.proposeDimensions, so fit() and observeResize() and their resize state go, and ITerminalCore keeps only element. Also remove the OSC 8 provider's uncalled findLinkRange, LinkDetector.invalidateRows, the renderer's setTheme and the palette array nothing read, and ILink.dispose. --- src/client/vendor/fit.test.ts | 26 +++- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/addons/fit.ts | 130 +----------------- src/client/vendor/ghostty-web/interfaces.ts | 3 - .../vendor/ghostty-web/link-detector.ts | 29 ---- .../providers/osc8-link-provider.ts | 112 --------------- src/client/vendor/ghostty-web/renderer.ts | 49 ------- src/client/vendor/ghostty-web/types.ts | 3 - 8 files changed, 32 insertions(+), 321 deletions(-) diff --git a/src/client/vendor/fit.test.ts b/src/client/vendor/fit.test.ts index 46e6eed..3cc2783 100644 --- a/src/client/vendor/fit.test.ts +++ b/src/client/vendor/fit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { fitDimensions } from './ghostty-web/addons/fit'; +import { FitAddon, fitDimensions } from './ghostty-web/addons/fit'; +import type { ITerminalCore } from './ghostty-web/interfaces'; const NONE = { top: 0, right: 0, bottom: 0, left: 0 }; const pad = (p: number) => ({ top: p, right: p, bottom: p, left: p }); @@ -34,3 +35,26 @@ describe('fitDimensions', () => { expect(fitDimensions(100, 100, pad(60), 10, 20)).toEqual({ cols: 2, rows: 1 }); }); }); + +// webtty sizes the grid from proposeDimensions alone (ADR 033). +describe('FitAddon.proposeDimensions', () => { + test("fits the element's client size, less its padding, in renderer cells", () => { + const win = globalThis.window; + globalThis.window = { + getComputedStyle: () => ({ getPropertyValue: () => '10px' }), + } as unknown as Window & typeof globalThis; + try { + const addon = new FitAddon(); + expect(addon.proposeDimensions()).toBeUndefined(); + addon.activate({ + element: { clientWidth: 820, clientHeight: 420 }, + renderer: { getMetrics: () => ({ width: 10, height: 20 }) }, + } as unknown as ITerminalCore); + expect(addon.proposeDimensions()).toEqual({ cols: 80, rows: 20 }); + addon.dispose(); + expect(addon.proposeDimensions()).toBeUndefined(); + } finally { + globalThis.window = win; + } + }); +}); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 1d7ebac..eca4348 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -34,4 +34,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`, `interfaces.ts`, `index.ts`, `renderer.ts`: xterm.js API nothing in webtty reaches is removed. `terminal.ts`: `unicode`, `onKey`, `onRender` (never fired), `onCursorMove` and its per-frame cursor check, `writeln`, `paste`, `input`, `clear`, `blur`, `hasSelection`, `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `registerLinkProvider`, `scrollPages`, `scrollToTop`, `hasBracketedPaste` and `hasFocusEvents`. `input-handler.ts`: the `onBell`, `onKey` and `customKeyEventHandler` constructor arguments, `setCustomKeyEventHandler` and `isActive`. `selection-manager.ts`: `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `deselect`, `focus`, `getSelectionCoords`, `normalizeSelection` and the dirty-row set nothing read (`getDirtySelectionRows`, `clearDirtySelectionRows`, `markCurrentSelectionDirty`); the drag auto-scroll calls the terminal's `scrollLines` typed instead of through `any`. `interfaces.ts`: `IKeyEvent`, `IUnicodeVersionProvider` and `IBufferRange`. `index.ts`: `init()` and its module-level instance, so `ITerminalOptions.ghostty` is required. - `buffer.ts`, `interfaces.ts`, `types.ts`: the buffer API keeps what the link providers and webtty's kitty graphics read (`active`, `normal`, `alternate`, a buffer's `type`, `cursorX`, `cursorY`, `length` and `getLine`, a line's `length` and `getCell`, a cell's `getHyperlinkId` and `getCodepoint`). Removed: `onBufferChange`, `viewportY`, `baseY`, `getNullCell`, a line's `isWrapped` (and the per-line `isRowWrapped` call that filled it) and `translateToString`, and the cell's text, colour and style getters. `types.ts` loses its unused copies of `IBuffer`, `IBufferLine`, `IBufferCell` and `IBufferManager`. - `ghostty.ts`, `types.ts`, `index.ts`: engine API nothing in webtty reaches is removed: `getColors` (and the render-state cursor colour only it returned), `hasBracketedPaste` and `hasFocusEvents` (`getMode(2004)` and `getMode(1004)` remain), and the key encoder's `setKittyFlags`; in `types.ts` the SGR types (`SgrAttributeTag`, `SgrAttribute`, `RGBColor`), `KittyKeyFlags`, `KeyEvent`'s `consumedMods`, `composing` and `unshiftedCodepoint` (the binding never read them), `RenderStateColors`, `RGB`, `Cursor`, the Coder-era `TerminalConfig`, `TerminalMode`, and a second, unused `EventEmitter` with its `IEvent` and `IDisposable`. The `Key`, `KeyAction`, `KeyEncoderOption` and `Mods` enums are kept whole: the binding translates them to the official enums by name at load. +- `addons/fit.ts`, `interfaces.ts`, `providers/osc8-link-provider.ts`, `link-detector.ts`, `renderer.ts`, `types.ts`: unreachable code removed. `FitAddon.fit` and `observeResize` with their resize state (webtty resizes from `proposeDimensions`, ADR 033), so `ITerminalCore` keeps only `element`; `OSC8LinkProvider.findLinkRange` (private, never called); `LinkDetector.invalidateRows`; the renderer's `setTheme` and the ANSI `palette` array nothing read; `ILink.dispose`. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/addons/fit.ts b/src/client/vendor/ghostty-web/addons/fit.ts index 0339c85..71e69df 100644 --- a/src/client/vendor/ghostty-web/addons/fit.ts +++ b/src/client/vendor/ghostty-web/addons/fit.ts @@ -1,16 +1,16 @@ /** - * FitAddon - Auto-resize terminal to fit container - * - * Provides automatic terminal resizing to fit its container element. - * Compatible with xterm.js FitAddon API. + * FitAddon - Proposes the grid that fits the terminal's container * * Usage: * ```typescript * const fitAddon = new FitAddon(); * term.loadAddon(fitAddon); - * fitAddon.fit(); // Manual fit - * fitAddon.observeResize(); // Auto-fit on resize + * const dims = fitAddon.proposeDimensions(); + * if (dims) term.resize(dims.cols, dims.rows); * ``` + * + * (webtty: upstream's fit() and observeResize() are removed; webtty resizes the + * terminal itself from proposeDimensions, ADR 033.) */ import type { ITerminalAddon, ITerminalCore } from '../interfaces'; @@ -21,7 +21,6 @@ import type { ITerminalAddon, ITerminalCore } from '../interfaces'; const MINIMUM_COLS = 2; const MINIMUM_ROWS = 1; -const RESIZE_DEBOUNCE_MS = 100; // Debounce time for ResizeObserver // ============================================================================ // Types @@ -65,11 +64,6 @@ export function fitDimensions( export class FitAddon implements ITerminalAddon { private _terminal?: ITerminalCore; - private _resizeObserver?: ResizeObserver; - private _resizeDebounceTimer?: ReturnType; - private _lastCols?: number; - private _lastRows?: number; - private _isResizing: boolean = false; /** * Activate the addon (called by Terminal.loadAddon) @@ -82,76 +76,9 @@ export class FitAddon implements ITerminalAddon { * Dispose the addon and clean up resources */ public dispose(): void { - // Disconnect ResizeObserver - if (this._resizeObserver) { - this._resizeObserver.disconnect(); - this._resizeObserver = undefined; - } - - // Clear pending debounce timer - if (this._resizeDebounceTimer) { - clearTimeout(this._resizeDebounceTimer); - this._resizeDebounceTimer = undefined; - } - - // Clear stored dimensions - this._lastCols = undefined; - this._lastRows = undefined; - this._terminal = undefined; } - /** - * Fit the terminal to its container - * - * Calculates optimal dimensions and resizes the terminal. - * Does nothing if dimensions cannot be calculated or haven't changed. - */ - public fit(): void { - // Prevent re-entrant calls during resize - if (this._isResizing) { - return; - } - - const dims = this.proposeDimensions(); - if (!dims || !this._terminal) { - return; - } - - // Access terminal to check current dimensions - const terminal = this._terminal as any; - const currentCols = terminal.cols; - const currentRows = terminal.rows; - - // Check if dimensions actually changed (prevent feedback loops) - // Compare against BOTH proposed dimensions AND current terminal dimensions - if ( - (dims.cols === this._lastCols && dims.rows === this._lastRows) || - (dims.cols === currentCols && dims.rows === currentRows) - ) { - return; - } - - // Store dimensions before resize - this._lastCols = dims.cols; - this._lastRows = dims.rows; - - // Set flag to prevent re-entrant calls - this._isResizing = true; - - try { - // Resize terminal - if (terminal.resize && typeof terminal.resize === 'function') { - terminal.resize(dims.cols, dims.rows); - } - } finally { - // Clear flag after a short delay to allow DOM to settle - setTimeout(() => { - this._isResizing = false; - }, 50); - } - } - /** * Propose dimensions to fit the terminal to its container * @@ -218,49 +145,4 @@ export class FitAddon implements ITerminalAddon { metrics.height ); } - - /** - * Observe the terminal's container for resize events - * - * Sets up a ResizeObserver to automatically call fit() when the - * container size changes. Resize events are debounced to avoid - * excessive calls during window drag operations. - * - * Call dispose() to stop observing. - */ - public observeResize(): void { - if (!this._terminal?.element) { - return; - } - - // Already observing - if (this._resizeObserver) { - return; - } - - // Create ResizeObserver that watches for external size changes - this._resizeObserver = new ResizeObserver((entries) => { - // Ignore resize events while we're actively resizing - if (this._isResizing) { - return; - } - - // Only trigger if the observed element's content rect changed - const entry = entries[0]; - if (!entry) return; - - // Debounce resize events - if (this._resizeDebounceTimer) { - clearTimeout(this._resizeDebounceTimer); - } - - this._resizeDebounceTimer = setTimeout(() => { - this.fit(); - }, RESIZE_DEBOUNCE_MS); - }); - - // Observe the terminal element itself (the container we want to fit into) - // This gives us stable resize events when the CONTAINER changes, not when our canvas changes - this._resizeObserver.observe(this._terminal.element); - } } diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 8d0e202..578ec93 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -69,10 +69,7 @@ export interface ITerminalAddon { } export interface ITerminalCore { - cols: number; - rows: number; element?: HTMLElement; - textarea?: HTMLTextAreaElement; } // ============================================================================ diff --git a/src/client/vendor/ghostty-web/link-detector.ts b/src/client/vendor/ghostty-web/link-detector.ts index 3c5b66a..812f9dd 100644 --- a/src/client/vendor/ghostty-web/link-detector.ts +++ b/src/client/vendor/ghostty-web/link-detector.ts @@ -149,35 +149,6 @@ export class LinkDetector { this.scannedRows.clear(); } - /** - * Invalidate cache for specific rows - * Used when only part of the terminal changed - */ - invalidateRows(startRow: number, endRow: number): void { - // Remove scanned markers - for (let row = startRow; row <= endRow; row++) { - this.scannedRows.delete(row); - } - - // Remove cached links in this range - // This is conservative - we remove any link that touches these rows - const toDelete: string[] = []; - for (const [key, link] of this.linkCache.entries()) { - const { start, end } = link.range; - if ( - (start.y >= startRow && start.y <= endRow) || - (end.y >= startRow && end.y <= endRow) || - (start.y < startRow && end.y > endRow) - ) { - toDelete.push(key); - } - } - - for (const key of toDelete) { - this.linkCache.delete(key); - } - } - /** * Dispose and cleanup */ diff --git a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts index aa4e8d2..4a0d945 100644 --- a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts +++ b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts @@ -112,118 +112,6 @@ export class OSC8LinkProvider implements ILinkProvider { callback(links.length > 0 ? links : undefined); } - /** - * Find the full extent of a link by scanning for contiguous cells - * with the same hyperlink_id. Handles multi-line links. - */ - private findLinkRange(hyperlinkId: number, startY: number, startX: number): IBufferRange { - const buffer = this.terminal.buffer.active; - - // Find the start of the link (scan backwards) - let minY = startY; - let minX = startX; - - // Scan backwards on current line - while (minX > 0) { - const line = buffer.getLine(minY); - if (!line) break; - - const cell = line.getCell(minX - 1); - if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; - minX--; - } - - // If at start of line, check if link continues from previous line - if (minX === 0 && minY > 0) { - let prevY = minY - 1; - - while (prevY >= 0) { - const prevLine = buffer.getLine(prevY); - if (!prevLine || prevLine.length === 0) break; - - // Check if last cell of previous line has same hyperlink_id - const lastCell = prevLine.getCell(prevLine.length - 1); - if (!lastCell || lastCell.getHyperlinkId() !== hyperlinkId) break; - - // Link continues from previous line - find where it starts - minY = prevY; - minX = 0; - - // Scan backwards on this line - for (let x = prevLine.length - 1; x >= 0; x--) { - const cell = prevLine.getCell(x); - if (!cell || cell.getHyperlinkId() !== hyperlinkId) { - minX = x + 1; - break; - } - } - - // If entire line is part of link, continue to previous line - if (minX === 0) { - prevY--; - } else { - break; - } - } - } - - // Find the end of the link (scan forwards) - let maxY = startY; - let maxX = startX; - - // Scan forwards on current line - const currentLine = buffer.getLine(maxY); - if (currentLine) { - while (maxX < currentLine.length - 1) { - const cell = currentLine.getCell(maxX + 1); - if (!cell || cell.getHyperlinkId() !== hyperlinkId) break; - maxX++; - } - - // If at end of line, check if link continues to next line - if (maxX === currentLine.length - 1) { - let nextY = maxY + 1; - const maxBuffer = buffer.length; - - while (nextY < maxBuffer) { - const nextLine = buffer.getLine(nextY); - if (!nextLine || nextLine.length === 0) break; - - // Check if first cell of next line has same hyperlink_id - const firstCell = nextLine.getCell(0); - if (!firstCell || firstCell.getHyperlinkId() !== hyperlinkId) break; - - // Link continues to next line - find where it ends - maxY = nextY; - maxX = 0; - - // Scan forwards on this line - for (let x = 0; x < nextLine.length; x++) { - const cell = nextLine.getCell(x); - if (!cell) break; - if (cell.getHyperlinkId() !== hyperlinkId) { - maxX = x - 1; - break; - } - maxX = x; - } - - // If entire line is part of link, continue to next line - if (maxX === nextLine.length - 1) { - nextY++; - } else { - break; - } - } - } - } - - return { - start: { x: minX, y: minY }, - end: { x: maxX, y: maxY }, - }; - } - dispose(): void { // No resources to clean up } diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index d0fff68..17d058b 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -237,7 +237,6 @@ export class CanvasRenderer { private _devicePixelRatio: number; private stopWatchingDevicePixelRatio?: () => void; private metrics: FontMetrics; - private palette: string[]; // Cursor blinking state private cursorVisible: boolean = true; @@ -352,26 +351,6 @@ export class CanvasRenderer { }); } - // Build color palette (16 ANSI colors) - this.palette = [ - this.theme.black, - this.theme.red, - this.theme.green, - this.theme.yellow, - this.theme.blue, - this.theme.magenta, - this.theme.cyan, - this.theme.white, - this.theme.brightBlack, - this.theme.brightRed, - this.theme.brightGreen, - this.theme.brightYellow, - this.theme.brightBlue, - this.theme.brightMagenta, - this.theme.brightCyan, - this.theme.brightWhite, - ]; - // Measure font metrics this.metrics = this.measureFont(); @@ -1202,34 +1181,6 @@ export class CanvasRenderer { // Public API // ========================================================================== - /** - * Update theme colors - */ - public setTheme(theme: ITheme): void { - this.theme = { ...DEFAULT_THEME, ...theme }; - this.canvasStale = true; - - // Rebuild palette - this.palette = [ - this.theme.black, - this.theme.red, - this.theme.green, - this.theme.yellow, - this.theme.blue, - this.theme.magenta, - this.theme.cyan, - this.theme.white, - this.theme.brightBlack, - this.theme.brightRed, - this.theme.brightGreen, - this.theme.brightYellow, - this.theme.brightBlue, - this.theme.brightMagenta, - this.theme.brightCyan, - this.theme.brightWhite, - ]; - } - /** * Update font size */ diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index 29a091d..f2445d9 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -362,9 +362,6 @@ export interface ILink { /** Optional: called when mouse enters/leaves the link */ hover?(isHovered: boolean): void; - - /** Optional: called to clean up resources */ - dispose?(): void; } /** From c53cec2c1c3afaf5c435a9e4affa3333c55b09e3 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:17:17 -0400 Subject: [PATCH 065/295] docs(client): ADR 049, mouse, cursor style and paste through libghostty-vt Decide to encode mouse reports with Ghostty's mouse encoder, take the cursor shape and blink from Ghostty's state with the config as its default, and send pastes through ghostty_terminal_paste. ADR 047's follow-ups for these three now point here, and ADR 013, 017 and 037 note what changes. --- docs/adrs/013.client.cursor-style.md | 2 + docs/adrs/017.client.mouse-scroll.md | 2 + docs/adrs/037.client.multiline-text-paste.md | 2 + docs/adrs/047.client.official-engine-only.md | 8 +- .../049.client.official-mouse-cursor-paste.md | 95 +++++++++++++++++++ docs/specs/client.md | 3 +- 6 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 docs/adrs/049.client.official-mouse-cursor-paste.md diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index 3203d6b..a41008b 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -36,6 +36,8 @@ The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and t **Update (ADR 041):** DECSCUSR is now applied inside the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `write()` scans the output (`scanDecscusr`, which also carries a sequence split across writes) and keeps the app's shape apart from `options.cursorStyle` / `options.cursorBlink`, which stay the configured default. Ps 0 now clears the app's shape, so the configured cursor shows again (it was blinking block). `cursor.ts` and its call in `index.ts` are deleted. The Coder wasm does not export the cursor style, so the output is still scanned; reading it from the render state is ADR 041 step 5. ([ADR 047](047.client.official-engine-only.md) removed the Coder wasm but kept the scan: the official render state does expose the cursor style, and reading it from there is a follow-up with its own update to this ADR, since Ghostty's blink also follows mode 12.) +**Update (ADR 049):** the scan is gone. The configured style and blink are Ghostty's defaults (`DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`), Ghostty applies DECSCUSR, and the renderer draws the shape and blink that the render state reports. Ps 0 and RIS still restore the configured default. Following Ghostty, mode 12 now also turns blinking on and off, the shape is kept per screen, and DECSTR restores the default. See [ADR 049](049.client.official-mouse-cursor-paste.md). + ## Considered Options **Option A: Fix ghostty-web upstream (long-term)** diff --git a/docs/adrs/017.client.mouse-scroll.md b/docs/adrs/017.client.mouse-scroll.md index 6c110f9..1d01202 100644 --- a/docs/adrs/017.client.mouse-scroll.md +++ b/docs/adrs/017.client.mouse-scroll.md @@ -91,6 +91,8 @@ term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { **Update (ADR 041):** the intercept now lives in the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `Terminal.handleWheel` checks mouse tracking on either screen before its arrow-key and viewport paths and fires the SGR reports through `onData` (`wheelTicks`, `wheelSgr`); `mouseScrollSpeed` is a `Terminal` option. The custom wheel handler in `index.ts` now only does pixel-smooth scrollback (ADR 040) when the app does not track the mouse. The cell is measured from the canvas rather than the event target. +**Update (ADR 049):** wheel ticks are encoded by Ghostty's mouse encoder as presses of buttons 4/5 at the pointer's cell, in the format the app chose (SGR 64/65 under 1006, X10 bytes, UTF-8, urxvt or SGR-pixels), with Shift, Alt and Ctrl. `wheelSgr` is removed. See [ADR 049](049.client.official-mouse-cursor-paste.md). + When the custom handler returns `true`, `Terminal.handleWheel` returns early and skips the arrow-key loop. When it returns `false` (mouse tracking off), the default behaviour is preserved — arrow keys are still sent for apps that diff --git a/docs/adrs/037.client.multiline-text-paste.md b/docs/adrs/037.client.multiline-text-paste.md index 4332442..ad9c71d 100644 --- a/docs/adrs/037.client.multiline-text-paste.md +++ b/docs/adrs/037.client.multiline-text-paste.md @@ -22,6 +22,8 @@ Send nonempty text to the PTY regardless of mode 2004: wrap multiline text in br - A receiving app without bracketed paste may treat newlines as Enter, potentially submitting commands or accepting an fzf selection. There is no universal, application-independent way to ensure atomic multiline editing in such an app. Users should not paste untrusted multiline commands into an unbracketed shell or prompt. A nested app must enable/propagate mode 2004 for safe multiline editing. - Image-only clipboard paste still forwards Ctrl+V, preserving the existing native clipboard path from ADR 014. +**Update (ADR 049):** pastes are encoded by `ghostty_terminal_paste`. With mode 2004 on, every paste is bracketed, single-line text too. Unsafe control bytes become spaces, and without 2004 newlines are sent as `\r`. Nonempty text is still always sent and never refused, and empty text still sends Ctrl+V. See [ADR 049](049.client.official-mouse-cursor-paste.md). + ## Related Decisions - [ADR 014 — Non-text paste](014.client.image-paste.md) diff --git a/docs/adrs/047.client.official-engine-only.md b/docs/adrs/047.client.official-engine-only.md index 9cda3d9..6f59c5e 100644 --- a/docs/adrs/047.client.official-engine-only.md +++ b/docs/adrs/047.client.official-engine-only.md @@ -51,9 +51,9 @@ Make the official libghostty-vt wasm the only engine now, and delete the Coder e | Coder wasm and sidecar | Now | Above. | | Size reports through `OPT_SIZE` | Now | Already what runs on the official engine; deleting the `kitty.ts` replies removes dead code. | | Official key encoder | Done | `OfficialKeyEncoder` already runs on `ghostty_key_encoder_*`. The name translation from ghostty-web's `Key` enum stays (ADR 041: layouts and enums come from `ghostty_type_json`). | -| Cursor style from the render state | Follow-up | Possible with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, but the renderer would take shape and blink from `getCursor()` each frame. Hollow block needs mapping, and Ghostty's blink also follows mode 12, which changes behaviour for apps. The DECSCUSR scan in `terminal.ts` works and stays until an ADR 013 update with wasm tests and a vim/nvim check. | -| Official mouse encoder | Follow-up | The TS encoders carry webtty's fixes (hover Cb=35, per-cell motion, release outside) and ADR 045's wheel pacing. Replacing them changes behaviour that ADR 017 and 045 settled, and needs its own ADR and a vim/htop/tmux/less check in three browsers. | -| `terminal_paste` / paste encoding | Follow-up | Ghostty brackets every paste while mode 2004 is on and filters unsafe bytes. ADR 014 (Ctrl+V for images) and ADR 037 (bracket multi-line only) decided otherwise. A change is a product decision for its own ADR. | +| Cursor style from the render state | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | Possible with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, but the renderer would take shape and blink from `getCursor()` each frame. Hollow block needs mapping, and Ghostty's blink also follows mode 12, which changes behaviour for apps. The DECSCUSR scan in `terminal.ts` works and stays until an ADR 013 update with wasm tests and a vim/nvim check. | +| Official mouse encoder | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | The TS encoders carry webtty's fixes (hover Cb=35, per-cell motion, release outside) and ADR 045's wheel pacing. Replacing them changes behaviour that ADR 017 and 045 settled, and needs its own ADR and a vim/htop/tmux/less check in three browsers. | +| `terminal_paste` / paste encoding | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | Ghostty brackets every paste while mode 2004 is on and filters unsafe bytes. ADR 014 (Ctrl+V for images) and ADR 037 (bracket multi-line only) decided otherwise. A change is a product decision for its own ADR. | | Theme colours from the render state | Follow-up | The renderer, edge strips, padding and IME composer paint from the static theme, so OSC 10/11/12 and DECSCNM change cell colours but not the canvas clear or cursor colour. Fixing that touches several components and ADR 036. | Kitty keyboard flags (ADR 042, Consequences) remain future work as before. @@ -67,7 +67,7 @@ Kitty keyboard flags (ADR 042, Consequences) remain future work as before. - A regression found in a browser the user did not check cannot be avoided per tab; it is fixed forward or reverted. - The client config type drops `scrollback`; the server and `/api/config` are unchanged, so no config migrates. - The `kitty.ts` size replies are gone, and Ghostty is the only source of size reports. -- The cursor-style scan, the TS mouse and wheel encoders, `paste.ts` and the static theme stay. Each is now a single-engine workaround with its own follow-up (above), not a parity requirement. +- The cursor-style scan, the TS mouse and wheel encoders, `paste.ts` and the static theme stay. Each is now a single-engine workaround with its own follow-up (above), not a parity requirement. ([ADR 049](049.client.official-mouse-cursor-paste.md) later replaced the first three with the official APIs; the static theme remains.) - `GhosttyTerminal` and `KeyEncoder` stay interfaces with one implementation, so test fakes keep working, and a later engine change would not touch the renderer. ## Considered Options diff --git a/docs/adrs/049.client.official-mouse-cursor-paste.md b/docs/adrs/049.client.official-mouse-cursor-paste.md new file mode 100644 index 0000000..549d1f1 --- /dev/null +++ b/docs/adrs/049.client.official-mouse-cursor-paste.md @@ -0,0 +1,95 @@ +# ADR 049: client — Mouse reports, cursor style and paste through libghostty-vt + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-13 + +--- + +## Context + +ADR 047 left three ADR 041 step 5 replacements as follow-ups, each a behaviour change that needed its own decision. All three are TypeScript re-implementations of something the pinned official wasm (Ghostty `05dcf887`) already does: + +- **Mouse reports.** `input-handler.ts` encodes clicks and motion itself, and `terminal.ts` builds wheel reports with `wheelSgr`. It knows two formats: SGR when mode 1006 is on, otherwise X10 bytes. Measured against Ghostty's encoder (`src/input/mouse_encode.zig`): + - Alt is never reported, and Cmd/Meta is sent as Alt (+8). + - The back and forward buttons (`MouseEvent.button` 3 and 4) are sent as codes 3 and 4. Code 3 means a release, and code 4 is the Shift bit on a left press. Ghostty sends 128 and 129, as for X11 buttons 8 and 9. + - UTF-8 (1005), urxvt (1015) and SGR-pixels (1016) are sent as X10 bytes, and the wheel is always sent as SGR 64/65, even when 1006 is off. + - X10 mode 9 (presses only, no modifiers) gets releases, motion and modifiers. + - X10 columns or rows above 95 are sent as `String.fromCharCode(32 + n)`. That character goes over the text WebSocket as UTF-8, so the app gets two bytes, not one. + - A click in the padding is clamped only from below, so the reported cell can be past the grid. A drag that leaves the container stops reporting, because the listeners are on the container, and the release outside it is lost. +- **Cursor style.** `write()` scans every chunk of output with a regex for DECSCUSR (`scanDecscusr`) and keeps the app's shape apart from the configured one (ADR 013). That is about 10% of write CPU. Ghostty already applies DECSCUSR. Its render state, which the binding reads every frame, reports the shape (`visual_style`) and blink (`blinking`). `GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE` and `…_BLINK` set the defaults that `CSI 0 SP q` and RIS restore. A new default applies at once only while the app has not chosen a shape (Ghostty's `setDefaultCursorStyle`). +- **Paste.** `paste.ts` sends text raw, wrapped in `ESC [200~ … ESC [201~` only when mode 2004 is on and the text has a newline (ADR 037). `ghostty_terminal_paste` brackets every paste while 2004 is on. It replaces unsafe control bytes (NUL, BS, ENQ, EOT, ESC, DEL, and ^C ^\ ^U ^Z ^Q ^S ^W ^V ^R ^O, per xterm) with spaces, and turns `\n` into `\r` when unbracketed, so `\r\n` becomes `\r\r` as in xterm. It refuses text that could run commands unless `allow_unsafe` is set, and it sends kitty paste events (mode 5522) only when a clipboard-read callback is installed. + +The user approved moving all three to the official APIs. + +## Decision + +### Mouse: `ghostty_mouse_encoder_*` and `ghostty_mouse_event_*` + +The binding gains a mouse encoder per terminal (`OfficialTerminal.encodeMouse`, `setMouseGeometry`). Every mouse report, including wheel ticks, is encoded by it. + +- **Modes.** The tracking mode and format come from the terminal through `ghostty_mouse_encoder_setopt_from_terminal`. That call also clears the encoder's last cell, so the binding makes it only when one of modes 9, 1000, 1002, 1003, 1005, 1006, 1015 or 1016 has changed since the last report. Otherwise motion deduplication would reset on every event. +- **Deduplication.** `TRACK_LAST_CELL` is on: Ghostty reports motion once per cell, which replaces `isNewMouseCell` (ADR 041's fix for drags within one cell). As before, any report sets the last cell, including a press, and the last cell resets on a mode or geometry change. +- **Hover.** Ghostty sends motion with no button as 32 + 3 = 35, the code webtty's fix chose (ADR 041), so `mouseMotionButton` goes. A drag reports the lowest held button (left, then middle, then right), as before. +- **Geometry.** The encoder works in whole pixels, with a whole cell size. webtty's CSS cell is fractional, so positions are given in device-pixel cell units: the cell is `deviceCellPixels` (the size Ghostty reports for XTWINOPS 14/16 t and mode 2048, ADR 042), and a CSS offset from the grid's origin is scaled by that cell over the CSS cell. The cell under the pointer is then exact at any column, and SGR-pixels (1016) coordinates agree with the cell size apps were told. The padding is the distance from the grid to the container's edges, rounded in the same units, so the screen is the container. The position is measured from the grid's origin and then offset by the rounded left and top padding, so rounding never moves a cell boundary. +- **Outside the grid.** A press, a release or motion in the padding is reported at the nearest edge cell, as in Ghostty, instead of at a cell past the grid. Motion outside the container is reported only while a button is held and the mode reports motion (1002/1003), clamped to the edge cell. A release is always reported. Move and release listeners are on the document, so a drag that leaves the terminal keeps reporting, and its release is sent. A release outside the container is sent only for a button pressed in it. +- **Buttons and modifiers.** Browser buttons 0, 1, 2, 3 and 4 are Ghostty's left, middle, right, eight and nine. A reported back or forward press and release is `preventDefault`ed, so the browser does not navigate away from the session. Modifiers are Shift (+4), Alt (+8) and Ctrl (+16). Cmd/Meta is not reported, because Ghostty does not encode Super. +- **Wheel.** `TickPacer` still paces the ticks of ADR 045. Each event's tick is encoded once, as a press of button four (up) or five (down) at the pointer's cell with the event's modifiers, and the pacer repeats that sequence. The tick counts (`wheelTicks`, `mouseScrollSpeed`) are unchanged. `wheelSgr` goes. In X10 mode 9 the encoder sends nothing for the wheel, and nothing is queued. +- **Bytes over a text WebSocket.** The encoder returns bytes, and the client sends text frames, which the server writes to the PTY as UTF-8. A report that is not valid UTF-8 cannot reach the app unchanged, so it is dropped. That covers X10 coordinates above 95 (Ghostty's own limit is 223) and X10 buttons 128/129. Sending them would need binary WebSocket frames and a server that writes bytes, which is a protocol change. That limit is documented here instead. Apps that set 1006 (vim, tmux, htop, less) or 1015 are not affected. UTF-8 (1005) reports are valid UTF-8 and are sent. + +### Cursor style: from Ghostty's state + +- The renderer takes the shape and blink from `getCursor()` every frame (`style`, `blinking`), and repaints the cursor cell when either changes (ADR 015). The binding already reads both from the render state, and maps a hollow block to a block. +- The configured `cursorStyle` / `cursorBlink` go to `GhosttyTerminalConfig` and are set with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK` when the terminal is created, including by `reset()`. An option change at runtime calls the new `setDefaultCursor`. `scanDecscusr`, `decscusrShape`, `CursorShape` and the app-shape state in `terminal.ts` go. +- ADR 013's behaviour is kept. The config sets the default, DECSCUSR 1–6 overrides it, `CSI 0 SP q` and RIS restore it, and changing the config while an app has chosen a shape does not override that shape. + +### Paste: `ghostty_terminal_paste` + +- `Terminal.paste(text)` passes a `text/plain` paste (source CLIPBOARD, location STANDARD) to `ghostty_terminal_paste` with a mime reader that writes the text. The encoded bytes come back through `WRITE_PTY` and leave through `onData`, like query replies. +- `allow_unsafe` is always set. ADR 037 decided that unbracketed multi-line text is sent rather than refused, and webtty has no confirmation dialog. No clipboard-read callback is installed, so no kitty paste events (mode 5522) are sent. +- ADR 014 and ADR 037's paste UX stays in `index.ts`. Cmd+V and the context-menu paste go through the `paste` event, Ctrl+V reads the clipboard itself, a configured binding still wins, and an empty `text/plain` (an image) or a failed clipboard read sends `\x16`. `encodePaste` goes, and `paste.ts` keeps only the empty-text check. + +## Consequences + +Mouse, visible to apps: + +- Alt-click and Alt-drag carry +8. Cmd-click no longer does. +- Back/forward clicks report 128/129 in SGR, UTF-8 and urxvt, instead of a fake release and a Shift-left press. They are dropped in X10 format, and the browser no longer navigates back on them while an app tracks the mouse. +- Modes 1005, 1015 and 1016 work. The wheel follows the format (`ESC [M` + `` ` ``/`a` bytes in X10 format), carries modifiers, and mode 9 gets no wheel, releases, motion or modifiers. +- In X10 format, reports past column or row 95 are dropped, where they used to arrive mangled. +- Clicks in the padding report the edge cell. Drags past the edge keep reporting at the edge cell, and their release arrives. +- Motion deduplication is Ghostty's: per cell, reset on a mode or geometry change (resize, zoom, padding). + +Cursor style: + +- The output scan's CPU is gone. The shape is now whatever Ghostty's state says, so these follow Ghostty: + - Mode 12 (`CSI ? 12 h/l`, ATT610) turns blinking on and off. Vim's xterm `t_vs`/`t_ve` send it, so a cursor can stop or start blinking where the scan ignored it. + - The shape is kept per screen: leaving the alternate screen shows the main screen's shape again. Blink is a mode, so it is shared by both screens. + - DECSTR (`CSI ! p`) restores the default. + - Ps 7 and above are ignored. + +Paste: + +- With mode 2004 on, single-line pastes are bracketed too. +- Control bytes in pasted text become spaces in both modes. A pasted ESC, ^C or ^U can no longer act as a key. +- With mode 2004 off, newlines are sent as `\r`, and `\r\n` as `\r\r`. + +Overall: about 200 lines of encoders and the regex scan go. Every mouse, cursor-style and paste path is now Ghostty's. + +## Considered Options + +- **Use the official APIs (chosen).** Ghostty's behaviour, tested upstream, with webtty's wheel pacing and transport on top. +- **Fix the TS encoders.** It would mean adding Alt, buttons 8/9, three more formats, mode 9, clamping and drags outside the container, which re-implements `mouse_encode.zig` and keeps it in step with Ghostty by hand. +- **`ghostty_paste_encode` instead of `ghostty_terminal_paste`.** Same bytes today, with no callback. But the terminal call is the one Ghostty recommends: it reads the modes itself and is where kitty paste events live. +- **Binary frames for X10 above column 95.** Needs a protocol and server change for a format that modern apps do not use. Deferred. +- **Ignore mode 12 and keep blink from DECSCUSR only.** That would need the scan again, or a second, JS-side state that could disagree with Ghostty's. Ghostty itself follows mode 12. + +## Related Decisions + +- [ADR 047 — Official libghostty-vt as the only engine](047.client.official-engine-only.md) +- [ADR 041 — Own the terminal layer on official libghostty-vt](041.client.libghostty-vt.md) +- [ADR 013 — DECSCUSR cursor style](013.client.cursor-style.md) +- [ADR 014 — Non-text paste](014.client.image-paste.md) +- [ADR 017 — SGR mouse scroll](017.client.mouse-scroll.md) +- [ADR 037 — Preserve text when bracketed paste is off](037.client.multiline-text-paste.md) +- [ADR 045 — Smooth mouse wheel scrolling](045.client.smooth-mouse-wheel.md) diff --git a/docs/specs/client.md b/docs/specs/client.md index 4e54ec6..86fb877 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -189,8 +189,9 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Vendored terminal layer | The terminal (renderer, input, selection) is webtty's own vendored code with no npm `ghostty-web` dependency; terminal behaviour is unchanged | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | ghostty-web bugs fixed at source | ghostty-web bugs are fixed in the vendored code instead of patched from outside, each fix deleting its webtty workaround (hover motion, cursor interceptor, fit reserve, private `devicePixelRatio` set, and the rest of ADR 041 step 2) | [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default (superseded by the row below) | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | -| Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders and theme colours from render state remain follow-ups | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | +| Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders moved to the official APIs in ADR 049; theme colours from render state remain a follow-up | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | +| Mouse, cursor style and paste from libghostty-vt | Mouse reports (clicks, motion, drags past the edge, wheel ticks) are encoded by Ghostty's mouse encoder in every tracking mode and format, with Alt and buttons 8/9, at a device-pixel geometry that includes the padding; the cursor shape and blink come from Ghostty's state, with the config as its default; pastes go through `ghostty_terminal_paste` (bracketed whenever mode 2004 is on, control bytes stripped). X10-format reports past column 95 are dropped: they cannot travel as text frames | [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ⬜ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | From 95b6741c4b763c6d39c6ed3aaeae8b5a65d4ae70 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:25:30 -0400 Subject: [PATCH 066/295] feat(client): encode mouse reports with Ghostty's mouse encoder Clicks, motion and wheel ticks are encoded by ghostty_mouse_encoder_* in the tracking mode and format the app set (ADR 049): Alt is +8 and Cmd is no longer sent as Alt, back and forward are buttons 8 and 9, UTF-8, urxvt and SGR-pixels formats work, X10 mode 9 gets presses only, and the wheel follows the format. Positions are in device-pixel cells with the padding as part of the screen, so the padding reports the edge cell, and move and release are heard on the document, so a drag past the edge keeps reporting and its release is sent. Hover stays 35 and motion stays once per cell, now Ghostty's own check; the wheel pacer repeats each event's encoded tick. Reports that are not valid UTF-8 (X10 past column 95) are dropped: text frames cannot carry them. --- docs/specs/client.md | 2 +- docs/specs/config.md | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 7 +- src/client/vendor/ghostty-web/ghostty.ts | 205 ++++++++++++++- .../vendor/ghostty-web/input-handler.ts | 237 +++++++----------- src/client/vendor/ghostty-web/terminal.ts | 150 +++++++++-- src/client/vendor/ghostty-web/wheel.ts | 13 +- src/client/vendor/input-handler.test.ts | 157 +++++++++--- src/client/vendor/official-terminal.test.ts | 164 ++++++++++++ src/client/vendor/terminal.test.ts | 115 +++++++++ src/client/vendor/wheel.test.ts | 11 - 11 files changed, 826 insertions(+), 237 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 86fb877..3870d47 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -196,7 +196,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | -| Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | +| Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as mouse buttons 4/5 at the pointer's cell, encoded by Ghostty in the app's format (`\x1b[<64/65;col;rowM` under SGR), instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | | Font-size zoom | `Ctrl/Cmd` + `=`/`-`/`0` adjust terminal font size in-session; not configurable; same shortcuts as VS Code | [ADR 023](../adrs/023.client.font-size-zoom.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 7fd7891..f781353 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -115,7 +115,7 @@ All keys are optional — omit any key to use the default value. | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | -| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one SGR event per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | +| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | | `fontSize` | number | `13` | Font size in px | diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index eca4348..465b178 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -13,11 +13,11 @@ webtty edits to these files are tracked in git from the commit that added them; ## Local changes - `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. -- `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code (`mouseMotionButton`), a release outside the terminal no longer leaves a button held, motion is reported once per cell instead of per `mousemove` (`isNewMouseCell`), and its own `wheel` listener is removed: the terminal's paced `handleWheel` is the only wheel path, where a wheel over the padding (the container itself the target, so the terminal's capture listener did not stop it) was sent a second time, unpaced. +- `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code, a release outside the terminal no longer leaves a button held, motion is reported once per cell instead of per `mousemove`, and its own `wheel` listener is removed: the terminal's paced `handleWheel` is the only wheel path, where a wheel over the padding (the container itself the target, so the terminal's capture listener did not stop it) was sent a second time, unpaced. - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. -- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, `wheelSgr`, now in `wheel.ts`). +- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. - `ghostty.ts`, `types.ts`, `index.ts`: the terminal runs on Ghostty's official libghostty-vt wasm (ADR 042, ADR 047). Upstream's `GhosttyTerminal` class, which ran the Coder wasm, is replaced: `GhosttyTerminal` and `KeyEncoder` are interfaces, implemented by `OfficialTerminal` and `OfficialKeyEncoder`, and `Ghostty.load(path)` loads the official wasm (by default from `../ghostty-vt/ghostty-vt-small.wasm`). The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` and `readEffects`, and drops upstream's `isDirty`, `getGrapheme` and `getScrollbackGrapheme`, which nothing calls: graphemes are read as strings (`getGraphemeString`, `getScrollbackGraphemeString`). `GhosttyTerminalConfig` drops `scrollbackLimit`, and its colours are undefined when unset instead of 0, so `#000000` is black. The binding drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. Upstream's Coder-only types (`GhosttyWasmExports`, the struct sizes, `TerminalHandle`) are removed. @@ -29,10 +29,11 @@ webtty edits to these files are tracked in git from the commit that added them; - `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). - `input-handler.ts`: the paste path is removed (the `paste` listeners, `handlePaste`, the `insertFromPaste` case of `handleBeforeInput`, `emitPasteData` and the paste de-duplication). It never ran: webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no `insertFromPaste`. - `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. -- `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelSgr`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. +- `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`, `interfaces.ts`, `index.ts`, `renderer.ts`: xterm.js API nothing in webtty reaches is removed. `terminal.ts`: `unicode`, `onKey`, `onRender` (never fired), `onCursorMove` and its per-frame cursor check, `writeln`, `paste`, `input`, `clear`, `blur`, `hasSelection`, `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `registerLinkProvider`, `scrollPages`, `scrollToTop`, `hasBracketedPaste` and `hasFocusEvents`. `input-handler.ts`: the `onBell`, `onKey` and `customKeyEventHandler` constructor arguments, `setCustomKeyEventHandler` and `isActive`. `selection-manager.ts`: `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `deselect`, `focus`, `getSelectionCoords`, `normalizeSelection` and the dirty-row set nothing read (`getDirtySelectionRows`, `clearDirtySelectionRows`, `markCurrentSelectionDirty`); the drag auto-scroll calls the terminal's `scrollLines` typed instead of through `any`. `interfaces.ts`: `IKeyEvent`, `IUnicodeVersionProvider` and `IBufferRange`. `index.ts`: `init()` and its module-level instance, so `ITerminalOptions.ghostty` is required. - `buffer.ts`, `interfaces.ts`, `types.ts`: the buffer API keeps what the link providers and webtty's kitty graphics read (`active`, `normal`, `alternate`, a buffer's `type`, `cursorX`, `cursorY`, `length` and `getLine`, a line's `length` and `getCell`, a cell's `getHyperlinkId` and `getCodepoint`). Removed: `onBufferChange`, `viewportY`, `baseY`, `getNullCell`, a line's `isWrapped` (and the per-line `isRowWrapped` call that filled it) and `translateToString`, and the cell's text, colour and style getters. `types.ts` loses its unused copies of `IBuffer`, `IBufferLine`, `IBufferCell` and `IBufferManager`. - `ghostty.ts`, `types.ts`, `index.ts`: engine API nothing in webtty reaches is removed: `getColors` (and the render-state cursor colour only it returned), `hasBracketedPaste` and `hasFocusEvents` (`getMode(2004)` and `getMode(1004)` remain), and the key encoder's `setKittyFlags`; in `types.ts` the SGR types (`SgrAttributeTag`, `SgrAttribute`, `RGBColor`), `KittyKeyFlags`, `KeyEvent`'s `consumedMods`, `composing` and `unshiftedCodepoint` (the binding never read them), `RenderStateColors`, `RGB`, `Cursor`, the Coder-era `TerminalConfig`, `TerminalMode`, and a second, unused `EventEmitter` with its `IEvent` and `IDisposable`. The `Key`, `KeyAction`, `KeyEncoderOption` and `Mods` enums are kept whole: the binding translates them to the official enums by name at load. - `addons/fit.ts`, `interfaces.ts`, `providers/osc8-link-provider.ts`, `link-detector.ts`, `renderer.ts`, `types.ts`: unreachable code removed. `FitAddon.fit` and `observeResize` with their resize state (webtty resizes from `proposeDimensions`, ADR 033), so `ITerminalCore` keeps only `element`; `OSC8LinkProvider.findLinkRange` (private, never called); `LinkDetector.invalidateRows`; the renderer's `setTheme` and the ANSI `palette` array nothing read; `ILink.dispose`. +- `ghostty.ts`, `input-handler.ts`, `terminal.ts`, `wheel.ts`: mouse reports are encoded by Ghostty's mouse encoder (ADR 049). The binding adds `setMouseGeometry` and `encodeMouse` (`ghostty_mouse_encoder_*`, `ghostty_mouse_event_*`, with Ghostty's per-cell motion deduplication, the tracking mode and format set from the terminal when a mouse mode changes). Upstream's `encodeMouseSGR`, `encodeMouseX10` and `pixelToCell` go, with webtty's `mouseMotionButton` and `isNewMouseCell` (Ghostty reports hover as 35 and motion once per cell), and `MouseTrackingConfig` becomes `hasMouseTracking` and `encode`. Upstream sent Meta as Alt and dropped Alt, sent the back and forward buttons as codes 3 and 4, knew only the SGR and X10 formats, and clamped the cell from below only; `mouseMods` and `MOUSE_BUTTONS` follow Ghostty (Shift, Alt, Ctrl; buttons 8 and 9). Move and release are heard on the document, so a drag past the terminal's edge keeps reporting and its release is sent; back and forward are `preventDefault`ed while reported. `terminal.ts` maps client positions to device-pixel cells with the container as the screen (`mouseSurface`), drops reports that are not valid UTF-8 (`mouseReportText`), and encodes each wheel tick as a press of button 4 or 5; `wheelSgr` is removed. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 0b159ec..608f14c 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -34,6 +34,38 @@ export { type RenderStateCursor, }; +/** A mouse button as Ghostty names it: four/five are the wheel, eight/nine back/forward. */ +export type MouseButton = 'left' | 'middle' | 'right' | 'four' | 'five' | 'eight' | 'nine'; + +/** One mouse event for `encodeMouse` (ADR 049). */ +export interface MouseInput { + action: 'press' | 'release' | 'motion'; + /** The button pressed or released, or the lowest one held for motion; null for none. */ + button: MouseButton | null; + /** `Mods` bits; Ghostty reports Shift, Alt and Ctrl. */ + mods: number; + /** Position in the units of `MouseGeometry`, from the screen's top-left corner. */ + x: number; + y: number; + /** Whether a button is held after this event: motion outside the screen is reported only then. */ + anyButtonPressed: boolean; +} + +/** + * The surface the mouse encoder maps positions on (ADR 049): the screen is the + * grid plus the padding around it, in whole pixels, with a whole cell size. + */ +export interface MouseGeometry { + screenWidth: number; + screenHeight: number; + cellWidth: number; + cellHeight: number; + paddingTop: number; + paddingRight: number; + paddingBottom: number; + paddingLeft: number; +} + /** Bell and title changes since the last `readEffects` (ADR 042). */ export interface TerminalEffects { bell: boolean; @@ -88,6 +120,16 @@ export interface GhosttyTerminal { getGraphemeString(row: number, col: number): string; getScrollbackGraphemeString(offset: number, col: number): string; + // Mouse reports (ADR 049) + /** The geometry `encodeMouse` positions are in. */ + setMouseGeometry(geometry: MouseGeometry): void; + /** + * A mouse event as the app asked for it (tracking mode and format), or no bytes when + * it is not reported: motion within the last reported cell, motion the mode does not + * report, or motion outside the screen with no button held. + */ + encodeMouse(input: MouseInput): Uint8Array; + // Replies and effects hasResponse(): boolean; readResponse(): string | null; @@ -199,6 +241,25 @@ interface OfficialWasmExports { ghostty_key_event_set_key(event: number, key: number): void; ghostty_key_event_set_mods(event: number, mods: number): void; ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; + ghostty_mouse_encoder_new(allocator: number, out: number): number; + ghostty_mouse_encoder_free(encoder: number): void; + ghostty_mouse_encoder_setopt(encoder: number, option: number, value: number): void; + ghostty_mouse_encoder_setopt_from_terminal(encoder: number, term: number): void; + ghostty_mouse_encoder_encode( + encoder: number, + event: number, + buf: number, + len: number, + outLen: number + ): number; + ghostty_mouse_event_new(allocator: number, out: number): number; + ghostty_mouse_event_free(event: number): void; + ghostty_mouse_event_set_action(event: number, action: number): void; + ghostty_mouse_event_set_button(event: number, button: number): void; + ghostty_mouse_event_clear_button(event: number): void; + ghostty_mouse_event_set_mods(event: number, mods: number): void; + /** The GhosttyMousePosition struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_mouse_event_set_position(event: number, position: number): void; } interface ManifestBits { @@ -534,6 +595,38 @@ function resolveOfficialLayout(layout: VtLayout) { rom: layout.offset('GhosttyDeviceAttributesSecondary', 'rom_cartridge'), unitId: layout.offset('GhosttyDeviceAttributesTertiary', 'unit_id'), }, + mouse: { + size: layout.size('GhosttyMouseEncoderSize'), + sizeField: layout.offset('GhosttyMouseEncoderSize', 'size'), + screenWidth: layout.offset('GhosttyMouseEncoderSize', 'screen_width'), + screenHeight: layout.offset('GhosttyMouseEncoderSize', 'screen_height'), + cellWidth: layout.offset('GhosttyMouseEncoderSize', 'cell_width'), + cellHeight: layout.offset('GhosttyMouseEncoderSize', 'cell_height'), + paddingTop: layout.offset('GhosttyMouseEncoderSize', 'padding_top'), + paddingBottom: layout.offset('GhosttyMouseEncoderSize', 'padding_bottom'), + paddingRight: layout.offset('GhosttyMouseEncoderSize', 'padding_right'), + paddingLeft: layout.offset('GhosttyMouseEncoderSize', 'padding_left'), + positionSize: layout.size('GhosttyMousePosition'), + x: layout.offset('GhosttyMousePosition', 'x'), + y: layout.offset('GhosttyMousePosition', 'y'), + SIZE: layout.value('GhosttyMouseEncoderOption', 'SIZE'), + ANY_BUTTON_PRESSED: layout.value('GhosttyMouseEncoderOption', 'ANY_BUTTON_PRESSED'), + TRACK_LAST_CELL: layout.value('GhosttyMouseEncoderOption', 'TRACK_LAST_CELL'), + action: { + press: layout.value('GhosttyMouseAction', 'PRESS'), + release: layout.value('GhosttyMouseAction', 'RELEASE'), + motion: layout.value('GhosttyMouseAction', 'MOTION'), + }, + button: { + left: layout.value('GhosttyMouseButton', 'LEFT'), + middle: layout.value('GhosttyMouseButton', 'MIDDLE'), + right: layout.value('GhosttyMouseButton', 'RIGHT'), + four: layout.value('GhosttyMouseButton', 'FOUR'), + five: layout.value('GhosttyMouseButton', 'FIVE'), + eight: layout.value('GhosttyMouseButton', 'EIGHT'), + nine: layout.value('GhosttyMouseButton', 'NINE'), + } satisfies Record, + }, result: { SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), @@ -763,6 +856,13 @@ const FALLBACK_FOREGROUND = 0xcccccc; const FALLBACK_BACKGROUND = 0x000000; /** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ const GRAPHEME_SCRATCH = 32; +/** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ +const MOUSE_REPORT_MAX = 64; +/** + * The modes that set the mouse encoder's tracking mode and format: X10, normal, button, + * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. + */ +const MOUSE_MODES = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; function blankCell(): GhosttyCell { return { @@ -856,7 +956,10 @@ export class OfficialTerminal implements GhosttyTerminal { | 'u32' | 'u8' | 'gBuf' - | 'palette', + | 'palette' + | 'mouseSize' + | 'mousePos' + | 'mouseOut', number >; private readonly callbacks: InstalledFn[] = []; @@ -902,6 +1005,13 @@ export class OfficialTerminal implements GhosttyTerminal { private readonly pages = new Map(); private pageGen = 0; + // Mouse encoder and event, made on the first report (ADR 049). `mouseModes` is the + // MOUSE_MODES bitmask the encoder was last set from; `mouseGeometry` the last size. + private mouseEncoder = 0; + private mouseEvent = 0; + private mouseModes = -1; + private mouseGeometry = ''; + // Effects. private readonly responses: Uint8Array[] = []; private bell = false; @@ -956,6 +1066,9 @@ export class OfficialTerminal implements GhosttyTerminal { u8: [8, 8], gBuf: [GRAPHEME_SCRATCH * 4, 8], palette: [256 * L.colors.rgbSize, 8], + mouseSize: [L.mouse.size, 8], + mousePos: [L.mouse.positionSize, 8], + mouseOut: [MOUSE_REPORT_MAX, 8], } as const); this.scratch = scratch; this.at = at; @@ -1076,6 +1189,10 @@ export class OfficialTerminal implements GhosttyTerminal { // Slots are released only after nothing can call them (fn-table.ts rule 3). for (const fn of this.callbacks) fn.release(); this.callbacks.length = 0; + if (this.mouseEncoder) ex.ghostty_mouse_encoder_free(this.mouseEncoder); + if (this.mouseEvent) ex.ghostty_mouse_event_free(this.mouseEvent); + this.mouseEncoder = 0; + this.mouseEvent = 0; if (this.pageRs) ex.ghostty_render_state_free(this.pageRs); ex.ghostty_render_state_row_cells_free(this.rowCells); ex.ghostty_render_state_row_iterator_free(this.iter); @@ -1214,6 +1331,92 @@ export class OfficialTerminal implements GhosttyTerminal { return this.graphemeOf(this.rowAt(offset), col); } + // ========================================================================== + // Mouse reports (ADR 049) + // ========================================================================== + + setMouseGeometry(geometry: MouseGeometry): void { + const g = geometry; + // Setting the size clears the encoder's last cell, so only a change is set. + const key = [ + g.screenWidth, + g.screenHeight, + g.cellWidth, + g.cellHeight, + g.paddingTop, + g.paddingRight, + g.paddingBottom, + g.paddingLeft, + ].join(','); + if (key === this.mouseGeometry) return; + const enc = this.ensureMouseEncoder(); + const at = this.at; + const M = this.L.mouse; + const dv = this.dv(); + const u32 = (n: number) => Math.max(0, Math.round(n)) >>> 0; + dv.setUint32(at.mouseSize + M.sizeField, M.size, true); + dv.setUint32(at.mouseSize + M.screenWidth, u32(g.screenWidth), true); + dv.setUint32(at.mouseSize + M.screenHeight, u32(g.screenHeight), true); + // Ghostty ignores a zero cell size. + dv.setUint32(at.mouseSize + M.cellWidth, Math.max(1, u32(g.cellWidth)), true); + dv.setUint32(at.mouseSize + M.cellHeight, Math.max(1, u32(g.cellHeight)), true); + dv.setUint32(at.mouseSize + M.paddingTop, u32(g.paddingTop), true); + dv.setUint32(at.mouseSize + M.paddingRight, u32(g.paddingRight), true); + dv.setUint32(at.mouseSize + M.paddingBottom, u32(g.paddingBottom), true); + dv.setUint32(at.mouseSize + M.paddingLeft, u32(g.paddingLeft), true); + this.ex.ghostty_mouse_encoder_setopt(enc, M.SIZE, at.mouseSize); + this.mouseGeometry = key; + } + + encodeMouse(input: MouseInput): Uint8Array { + const ex = this.ex; + const at = this.at; + const M = this.L.mouse; + const enc = this.ensureMouseEncoder(); + // The tracking mode and format, from the terminal. That call also clears the + // encoder's last cell, so it is made only when a mouse mode changed: per-cell motion + // deduplication would otherwise reset on every event. + let modes = 0; + MOUSE_MODES.forEach((mode, i) => { + if (this.getMode(mode)) modes |= 1 << i; + }); + if (modes !== this.mouseModes) { + ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.term); + this.mouseModes = modes; + } + this.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; + ex.ghostty_mouse_encoder_setopt(enc, M.ANY_BUTTON_PRESSED, at.u8); + const ev = this.mouseEvent; + ex.ghostty_mouse_event_set_action(ev, M.action[input.action]); + if (input.button) ex.ghostty_mouse_event_set_button(ev, M.button[input.button]); + else ex.ghostty_mouse_event_clear_button(ev); + ex.ghostty_mouse_event_set_mods(ev, input.mods); + const dv = this.dv(); + dv.setFloat32(at.mousePos + M.x, input.x, true); + dv.setFloat32(at.mousePos + M.y, input.y, true); + ex.ghostty_mouse_event_set_position(ev, at.mousePos); + const rc = ex.ghostty_mouse_encoder_encode(enc, ev, at.mouseOut, MOUSE_REPORT_MAX, at.u32); + if (rc !== 0) return new Uint8Array(0); + const n = this.dv().getUint32(at.u32, true); + return this.u8().slice(at.mouseOut, at.mouseOut + n); + } + + /** The mouse encoder, made with per-cell motion deduplication on, and its event. */ + private ensureMouseEncoder(): number { + if (this.mouseEncoder) return this.mouseEncoder; + const ex = this.ex; + const vt = this.vt; + this.mouseEncoder = vt.newHandle('mouse_encoder_new', (slot) => + ex.ghostty_mouse_encoder_new(0, slot) + ); + this.mouseEvent = vt.newHandle('mouse_event_new', (slot) => + ex.ghostty_mouse_event_new(0, slot) + ); + this.u8()[this.at.u8] = 1; + ex.ghostty_mouse_encoder_setopt(this.mouseEncoder, this.L.mouse.TRACK_LAST_CELL, this.at.u8); + return this.mouseEncoder; + } + // ========================================================================== // Replies and effects // ========================================================================== diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index c6157a5..842b1fc 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -13,7 +13,7 @@ * - Captures all keyboard input (preventDefault on everything) */ -import type { Ghostty } from './ghostty'; +import type { Ghostty, MouseButton, MouseInput } from './ghostty'; import type { KeyEncoder } from './ghostty'; import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; @@ -163,14 +163,17 @@ const KEY_MAP: Record = { export interface MouseTrackingConfig { /** Check if any mouse tracking mode is enabled */ hasMouseTracking: () => boolean; - /** Check if SGR extended mouse mode is enabled (mode 1006) */ - hasSgrMouseMode: () => boolean; - /** Get cell dimensions for pixel to cell conversion */ - getCellDimensions: () => { width: number; height: number }; - /** Get canvas/container offset for accurate position calculation */ - getCanvasOffset: () => { left: number; top: number }; + /** + * The report for a mouse event at a client position, encoded by Ghostty's mouse + * encoder in the app's tracking mode and format, or '' when the app does not get one. + * (webtty: upstream encoded SGR or X10 itself, ADR 049) + */ + encode: (event: MouseEventReport) => string; } +/** A mouse event to report, at a client (viewport) position. (webtty, ADR 049) */ +export type MouseEventReport = Omit & { clientX: number; clientY: number }; + /** * The sequence for a key the official encoder does not send as xterm does, or null to * encode the key. For Shift+Enter and Shift+Escape the encoder sends Ghostty's @@ -189,29 +192,37 @@ export function plainKeySequence(key: Key, mods: Mods): string | null { } /** - * Button code for a mouse motion report, from the pressed-button bitmask - * (bit 0 left, bit 1 middle, bit 2 right). Motion adds 32 to the button, and - * no button is button 3, so hover is 35. (webtty: upstream sent 32 for hover, - * the same code as a left-button drag.) + * Ghostty's button for `MouseEvent.button`: 0 left, 1 middle, 2 right, and 3/4 back + * and forward, which Ghostty reports as buttons 8/9 (codes 128/129). (webtty: upstream + * sent `MouseEvent.button` as the code, so back was a release and forward Shift.) + */ +export const MOUSE_BUTTONS: readonly MouseButton[] = ['left', 'middle', 'right', 'eight', 'nine']; + +/** + * The button a motion report carries, from the pressed-button bitmask (bit n is + * `MouseEvent.button` n): the lowest one held, or none, which Ghostty reports as 35. + * (webtty: upstream sent 32 for hover, the same code as a left-button drag.) */ -export function mouseMotionButton(pressed: number): number { - if (pressed & 1) return 32; // Left - if (pressed & 2) return 33; // Middle - if (pressed & 4) return 34; // Right - return 35; // No button +export function motionButton(pressed: number): MouseButton | null { + for (let i = 0; i < MOUSE_BUTTONS.length; i++) { + if (pressed & (1 << i)) return MOUSE_BUTTONS[i]; + } + return null; } /** - * Whether a motion event lands in a different cell from the last mouse report. - * Like native terminals, motion is reported once per cell, not per pixel. - * (webtty: upstream reported every mousemove, so a drag within one cell sent - * repeats that vim reads as a visual-mode toggle.) + * The modifiers of a mouse event. Ghostty reports Shift (+4), Alt (+8) and Ctrl (+16), + * not Super. (webtty: upstream sent Meta as +8 and dropped Alt.) */ -export function isNewMouseCell( - last: { col: number; row: number } | null, - cell: { col: number; row: number } -): boolean { - return !last || last.col !== cell.col || last.row !== cell.row; +export function mouseMods( + e: Pick +): number { + return ( + (e.shiftKey ? Mods.SHIFT : 0) | + (e.altKey ? Mods.ALT : 0) | + (e.ctrlKey ? Mods.CTRL : 0) | + (e.metaKey ? Mods.SUPER : 0) + ); } export class InputHandler { @@ -231,10 +242,12 @@ export class InputHandler { private mousedownListener: ((e: MouseEvent) => void) | null = null; private mouseupListener: ((e: MouseEvent) => void) | null = null; private mousemoveListener: ((e: MouseEvent) => void) | null = null; + // Where move and release are heard: the document, so a drag keeps reporting past the + // terminal's edge (webtty, ADR 049) + private mouseTarget: Pick; private isComposing = false; private isDisposed = false; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting - private lastMouseCell: { col: number; row: number } | null = null; // Cell of the last mouse report private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; private lastCompositionData: string | null = null; @@ -269,6 +282,7 @@ export class InputHandler { this.getModeCallback = getMode; this.onCopyCallback = onCopy; this.mouseConfig = mouseConfig; + this.mouseTarget = container.ownerDocument ?? container; // Attach event listeners this.attach(); @@ -319,10 +333,10 @@ export class InputHandler { this.container.addEventListener('mousedown', this.mousedownListener); this.mouseupListener = this.handleMouseUp.bind(this); - this.container.addEventListener('mouseup', this.mouseupListener); + this.mouseTarget.addEventListener('mouseup', this.mouseupListener); this.mousemoveListener = this.handleMouseMove.bind(this); - this.container.addEventListener('mousemove', this.mousemoveListener); + this.mouseTarget.addEventListener('mousemove', this.mousemoveListener); // Wheel input is the terminal's alone (Terminal.handleWheel, paced). A listener here // also fired for a wheel over the padding, where the container itself is the target, @@ -599,98 +613,32 @@ export class InputHandler { // Mouse Event Handling (for terminal mouse tracking) // ========================================================================== - /** - * Convert pixel coordinates to terminal cell coordinates - */ - private pixelToCell(event: MouseEvent): { col: number; row: number } | null { - if (!this.mouseConfig) return null; - - const dims = this.mouseConfig.getCellDimensions(); - const offset = this.mouseConfig.getCanvasOffset(); - - if (dims.width <= 0 || dims.height <= 0) return null; - - const x = event.clientX - offset.left; - const y = event.clientY - offset.top; - - // Convert to 1-based cell coordinates (terminal uses 1-based) - const col = Math.floor(x / dims.width) + 1; - const row = Math.floor(y / dims.height) + 1; - - // Clamp to valid range (at least 1) - return { - col: Math.max(1, col), - row: Math.max(1, row), - }; + /** Whether a mouse event is over the terminal (the container or what is in it). */ + private isOverTerminal(event: MouseEvent): boolean { + const target = event.target as Node | null; + return !!target && (target === this.container || !!this.container.contains?.(target)); } /** - * Get modifier flags for mouse event - */ - private getMouseModifiers(event: MouseEvent): number { - let mods = 0; - if (event.shiftKey) mods |= 4; - if (event.metaKey) mods |= 8; // Meta (Cmd on Mac) - if (event.ctrlKey) mods |= 16; - return mods; - } - - /** - * Encode mouse event as SGR sequence - * SGR format: \x1b[ - */ - private encodeMouseX10(button: number, col: number, row: number, modifiers: number): string { - // X10 format adds 32 to all values and encodes as characters - // Button encoding: 0=left, 1=middle, 2=right, 3=release - const btn = button + modifiers + 32; - const colChar = String.fromCharCode(Math.min(col + 32, 255)); - const rowChar = String.fromCharCode(Math.min(row + 32, 255)); - return `\x1b[M${String.fromCharCode(btn)}${colChar}${rowChar}`; - } - - /** - * Send mouse event to terminal + * Sends a mouse event as Ghostty encodes it. Ghostty decides what is reported: the + * tracking mode (presses only in X10 mode 9, no motion in 1000, motion with a button + * in 1002), motion once per cell, the padding as the nearest edge cell, and motion + * outside the terminal only while a button is held. (webtty, ADR 049) */ private sendMouseEvent( - button: number, - col: number, - row: number, - isRelease: boolean, - event: MouseEvent + event: MouseEvent, + action: MouseInput['action'], + button: MouseButton | null ): void { - const modifiers = this.getMouseModifiers(event); - this.lastMouseCell = { col, row }; - - // Check if SGR extended mode is enabled (mode 1006) - const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true; - - let sequence: string; - if (useSGR) { - sequence = this.encodeMouseSGR(button, col, row, isRelease, modifiers); - } else { - // X10/normal mode doesn't support release events directly - // Button 3 means release in X10 mode - const x10Button = isRelease ? 3 : button; - sequence = this.encodeMouseX10(x10Button, col, row, modifiers); - } - - this.onDataCallback(sequence); + const data = this.mouseConfig?.encode({ + action, + button, + mods: mouseMods(event), + clientX: event.clientX, + clientY: event.clientY, + anyButtonPressed: this.mouseButtonsPressed !== 0, + }); + if (data) this.onDataCallback(data); } /** @@ -700,69 +648,52 @@ export class InputHandler { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return; - const cell = this.pixelToCell(event); - if (!cell) return; - - // Map browser button to terminal button - // event.button: 0=left, 1=middle, 2=right - // Terminal: 0=left, 1=middle, 2=right - const button = event.button; + const button = MOUSE_BUTTONS[event.button]; + if (!button) return; + // Back and forward would leave the session's page (webtty) + if (event.button >= 3) event.preventDefault(); // Track pressed buttons for motion events - this.mouseButtonsPressed |= 1 << button; + this.mouseButtonsPressed |= 1 << event.button; - this.sendMouseEvent(button, cell.col, cell.row, false, event); + this.sendMouseEvent(event, 'press', button); // Don't prevent default - let SelectionManager handle selection - // Only prevent if we actually handled the event - // event.preventDefault(); } /** - * Handle mouseup event + * Handle mouseup event, anywhere in the document: outside the terminal only for a + * button pressed in it (webtty: upstream lost a release outside the container) */ private handleMouseUp(event: MouseEvent): void { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return; - const cell = this.pixelToCell(event); - if (!cell) return; - - const button = event.button; + const button = MOUSE_BUTTONS[event.button]; + if (!button) return; + const bit = 1 << event.button; + if (!(this.mouseButtonsPressed & bit) && !this.isOverTerminal(event)) return; + if (event.button >= 3) event.preventDefault(); // Clear pressed button - this.mouseButtonsPressed &= ~(1 << button); + this.mouseButtonsPressed &= ~bit; - this.sendMouseEvent(button, cell.col, cell.row, true, event); + this.sendMouseEvent(event, 'release', button); } /** - * Handle mousemove event + * Handle mousemove event, anywhere in the document: outside the terminal only while a + * button pressed in it is held, so a drag past the edge keeps reporting (webtty) */ private handleMouseMove(event: MouseEvent): void { if (this.isDisposed) return; if (!this.mouseConfig?.hasMouseTracking()) return; - // Check if button motion mode or any-event tracking is enabled - // Mode 1002 = button motion, Mode 1003 = any motion - const hasButtonMotion = this.getModeCallback?.(1002) ?? false; - const hasAnyMotion = this.getModeCallback?.(1003) ?? false; - - if (!hasButtonMotion && !hasAnyMotion) return; - - // No button held: a release outside the container never cleared the bitmask + // No button held: a release outside the document never cleared the bitmask if (event.buttons === 0) this.mouseButtonsPressed = 0; + if (this.mouseButtonsPressed === 0 && !this.isOverTerminal(event)) return; - // In button motion mode, only report if a button is pressed - if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return; - - const cell = this.pixelToCell(event); - if (!cell) return; - if (!isNewMouseCell(this.lastMouseCell, cell)) return; - - const button = mouseMotionButton(this.mouseButtonsPressed); - - this.sendMouseEvent(button, cell.col, cell.row, false, event); + this.sendMouseEvent(event, 'motion', motionButton(this.mouseButtonsPressed)); } /** @@ -889,12 +820,12 @@ export class InputHandler { } if (this.mouseupListener) { - this.container.removeEventListener('mouseup', this.mouseupListener); + this.mouseTarget.removeEventListener('mouseup', this.mouseupListener); this.mouseupListener = null; } if (this.mousemoveListener) { - this.container.removeEventListener('mousemove', this.mousemoveListener); + this.mouseTarget.removeEventListener('mousemove', this.mousemoveListener); this.mousemoveListener = null; } diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 370f67a..c8fbc40 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -16,9 +16,20 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; -import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; +import type { + Ghostty, + GhosttyCell, + GhosttyTerminal, + GhosttyTerminalConfig, + MouseGeometry, +} from './ghostty'; import { AT_ONCE, type Pace } from './glide'; -import { InputHandler, type MouseTrackingConfig } from './input-handler'; +import { + InputHandler, + type MouseEventReport, + type MouseTrackingConfig, + mouseMods, +} from './input-handler'; import type { IBufferNamespace, IDisposable, @@ -38,7 +49,6 @@ import { type WheelAccumulator, wheelArrowKey, wheelArrows, - wheelSgr, wheelTicks, } from './wheel'; @@ -98,6 +108,70 @@ export function decscusrShape(ps: number): CursorShape | undefined { }; } +/** + * The mouse encoder's geometry and a client position in it (ADR 049). Ghostty needs a + * whole cell size, and webtty's CSS cell is fractional, so the units are device-pixel + * cells: the cell is `cellPx` (the size Ghostty's size reports give, so SGR-pixels + * positions agree with them), and CSS lengths are scaled by `cellPx` over the CSS cell. + * The screen is the container `box`, so the padding around the grid is part of it and + * Ghostty maps it to the nearest edge cell. The position is measured from the grid's + * origin and then offset by the rounded padding, so rounding never moves a cell + * boundary. (webtty) + */ +export function mouseSurface( + clientX: number, + clientY: number, + grid: { + left: number; + top: number; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + }, + box: { left: number; top: number; right: number; bottom: number }, + cellPx: { width: number; height: number } +): { geometry: MouseGeometry; x: number; y: number } { + const sx = cellPx.width / grid.cellWidth; + const sy = cellPx.height / grid.cellHeight; + const left = Math.max(0, Math.round((grid.left - box.left) * sx)); + const top = Math.max(0, Math.round((grid.top - box.top) * sy)); + const gridRight = grid.left + grid.cols * grid.cellWidth; + const gridBottom = grid.top + grid.rows * grid.cellHeight; + const right = Math.max(0, Math.round((box.right - gridRight) * sx)); + const bottom = Math.max(0, Math.round((box.bottom - gridBottom) * sy)); + return { + geometry: { + screenWidth: left + grid.cols * cellPx.width + right, + screenHeight: top + grid.rows * cellPx.height + bottom, + cellWidth: cellPx.width, + cellHeight: cellPx.height, + paddingTop: top, + paddingRight: right, + paddingBottom: bottom, + paddingLeft: left, + }, + x: (clientX - grid.left) * sx + left, + y: (clientY - grid.top) * sy + top, + }; +} + +const MOUSE_REPORT_DECODER = new TextDecoder('utf-8', { fatal: true }); + +/** + * A mouse report as text for the PTY, or '' when it is not valid UTF-8. Input travels as + * text frames that the server writes as UTF-8, so a report with raw bytes above 0x7f + * (X10 format past column or row 95, X10 buttons 8/9) would reach the app as different + * bytes; it is dropped instead (ADR 049). (webtty) + */ +export function mouseReportText(bytes: Uint8Array): string { + try { + return MOUSE_REPORT_DECODER.decode(bytes); + } catch { + return ''; + } +} + /** * A cell's size in whole device pixels, for Ghostty's size reports * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as kitty.ts's `deviceCellSize` @@ -529,22 +603,10 @@ export class Terminal implements ITerminalCore { // so a reply never says 0 px. Mode 2048 cannot be on yet. (webtty, ADR 042) this.syncCellPixelSize(); - // Create mouse tracking configuration - const canvas = this.canvas; - const renderer = this.renderer; - const wasmTerm = this.wasmTerm; + // Create mouse tracking configuration. Reports are encoded by Ghostty (webtty, ADR 049) const mouseConfig: MouseTrackingConfig = { - hasMouseTracking: () => wasmTerm?.hasMouseTracking() ?? false, - hasSgrMouseMode: () => wasmTerm?.getMode(1006, false) ?? true, // SGR extended mode - getCellDimensions: () => ({ - width: renderer.charWidth, - height: renderer.charHeight, - }), - getCanvasOffset: () => { - const rect = canvas.getBoundingClientRect(); - // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) - return { left: rect.left, top: rect.top + renderer.scrollOffset }; - }, + hasMouseTracking: () => this.wasmTerm?.hasMouseTracking() ?? false, + encode: (event) => this.encodeMouse(event), }; // Create input handler @@ -1458,8 +1520,8 @@ export class Terminal implements ITerminalCore { return; } - // App tracks the mouse (e.g. vim `set mouse=a`): report scroll ticks as SGR - // buttons 64/65 at the cell under the pointer, on either screen. + // App tracks the mouse (e.g. vim `set mouse=a`): report scroll ticks as mouse + // buttons 4/5 (SGR 64/65) at the cell under the pointer, on either screen. if (this.wasmTerm?.hasMouseTracking()) { const metrics = this.renderer?.getMetrics(); if (!metrics || !this.canvas) return; @@ -1471,13 +1533,17 @@ export class Terminal implements ITerminalCore { this.options.mouseScrollSpeed ); if (ticks === 0) return; - const rect = this.canvas.getBoundingClientRect(); - const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); - const row = Math.max( - 1, - Math.floor((e.clientY - rect.top - this.renderer!.scrollOffset) / metrics.height) + 1 - ); - this.tickPacer.push(wheelSgr(Math.sign(ticks), col, row), Math.sign(ticks), Math.abs(ticks), pace); + // One tick, a press of button 4 (up) or 5 (down) at the pointer's cell as Ghostty + // encodes it, repeated by the pacer. Nothing in X10 mode 9. (webtty, ADR 049) + const tick = this.encodeMouse({ + action: 'press', + button: ticks < 0 ? 'four' : 'five', + mods: mouseMods(e), + clientX: e.clientX, + clientY: e.clientY, + anyButtonPressed: false, + }); + if (tick) this.tickPacer.push(tick, Math.sign(ticks), Math.abs(ticks), pace); return; } @@ -1504,6 +1570,36 @@ export class Terminal implements ITerminalCore { // SmoothScroll, ADR 040, ADR 045); upstream's easing here is removed. }; + /** + * A mouse report at a client position, encoded by Ghostty's mouse encoder in the + * app's tracking mode and format (`mouseSurface`, `mouseReportText`), or '' when the + * app does not get one. (webtty, ADR 049) + */ + private encodeMouse(event: MouseEventReport): string { + const px = this.cellPixelSize(); + if (!this.wasmTerm || !this.renderer || !this.canvas || !this.element || !px) return ''; + const metrics = this.renderer.getMetrics(); + const rect = this.canvas.getBoundingClientRect(); + const { geometry, x, y } = mouseSurface( + event.clientX, + event.clientY, + { + left: rect.left, + // Viewport row 0 starts the scroll offset below the canvas top (ADR 043) + top: rect.top + this.renderer.scrollOffset, + cols: this.cols, + rows: this.rows, + cellWidth: metrics.width, + cellHeight: metrics.height, + }, + this.element.getBoundingClientRect(), + px + ); + this.wasmTerm.setMouseGeometry(geometry); + const { clientX: _x, clientY: _y, ...input } = event; + return mouseReportText(this.wasmTerm.encodeMouse({ ...input, x, y })); + } + /** * Handle mouse down for scrollbar interaction */ diff --git a/src/client/vendor/ghostty-web/wheel.ts b/src/client/vendor/ghostty-web/wheel.ts index 8b2fc9b..c248908 100644 --- a/src/client/vendor/ghostty-web/wheel.ts +++ b/src/client/vendor/ghostty-web/wheel.ts @@ -1,7 +1,8 @@ /** * Wheel input for apps that scroll themselves: what a wheel event sends when the - * app tracks the mouse (SGR scroll buttons) or is on the alternate screen without - * mouse tracking (arrow keys), and the pacing that spreads it over time. The + * app tracks the mouse (scroll ticks, each encoded by Ghostty's mouse encoder as a + * press of button 4 or 5, ADR 049) or is on the alternate screen without mouse + * tracking (arrow keys), and the pacing that spreads it over time. The * terminal's `handleWheel` routes events here. (webtty, ADR 017, ADR 040, ADR 045) */ import { Glide, linear, type Pace } from './glide'; @@ -28,7 +29,7 @@ function accumulate(acc: WheelAccumulator, deltaY: number, amount: number): numb /** * Scroll ticks a wheel event sends to an app that tracks the mouse: negative - * scrolls up (SGR button 64), positive down (65). Pixel deltas (trackpads, and + * scrolls up (button 4, SGR 64), positive down (5, SGR 65). Pixel deltas (trackpads, and * mouse wheels in Chromium/Safari on macOS) count by distance, one tick per cell * height, 1:1 with the OS, and `speed` does not apply (Ghostty skips its scroll * multiplier for precision scrolls too). Line and page deltas count one tick per @@ -66,12 +67,6 @@ export function wheelArrows( return accumulate(acc, e.deltaY, lines); } -/** SGR mouse report for `ticks` wheel ticks at a 1-based cell. */ -export function wheelSgr(ticks: number, col: number, row: number): string { - const btn = ticks < 0 ? 64 : 65; - return `\x1b[<${btn};${col};${row}M`.repeat(Math.abs(ticks)); -} - /** * One up (`dir` < 0) or down arrow key. With application cursor keys (DECCKM), * which less sets, it is ESC O A/B; less ignores ESC [ A/B then. diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index e8e494c..b7e1805 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -4,8 +4,9 @@ import { fileURLToPath } from 'node:url'; import { Ghostty } from './ghostty-web/ghostty'; import { InputHandler, - isNewMouseCell, - mouseMotionButton, + type MouseEventReport, + motionButton, + mouseMods, plainKeySequence, } from './ghostty-web/input-handler'; import { Key, Mods } from './ghostty-web/types'; @@ -15,44 +16,140 @@ const WASM = path.join( 'ghostty-vt/ghostty-vt-small.wasm', ); -describe('mouseMotionButton', () => { - test('encodes hover (no button held) as 35', () => { - expect(mouseMotionButton(0)).toBe(35); +describe('motionButton', () => { + test('no button held is none, which Ghostty reports as hover (35)', () => { + expect(motionButton(0)).toBeNull(); }); - test('encodes a left-button drag as 32', () => { - expect(mouseMotionButton(1)).toBe(32); - }); - - test('encodes a middle-button drag as 33', () => { - expect(mouseMotionButton(2)).toBe(33); - }); - - test('encodes a right-button drag as 34', () => { - expect(mouseMotionButton(4)).toBe(34); + test('a drag reports the lowest button held', () => { + expect(motionButton(1)).toBe('left'); + expect(motionButton(2)).toBe('middle'); + expect(motionButton(4)).toBe('right'); + expect(motionButton(1 | 4)).toBe('left'); + expect(motionButton(2 | 4)).toBe('middle'); + expect(motionButton(8)).toBe('eight'); }); +}); - test('reports the lowest button when several are held', () => { - expect(mouseMotionButton(1 | 4)).toBe(32); - expect(mouseMotionButton(2 | 4)).toBe(33); +describe('mouseMods', () => { + test('passes Shift, Alt, Ctrl and Meta as their own bits', () => { + const none = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; + expect(mouseMods(none)).toBe(Mods.NONE); + expect(mouseMods({ ...none, altKey: true })).toBe(Mods.ALT); + expect(mouseMods({ ...none, metaKey: true })).toBe(Mods.SUPER); + expect(mouseMods({ ...none, shiftKey: true, ctrlKey: true })).toBe(Mods.SHIFT | Mods.CTRL); }); }); -describe('isNewMouseCell', () => { - test('reports the first motion when nothing was reported yet', () => { - expect(isNewMouseCell(null, { col: 8, row: 4 })).toBe(true); - }); +describe('InputHandler mouse', () => { + type Listener = (e: object) => void; + const target = () => { + const listeners = new Map(); + return { + listeners, + addEventListener: (type: string, listener: Listener) => + listeners.set(type, [...(listeners.get(type) ?? []), listener]), + removeEventListener: (type: string, listener: Listener) => + listeners.set( + type, + (listeners.get(type) ?? []).filter((l) => l !== listener), + ), + fire: (type: string, e: object) => { + for (const l of listeners.get(type) ?? []) l(e); + }, + }; + }; + + async function setup() { + const ghostty = await Ghostty.load(WASM); + const doc = target(); + const outside = {}; + const container = { + ...target(), + ownerDocument: doc, + contains: (node: unknown) => node !== outside, + }; + const encoded: MouseEventReport[] = []; + const sent: string[] = []; + const handler = new InputHandler( + ghostty, + container as unknown as HTMLElement, + (data) => sent.push(data), + undefined, + undefined, + undefined, + { + hasMouseTracking: () => true, + encode: (event) => { + encoded.push(event); + return `<${event.action}:${event.button}>`; + }, + }, + ); + const mouse = (over: boolean, extra: object) => { + const prevented = { value: false }; + return { + prevented, + event: { + target: over ? container : outside, + button: 0, + buttons: 0, + clientX: 12, + clientY: 34, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: () => { + prevented.value = true; + }, + ...extra, + }, + }; + }; + return { handler, container, doc, encoded, sent, mouse }; + } - test('drops motion within the last reported cell', () => { - expect(isNewMouseCell({ col: 8, row: 4 }, { col: 8, row: 4 })).toBe(false); + test('reports a press, then a drag and its release past the terminal edge', async () => { + const { handler, container, doc, encoded, sent, mouse } = await setup(); + container.fire('mousedown', mouse(true, { button: 0, buttons: 1, altKey: true }).event); + doc.fire('mousemove', mouse(false, { buttons: 1, clientX: -40 }).event); + doc.fire('mouseup', mouse(false, { button: 0, clientX: -40 }).event); + expect(sent).toEqual(['', '', '']); + expect(encoded[0]).toEqual({ + action: 'press', + button: 'left', + mods: Mods.ALT, + clientX: 12, + clientY: 34, + anyButtonPressed: true, + }); + expect(encoded[1]).toMatchObject({ clientX: -40, anyButtonPressed: true }); + expect(encoded[2]).toMatchObject({ action: 'release', anyButtonPressed: false }); + handler.dispose(); + expect(doc.listeners.get('mousemove')).toEqual([]); }); - test('reports motion into a new column', () => { - expect(isNewMouseCell({ col: 8, row: 4 }, { col: 9, row: 4 })).toBe(true); + test('hover is reported over the terminal only, with no button', async () => { + const { handler, doc, sent, mouse } = await setup(); + doc.fire('mousemove', mouse(true, {}).event); + doc.fire('mousemove', mouse(false, {}).event); + // A release outside the terminal of a button not pressed in it + doc.fire('mouseup', mouse(false, { button: 2 }).event); + expect(sent).toEqual(['']); + handler.dispose(); }); - test('reports motion into a new row', () => { - expect(isNewMouseCell({ col: 8, row: 4 }, { col: 8, row: 5 })).toBe(true); + test('back and forward are buttons 8 and 9, and do not navigate', async () => { + const { handler, container, doc, sent, mouse } = await setup(); + const down = mouse(true, { button: 3, buttons: 8 }); + container.fire('mousedown', down.event); + const up = mouse(true, { button: 4 }); + doc.fire('mouseup', up.event); + expect(sent).toEqual(['', '']); + expect(down.prevented.value).toBe(true); + expect(up.prevented.value).toBe(true); + handler.dispose(); }); }); @@ -78,9 +175,7 @@ describe('InputHandler wheel', () => { undefined, { hasMouseTracking: () => true, - hasSgrMouseMode: () => true, - getCellDimensions: () => ({ width: 8, height: 16 }), - getCanvasOffset: () => ({ left: 10, top: 10 }), + encode: () => 'report', }, ); const wheel = { deltaY: 40, clientX: 2, clientY: 2, preventDefault: () => {} }; diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 35b26b5..c878b86 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -5,6 +5,7 @@ import { DirtyState, Ghostty, type GhosttyTerminal, + type MouseInput, type OfficialTerminal, } from './ghostty-web/ghostty'; import { CellFlags, Key, KeyAction, KeyEncoderOption, Mods } from './ghostty-web/types'; @@ -689,3 +690,166 @@ describe('official engine: key encoder', () => { enc.dispose(); }); }); + +describe('official engine: mouse encoder (ADR 049)', () => { + // A 40x10 grid of 10 px cells with 5 px of padding on every side: the screen is + // 410x110, and cell (col, row) 1-based spans x 5+10(col-1) .. 5+10col. + const GEOMETRY = { + screenWidth: 410, + screenHeight: 110, + cellWidth: 10, + cellHeight: 10, + paddingTop: 5, + paddingRight: 5, + paddingBottom: 5, + paddingLeft: 5, + }; + const at = (col: number, row: number) => ({ x: 5 + 10 * col - 5, y: 5 + 10 * row - 5 }); + const tracking = (modes: string, cols = 40) => { + const t = term(cols, 10); + t.write(modes); + t.setMouseGeometry({ ...GEOMETRY, screenWidth: 10 + 10 * cols }); + return t; + }; + const report = ( + t: GhosttyTerminal, + action: MouseInput['action'], + button: MouseInput['button'], + pos: { x: number; y: number }, + mods = Mods.NONE, + anyButtonPressed = action === 'press', + ) => + new TextDecoder('latin1').decode( + t.encodeMouse({ action, button, mods, ...pos, anyButtonPressed }), + ); + + test('SGR clicks carry Shift, Alt and Ctrl, not Super', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'left', at(3, 2))).toBe('\x1b[<0;3;2M'); + expect(report(t, 'release', 'left', at(3, 2))).toBe('\x1b[<0;3;2m'); + expect(report(t, 'press', 'left', at(3, 2), Mods.ALT)).toBe('\x1b[<8;3;2M'); + expect(report(t, 'press', 'left', at(3, 2), Mods.SUPER)).toBe('\x1b[<0;3;2M'); + expect(report(t, 'press', 'right', at(3, 2), Mods.SHIFT | Mods.CTRL)).toBe('\x1b[<22;3;2M'); + expect(report(t, 'press', 'middle', at(3, 2))).toBe('\x1b[<1;3;2M'); + t.free(); + }); + + test('back and forward are buttons 8 and 9 (128, 129)', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'eight', at(1, 1))).toBe('\x1b[<128;1;1M'); + expect(report(t, 'release', 'nine', at(1, 1))).toBe('\x1b[<129;1;1m'); + t.free(); + }); + + test('the wheel is buttons 4 and 5 in the format the app chose', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'four', at(7, 4), Mods.NONE, false)).toBe('\x1b[<64;7;4M'); + expect(report(t, 'press', 'five', at(7, 4), Mods.CTRL, false)).toBe('\x1b[<81;7;4M'); + t.write('\x1b[?1006l'); + expect(report(t, 'press', 'four', at(7, 4), Mods.NONE, false)).toBe("\x1b[M`'$"); + t.free(); + }); + + test('any-event tracking reports hover as 35, once per cell', () => { + const t = tracking('\x1b[?1003h\x1b[?1006h'); + const hover = (x: number, y: number) => report(t, 'motion', null, { x, y }); + expect(hover(26, 16)).toBe('\x1b[<35;3;2M'); + expect(hover(29, 19)).toBe(''); + expect(hover(36, 19)).toBe('\x1b[<35;4;2M'); + // A press sets the last cell too + expect(report(t, 'press', 'left', at(5, 2))).toBe('\x1b[<0;5;2M'); + expect(report(t, 'motion', 'left', at(5, 2), Mods.NONE, true)).toBe(''); + expect(report(t, 'motion', 'left', at(6, 2), Mods.NONE, true)).toBe('\x1b[<32;6;2M'); + t.free(); + }); + + test('a mode change resets the last cell', () => { + const t = tracking('\x1b[?1003h\x1b[?1006h'); + expect(report(t, 'motion', null, at(2, 2))).toBe('\x1b[<35;2;2M'); + expect(report(t, 'motion', null, at(2, 2))).toBe(''); + // The format last set wins + t.write('\x1b[?1015h'); + expect(report(t, 'motion', null, at(2, 2))).toBe('\x1b[67;2;2M'); + t.free(); + }); + + test('normal tracking (1000) reports no motion, button tracking (1002) only drags', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'motion', 'left', at(2, 2), Mods.NONE, true)).toBe(''); + t.write('\x1b[?1002h'); + expect(report(t, 'motion', null, at(3, 2))).toBe(''); + expect(report(t, 'motion', 'left', at(3, 2), Mods.NONE, true)).toBe('\x1b[<32;3;2M'); + t.free(); + }); + + test('X10 mode 9 reports presses of left, middle and right only, without modifiers', () => { + const t = tracking('\x1b[?9h'); + expect(report(t, 'press', 'left', at(5, 3), Mods.SHIFT)).toBe('\x1b[M %#'); + expect(report(t, 'release', 'left', at(5, 3))).toBe(''); + expect(report(t, 'press', 'four', at(5, 3), Mods.NONE, false)).toBe(''); + expect(report(t, 'press', 'eight', at(5, 3))).toBe(''); + t.free(); + }); + + test('X10 format: releases are button 3, and columns past 95 need bytes above 0x7f', () => { + const t = tracking('\x1b[?1000h', 120); + expect(report(t, 'press', 'left', at(5, 3), Mods.ALT)).toBe('\x1b[M(%#'); + expect(report(t, 'release', 'left', at(5, 3))).toBe('\x1b[M#%#'); + expect(report(t, 'press', 'left', at(95, 1))).toBe('\x1b[M \x7f!'); + expect([ + ...t.encodeMouse({ + action: 'press', + button: 'left', + mods: 0, + ...at(96, 1), + anyButtonPressed: true, + }), + ]).toEqual([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0x21]); + t.free(); + }); + + test('UTF-8 (1005), urxvt (1015) and SGR-pixels (1016) formats', () => { + const t = tracking('\x1b[?1000h\x1b[?1005h', 300); + const utf8 = new TextDecoder().decode( + t.encodeMouse({ + action: 'press', + button: 'left', + mods: 0, + ...at(200, 3), + anyButtonPressed: true, + }), + ); + expect(utf8).toBe(`\x1b[M ${String.fromCodePoint(232)}#`); + t.write('\x1b[?1005l\x1b[?1015h'); + expect(report(t, 'press', 'left', at(200, 3))).toBe('\x1b[32;200;3M'); + t.write('\x1b[?1015l\x1b[?1016h'); + // Pixels from the grid's origin, not the screen's + expect(report(t, 'press', 'left', { x: 26, y: 17 })).toBe('\x1b[<0;21;12M'); + expect(report(t, 'release', 'left', { x: 2, y: 17 })).toBe('\x1b[<0;-3;12m'); + t.free(); + }); + + test('the padding is the nearest edge cell; outside the screen only drags and releases', () => { + const t = tracking('\x1b[?1002h\x1b[?1006h'); + expect(report(t, 'press', 'left', { x: 1, y: 1 })).toBe('\x1b[<0;1;1M'); + expect(report(t, 'press', 'left', { x: 408, y: 108 })).toBe('\x1b[<0;40;10M'); + // Dragged past the right edge: clamped to the edge cell + expect(report(t, 'motion', 'left', { x: 600, y: 50 }, Mods.NONE, true)).toBe('\x1b[<32;40;5M'); + expect(report(t, 'motion', 'left', { x: -50, y: 50 }, Mods.NONE, true)).toBe('\x1b[<32;1;5M'); + expect(report(t, 'release', 'left', { x: -50, y: 50 }, Mods.NONE, false)).toBe('\x1b[<0;1;5m'); + // Outside with no button held: nothing + expect(report(t, 'motion', null, { x: -50, y: 60 }, Mods.NONE, false)).toBe(''); + expect(report(t, 'press', 'left', { x: -50, y: 60 }, Mods.NONE, false)).toBe(''); + t.free(); + }); + + test('no tracking reports nothing, and a reset terminal starts without tracking', () => { + const t = tracking(''); + expect(report(t, 'press', 'left', at(1, 1))).toBe(''); + t.write('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'left', at(1, 1))).toBe('\x1b[<0;1;1M'); + t.write('\x1bc'); + expect(report(t, 'press', 'left', at(1, 1))).toBe(''); + t.free(); + }); +}); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 1d8c5a6..c17253f 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -9,6 +9,8 @@ import { decscusrShape, deviceCellPixels, mouseCursorFor, + mouseReportText, + mouseSurface, scanDecscusr, Terminal, } from './ghostty-web/terminal'; @@ -161,6 +163,119 @@ describe('deviceCellPixels', () => { }); }); +describe('mouseSurface', () => { + // A 9.6 x 19.2 px CSS cell at DPR 1.25 is 12 x 24 device pixels. + const grid = { left: 110, top: 57, cols: 100, rows: 30, cellWidth: 9.6, cellHeight: 19.2 }; + const box = { left: 100, top: 50, right: 110 + 960 + 7, bottom: 57 + 576 + 3 }; + const cellPx = { width: 12, height: 24 }; + const surface = (x: number, y: number) => mouseSurface(x, y, grid, box, cellPx); + + test('is the container in device-pixel cells, the padding around the grid', () => { + expect(surface(0, 0).geometry).toEqual({ + screenWidth: 13 + 1200 + 9, + screenHeight: 9 + 720 + 4, + cellWidth: 12, + cellHeight: 24, + paddingTop: 9, + paddingRight: 9, + paddingBottom: 4, + paddingLeft: 13, + }); + }); + + test('puts cell boundaries exactly where the CSS grid has them, at any column', () => { + // Column 99 (0-based) starts at 110 + 99 * 9.6 = 1060.4 CSS px + const cell = (x: number) => Math.floor((surface(x, 60).x - 13) / 12); + expect(cell(1060.3)).toBe(98); + expect(cell(1060.5)).toBe(99); + expect(cell(110)).toBe(0); + const row = (y: number) => Math.floor((surface(120, y).y - 9) / 24); + expect(row(57 + 29 * 19.2 - 0.1)).toBe(28); + expect(row(57 + 29 * 19.2 + 0.1)).toBe(29); + }); + + test('positions in the padding fall before or after the grid', () => { + expect(surface(101, 51).x).toBeLessThan(13); + expect(surface(101, 51).y).toBeLessThan(9); + expect(surface(1075, 60).x).toBeGreaterThan(13 + 1200); + }); +}); + +describe('mouseReportText', () => { + test('passes a UTF-8 report through as text', () => { + expect(mouseReportText(new TextEncoder().encode('\x1b[<0;1;1M'))).toBe('\x1b[<0;1;1M'); + expect(mouseReportText(new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0xc3, 0xa8, 0x23]))).toBe( + '\x1b[M è#', + ); + }); + + test('drops a report with raw bytes above 0x7f, which text frames cannot carry', () => { + expect(mouseReportText(new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0x21]))).toBe(''); + }); +}); + +describe('Terminal wheel in mouse-tracking apps', () => { + // A 10 px x 20 px CSS cell at DPR 1, the grid at (5, 5) in a container at (0, 0). + async function wheelTerm(modes: string) { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(40, 10); + wasmTerm.write(modes); + const term = new Terminal({ ghostty: {} as never, cols: 40, rows: 10 }); + const internals = term as unknown as { + wasmTerm: unknown; + renderer: unknown; + canvas: unknown; + element: unknown; + handleWheel(e: object): void; + }; + internals.wasmTerm = wasmTerm; + internals.renderer = { + getMetrics: () => ({ width: 10, height: 20, baseline: 15 }), + scrollOffset: 0, + }; + internals.canvas = { getBoundingClientRect: () => ({ left: 5, top: 5 }) }; + internals.element = { + getBoundingClientRect: () => ({ left: 0, top: 0, right: 410, bottom: 210 }), + }; + const sent: string[] = []; + term.onData((data) => sent.push(data)); + const wheel = (deltaY: number, clientX: number, clientY: number, mods: object = {}) => + internals.handleWheel({ + deltaY, + deltaMode: 1, + clientX, + clientY, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: () => {}, + stopPropagation: () => {}, + ...mods, + }); + return { sent, wheel, free: () => wasmTerm.free() }; + } + + test('sends buttons 4/5 at the pointer cell in SGR, with modifiers', async () => { + const { sent, wheel, free } = await wheelTerm('\x1b[?1000h\x1b[?1006h'); + wheel(-3, 5 + 10 * 6 + 1, 5 + 20 * 3 + 1); + wheel(3, 5 + 10 * 6 + 1, 5 + 20 * 3 + 1, { altKey: true }); + expect(sent).toEqual(['\x1b[<64;7;4M', '\x1b[<73;7;4M']); + free(); + }); + + test('follows the format: X10 bytes without 1006, nothing in X10 mode 9', async () => { + const x10 = await wheelTerm('\x1b[?1000h'); + x10.wheel(3, 6, 6); + expect(x10.sent).toEqual(['\x1b[Ma!!']); + x10.free(); + const mode9 = await wheelTerm('\x1b[?9h'); + mode9.wheel(3, 6, 6); + expect(mode9.sent).toEqual([]); + mode9.free(); + }); +}); + describe('Terminal scrollback options', () => { // The engine config the terminal builds from its options (ADR 046). const wasmConfig = (options: object) => diff --git a/src/client/vendor/wheel.test.ts b/src/client/vendor/wheel.test.ts index 04a4f8d..bf40c2b 100644 --- a/src/client/vendor/wheel.test.ts +++ b/src/client/vendor/wheel.test.ts @@ -4,7 +4,6 @@ import { type WheelAccumulator, wheelArrowKey, wheelArrows, - wheelSgr, wheelTicks, } from './ghostty-web/wheel'; @@ -51,16 +50,6 @@ describe('wheelTicks', () => { }); }); -describe('wheelSgr', () => { - test('scroll up is button 64 at the cell', () => { - expect(wheelSgr(-1, 5, 3)).toBe('\x1b[<64;5;3M'); - }); - - test('scroll down is button 65, one report per tick', () => { - expect(wheelSgr(2, 1, 1)).toBe('\x1b[<65;1;1M\x1b[<65;1;1M'); - }); -}); - describe('wheelArrows', () => { const acc = (): WheelAccumulator => ({ accum: 0, dir: 0 }); From 47a8db50361bb97d534e8a48edc2d86e435a2209 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:28:40 -0400 Subject: [PATCH 067/295] feat(client): take the cursor shape and blink from Ghostty's state The configured cursorStyle and cursorBlink are set as Ghostty's DEFAULT_CURSOR_STYLE and DEFAULT_CURSOR_BLINK, and the renderer draws the shape and blink the render state reports, instead of write() scanning every chunk of output for DECSCUSR (ADR 049). DECSCUSR overrides the default, Ps 0 and RIS restore it, and a config change does not override a shape the app chose, as before. Following Ghostty, mode 12 now also sets blinking, the shape is kept per screen, and DECSTR restores the default. --- docs/specs/client.md | 2 +- docs/specs/config.md | 8 +- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/ghostty.ts | 26 +++++ src/client/vendor/ghostty-web/renderer.ts | 16 ++- src/client/vendor/ghostty-web/terminal.ts | 87 +-------------- src/client/vendor/ghostty-web/types.ts | 6 ++ src/client/vendor/official-terminal.test.ts | 44 ++++++++ src/client/vendor/renderer.test.ts | 39 ++++++- src/client/vendor/terminal.test.ts | 111 +++++--------------- 10 files changed, 168 insertions(+), 173 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 3870d47..5e0799c 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -180,7 +180,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Output acknowledgements | Output is acknowledged as it is parsed, so the server sends it in order at the tab's pace; a tab closed with `4003` for falling behind resets and reconnects to the replay | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | -| Cursor style | `cursorStyle` / `cursorStyleBlink` defaults; DECSCUSR from PTY overrides at runtime inside the terminal (Ps 0 restores the default), and the cursor cell is repainted so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | +| Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default) and mode 12, and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | | IME / dictation at cursor | The terminal keeps focus on its input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index f781353..3f9e9e6 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -91,7 +91,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the startup defaults; apps override them at runtime via DECSCUSR. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (and blink via mode 12), and DECSCUSR 0 or a reset restores it. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -111,8 +111,8 @@ All keys are optional — omit any key to use the default value. | `scrollbackLines` | number | `10000` | Terminal history in lines; `0` = no line limit. History is also capped at 32 MiB, so wide terminals with many styles can keep fewer lines, and Ghostty prunes whole pages, so it keeps a little under the limit (about 9,700 of 10,000). Fractions are floored; negative values are ignored. The client derives nothing from `scrollback`. See [ADR 046](../adrs/046.client.scrollback-lines.md) and [ADR 047](../adrs/047.client.official-engine-only.md). | | `cols` | number | `80` | Initial terminal width in columns | | `rows` | number | `24` | Initial terminal height in rows | -| `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR — this is the startup default only. | -| `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. | +| `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. | +| `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR or mode 12; `CSI 0 SP q` and a terminal reset restore it. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | | `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | @@ -213,7 +213,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Hot config reload | Appearance re-read on tab reload; shell/PTY settings re-read on new PTY spawn; `port`/`host` locked for server lifetime | [ADR 009](../adrs/009.webtty.config-hot-reload.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | -| Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | +| Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime, and Ps 0 restores it | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | | Scrollback in lines | `scrollbackLines` (default `10000`, `0` = no line limit) sets terminal history in lines under a 32 MiB ceiling; `scrollback` stays the server replay buffer, so existing configs keep their meaning | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 465b178..a3b9617 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -16,7 +16,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code, a release outside the terminal no longer leaves a button held, motion is reported once per cell instead of per `mousemove`, and its own `wheel` listener is removed: the terminal's paced `handleWheel` is the only wheel path, where a wheel over the padding (the container itself the target, so the terminal's capture listener did not stop it) was sent a second time, unpaced. - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. -- `terminal.ts`: `write()` applies DECSCUSR (`scanDecscusr`, `decscusrShape`) as an app cursor shape over the configured `cursorStyle` / `cursorBlink`, which Ps 0 and `reset()` restore; `renderer.ts`: a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. +- `terminal.ts`, `renderer.ts`, `ghostty.ts`, `types.ts`: the cursor shape and blink are Ghostty's (ADR 049). The configured `cursorStyle` / `cursorBlink` are passed in `GhosttyTerminalConfig` and set as `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, which DECSCUSR Ps 0 and RIS restore; an option change calls the binding's new `setDefaultCursor`. `render()` draws the `style` and `blinking` that `getCursor()` reports (upstream drew only the options, and Ghostty's render-state style was hard-coded to block), and a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. webtty's earlier output scan for DECSCUSR (`scanDecscusr`, `decscusrShape`) is removed. - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 608f14c..b75f358 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -88,6 +88,11 @@ export interface GhosttyTerminal { resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void; /** Device-pixel cell size for size reports. */ setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void; + /** + * The default cursor shape and blink (DECSCUSR Ps 0, RIS). They show at once only + * while the app has not set a shape. (ADR 049) + */ + setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void; /** * The view's `viewportY` (lines scrolled up from the bottom). The renderer calls * it first in every render. @@ -439,7 +444,14 @@ function resolveOfficialLayout(layout: VtLayout) { SCROLLBACK_MAX_BYTES: opt('SCROLLBACK_MAX_BYTES'), SCROLLBACK_MAX_LINES: opt('SCROLLBACK_MAX_LINES'), MODE_DEFAULT: opt('MODE_DEFAULT'), + DEFAULT_CURSOR_STYLE: opt('DEFAULT_CURSOR_STYLE'), + DEFAULT_CURSOR_BLINK: opt('DEFAULT_CURSOR_BLINK'), }, + defaultCursorStyle: { + bar: layout.value('GhosttyTerminalCursorStyle', 'BAR'), + block: layout.value('GhosttyTerminalCursorStyle', 'BLOCK'), + underline: layout.value('GhosttyTerminalCursorStyle', 'UNDERLINE'), + } satisfies Record, tdata: { CURSOR_X: tdata('CURSOR_X'), CURSOR_Y: tdata('CURSOR_Y'), @@ -1166,6 +1178,17 @@ export class OfficialTerminal implements GhosttyTerminal { this.generation++; } + setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { + const L = this.L; + const at = this.at; + this.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); + this.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); + this.u8()[at.u8] = blink ? 1 : 0; + this.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); + // The cursor is read from the render state, which only a content change refreshes. + this.generation++; + } + /** * Records the view's offset. Ghostty's viewport is not moved and nothing is * decoded: rows above the active screen come from the page cache, and the active @@ -1614,6 +1637,9 @@ export class OfficialTerminal implements GhosttyTerminal { dv.setUint8(at.mode + L.mode.value, 1); this.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); + // The configured cursor, which DECSCUSR Ps 0 and RIS restore (ADR 049). + this.setDefaultCursor(config?.cursorStyle ?? 'block', config?.cursorBlink ?? false); + const setColor = (option: number, value: number, what: string) => { this.writeRgb(at.u32, value); this.setOption(option, at.u32, what); diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 17d058b..e75c17f 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -29,7 +29,18 @@ export interface IRenderable { * Optional for buffers that cannot scroll. (webtty, ADR 042) */ setViewportOffset?(linesFromBottom: number): void; - getCursor(): { x: number; y: number; visible: boolean }; + /** + * The cursor. `style` and `blinking`, when given, are the shape and blink the engine + * reports (DECSCUSR over the configured default) and are drawn instead of the + * options. (webtty, ADR 049) + */ + getCursor(): { + x: number; + y: number; + visible: boolean; + style?: 'block' | 'underline' | 'bar'; + blinking?: boolean; + }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; /** Returns true if a full redraw is needed (e.g., screen change) */ @@ -481,6 +492,9 @@ export class CanvasRenderer { // getCursor() calls update() internally to ensure fresh state. // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); + // The engine's shape and blink; a change repaints the cursor row (webtty, ADR 049) + if (cursor.style !== undefined) this.setCursorStyle(cursor.style); + if (cursor.blinking !== undefined) this.setCursorBlink(cursor.blinking); const dims = buffer.getDimensions(); const getLine = frameLines(buffer, dims.cols, dims.rows); this.currentLine = getLine; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index c8fbc40..be9426d 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -65,49 +65,6 @@ export function mouseCursorFor(link: ILink | undefined): 'pointer' | 'default' { return link ? 'pointer' : 'default'; } -/** Cursor shape set by an app through DECSCUSR. */ -export interface CursorShape { - style: 'block' | 'underline' | 'bar'; - blink: boolean; -} - -// DECSCUSR is CSI Ps SP q. A sequence split across writes leaves a prefix at the -// end of a chunk, which is carried into the next scan. -const DECSCUSR = /\x1b\[(\d*) q/g; -const DECSCUSR_PREFIX = /\x1b(?:\[\d{0,3} ?)?$/; - -/** - * Scans output for DECSCUSR and returns the last Ps from 0 to 6 (undefined if - * none), plus the unfinished sequence at the end of the chunk to pass back in as - * `carry` with the next one. (webtty: the renderer draws the cursor from these - * options, not from Ghostty's render-state cursor style, so the style is read from - * the output here; ADR 013, ADR 047.) - */ -export function scanDecscusr( - data: string, - carry: string = '' -): { ps: number | undefined; carry: string } { - const text = carry + data; - let ps: number | undefined; - for (const match of text.matchAll(DECSCUSR)) { - const value = match[1] === '' ? 0 : Number(match[1]); - if (value <= 6) ps = value; - } - return { ps, carry: DECSCUSR_PREFIX.exec(text)?.[0] ?? '' }; -} - -/** - * Applies DECSCUSR Ps to the app's cursor shape: 1-6 set it, 0 clears it so the - * configured `cursorStyle` / `cursorBlink` show again. - */ -export function decscusrShape(ps: number): CursorShape | undefined { - if (ps === 0) return undefined; - return { - style: ps <= 2 ? 'block' : ps <= 4 ? 'underline' : 'bar', - blink: ps % 2 === 1, - }; -} - /** * The mouse encoder's geometry and a client position in it (ADR 049). Ghostty needs a * whole cell size, and webtty's CSS cell is fractional, so the units are device-pixel @@ -278,11 +235,6 @@ export class Terminal implements ITerminalCore { private wheelPace?: (event: WheelEvent) => Pace; private beforeRender?: (now: number) => void; private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); - // DECSCUSR state: the app's cursor shape (undefined = options) and the - // unfinished sequence at the end of the last write - private appCursorShape?: CursorShape; - private decscusrCarry: string = ''; - private readonly decoder = new TextDecoder(); // Scrollbar interaction state private isDraggingScrollbar: boolean = false; @@ -357,7 +309,8 @@ export class Terminal implements ITerminalCore { case 'cursorBlink': case 'cursorStyle': - this.applyCursorShape(); + // The engine's default; the renderer draws what the engine reports (webtty, ADR 049) + this.wasmTerm?.setDefaultCursor(this.options.cursorStyle, this.options.cursorBlink); break; case 'theme': @@ -447,15 +400,10 @@ export class Terminal implements ITerminalCore { /** * Convert terminal options to WASM terminal config. */ - private buildWasmConfig(): GhosttyTerminalConfig | undefined { + private buildWasmConfig(): GhosttyTerminalConfig { const theme = this.options.theme; const scrollbackLines = this.options.scrollbackLines; - // If no theme and default scrollback, use defaults - if (!theme && scrollbackLines === undefined) { - return undefined; - } - // Build palette array from theme colors // Order: black, red, green, yellow, blue, magenta, cyan, white, // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite @@ -487,6 +435,8 @@ export class Terminal implements ITerminalCore { bgColor: bg, cursorColor: cursor, palette: paletteColors, + cursorStyle: this.options.cursorStyle, + cursorBlink: this.options.cursorBlink, }; } @@ -725,17 +675,6 @@ export class Terminal implements ITerminalCore { if (effects.bell) this.bellEmitter.fire(); if (effects.title !== null) this.setTitle(effects.title); - // DECSCUSR: the app's cursor shape (webtty) - const decscusr = scanDecscusr( - typeof data === 'string' ? data : this.decoder.decode(data, { stream: true }), - this.decscusrCarry - ); - this.decscusrCarry = decscusr.carry; - if (decscusr.ps !== undefined) { - this.appCursorShape = decscusrShape(decscusr.ps); - this.applyCursorShape(); - } - // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); @@ -857,22 +796,6 @@ export class Terminal implements ITerminalCore { // Reset title this.currentTitle = ''; - - // Back to the configured cursor shape (webtty) - this.appCursorShape = undefined; - this.decscusrCarry = ''; - this.applyCursorShape(); - } - - /** - * Shows the app's DECSCUSR cursor shape, or the configured one when the app has - * not set one. The renderer repaints the cursor cell when the shape changes. - * (webtty) - */ - private applyCursorShape(): void { - if (!this.renderer) return; - this.renderer.setCursorStyle(this.appCursorShape?.style ?? this.options.cursorStyle); - this.renderer.setCursorBlink(this.appCursorShape?.blink ?? this.options.cursorBlink); } /** diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index f2445d9..93eaf07 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -292,6 +292,12 @@ export interface GhosttyTerminalConfig { cursorColor?: number; /** The 16 ANSI colours; an undefined entry keeps Ghostty's default. */ palette?: (number | undefined)[]; + /** + * The default cursor shape and blink, which DECSCUSR Ps 0 and RIS restore. Default + * block, not blinking. (webtty, ADR 049) + */ + cursorStyle?: 'block' | 'underline' | 'bar'; + cursorBlink?: boolean; } /** diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index c878b86..788db34 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -263,6 +263,50 @@ describe('official engine: cursor and modes', () => { t.free(); }); + test('the configured cursor is the default that Ps 0 and RIS restore (ADR 049)', () => { + const t = term(40, 10, { ...THEME, cursorStyle: 'bar', cursorBlink: true }); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[2 q'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.write('\x1b[0 q'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[4 q\x1bc'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.free(); + }); + + test('a new default shows at once only while the app has not set a shape', () => { + const t = term(); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.setDefaultCursor('underline', true); + expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: true }); + t.write('\x1b[6 q'); + t.setDefaultCursor('block', false); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: false }); + t.write('\x1b[ q'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.free(); + }); + + test('follows Ghostty: mode 12 blinks, the shape is per screen, DECSTR restores', () => { + const t = term(); + t.write('\x1b[6 q\x1b[?12h'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[?12l'); + expect(t.getCursor().blinking).toBe(false); + t.write('\x1b[?1049h\x1b[2 q'); + expect(t.getCursor().style).toBe('block'); + t.write('\x1b[?1049l'); + expect(t.getCursor().style).toBe('bar'); + // A sequence split across writes, and Ps out of range + t.write('\x1b[4'); + t.write(' q\x1b[7 q'); + expect(t.getCursor().style).toBe('underline'); + t.write('\x1b[!p'); + expect(t.getCursor().style).toBe('block'); + t.free(); + }); + test('switches to the alternate screen and back', () => { const t = term(20, 4); t.write('main'); diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 7226a11..c25dd46 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -907,7 +907,13 @@ describe('overscan and copied scrolls (ADR 043)', () => { test(`a translucent cursor drawn again is not blended over itself`, async () => { const { term, provider } = await terminal(); const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; - for (const cursorStyle of ['block', 'bar', 'underline'] as const) { + // The shape is the engine's (ADR 049) + for (const [cursorStyle, ps] of [ + ['block', 2], + ['bar', 6], + ['underline', 4], + ] as const) { + term.write(`\x1b[${ps} q`); const live = renderer(2, { theme, cursorStyle }); live.r.render(term, true, 0, provider, 1); // Idle frames, a scroll away and back, and a frame that repaints the cursor row @@ -930,6 +936,37 @@ describe('overscan and copied scrolls (ADR 043)', () => { term.free(); }); + test(`draws the cursor shape the engine reports, not the configured one (ADR 049)`, async () => { + const { term, provider } = await terminal(); + // The same terminal with the shape left to the renderer's option + const optionShape = new Proxy(term, { + get: (t, k) => { + if (k === 'getCursor') { + return () => { + const { style: _s, blinking: _b, ...cursor } = t.getCursor(); + return cursor; + }; + } + const v = t[k as keyof typeof t]; + return typeof v === 'function' ? v.bind(t) : v; + }, + }); + term.write('\x1b[4 q'); + const live = renderer(2, { cursorStyle: 'block' }); + live.r.render(term, true, 0, provider, 1); + const underline = renderer(2, { cursorStyle: 'underline' }); + underline.r.render(optionShape, true, 0, provider, 1); + expect(live.shown()).toEqual(underline.shown()); + // A later DECSCUSR repaints the cursor cell on the next frame + term.write('\x1b[6 q'); + live.r.render(term, false, 0, provider, 1); + const bar = renderer(2, { cursorStyle: 'bar' }); + bar.r.render(optionShape, true, 0, provider, 1); + expect(live.shown()).toEqual(bar.shown()); + expect(live.shown()).not.toEqual(underline.shown()); + term.free(); + }); + test(`the block cursor redraws the grapheme of its own screen row`, async () => { const ghostty = await Ghostty.load(WASM); const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index c17253f..51319bc 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -6,12 +6,10 @@ import { LinkDetector } from './ghostty-web/link-detector'; import { OSC8LinkProvider } from './ghostty-web/providers/osc8-link-provider'; import { cursorCellOffset, - decscusrShape, deviceCellPixels, mouseCursorFor, mouseReportText, mouseSurface, - scanDecscusr, Terminal, } from './ghostty-web/terminal'; import type { GhosttyTerminalConfig, ILink } from './ghostty-web/types'; @@ -37,87 +35,6 @@ describe('mouseCursorFor', () => { }); }); -describe('decscusrShape', () => { - test('Ps 0 clears the app shape so the configured one shows', () => { - expect(decscusrShape(0)).toBeUndefined(); - }); - - test('Ps 1 — blinking block', () => { - expect(decscusrShape(1)).toEqual({ style: 'block', blink: true }); - }); - - test('Ps 2 — steady block', () => { - expect(decscusrShape(2)).toEqual({ style: 'block', blink: false }); - }); - - test('Ps 3 — blinking underline', () => { - expect(decscusrShape(3)).toEqual({ style: 'underline', blink: true }); - }); - - test('Ps 4 — steady underline', () => { - expect(decscusrShape(4)).toEqual({ style: 'underline', blink: false }); - }); - - test('Ps 5 — blinking bar', () => { - expect(decscusrShape(5)).toEqual({ style: 'bar', blink: true }); - }); - - test('Ps 6 — steady bar', () => { - expect(decscusrShape(6)).toEqual({ style: 'bar', blink: false }); - }); -}); - -describe('scanDecscusr', () => { - test('reads Ps', () => { - expect(scanDecscusr('\x1b[6 q')).toEqual({ ps: 6, carry: '' }); - }); - - test('empty Ps is 0', () => { - expect(scanDecscusr('\x1b[ q').ps).toBe(0); - }); - - test('no sequence yields no Ps', () => { - expect(scanDecscusr('hello world')).toEqual({ ps: undefined, carry: '' }); - }); - - test('out-of-range Ps is ignored', () => { - expect(scanDecscusr('\x1b[7 q').ps).toBeUndefined(); - expect(scanDecscusr('\x1b[5 q\x1b[7 q').ps).toBe(5); - }); - - test('last sequence in a chunk wins', () => { - expect(scanDecscusr('\x1b[2 q some output \x1b[5 q').ps).toBe(5); - }); - - test('finds a sequence embedded in output', () => { - expect(scanDecscusr('text before\x1b[6 qtext after').ps).toBe(6); - }); - - test('ignores other CSI sequences ending in q', () => { - expect(scanDecscusr('\x1b[>q\x1b[1q\x1b[0"q').ps).toBeUndefined(); - }); - - test('carries a sequence split across chunks', () => { - for (const [head, tail] of [ - ['ab\x1b', '[6 q'], - ['ab\x1b[', '6 q'], - ['ab\x1b[6', ' q'], - ['ab\x1b[6 ', 'q'], - ]) { - const first = scanDecscusr(head); - expect(first.ps).toBeUndefined(); - expect(first.carry).toBe(head.slice(2)); - expect(scanDecscusr(tail, first.carry)).toEqual({ ps: 6, carry: '' }); - } - }); - - test('carries nothing after a finished sequence or plain text', () => { - expect(scanDecscusr('\x1b[6 q').carry).toBe(''); - expect(scanDecscusr('\x1b[6m').carry).toBe(''); - expect(scanDecscusr('\x1b[6 x').carry).toBe(''); - }); -}); - describe('cursorCellOffset', () => { const grid = { cols: 80, rows: 24 }; const cell = { width: 9, height: 18 }; @@ -303,6 +220,34 @@ describe('Terminal scrollback options', () => { }); }); +describe('Terminal cursor style', () => { + // The configured shape is the engine's default, which DECSCUSR Ps 0 and RIS restore; + // the output is not scanned (ADR 049). + test('passes cursorStyle and cursorBlink to the engine as its default', () => { + const term = new Terminal({ ghostty: {} as never, cursorStyle: 'bar', cursorBlink: true }); + const config = ( + term as unknown as { buildWasmConfig(): GhosttyTerminalConfig } + ).buildWasmConfig(); + expect(config).toMatchObject({ cursorStyle: 'bar', cursorBlink: true }); + }); + + test('a cursor option change sets the engine default', () => { + const term = new Terminal({ ghostty: {} as never }); + const defaults: unknown[] = []; + const internals = term as unknown as { isOpen: boolean; wasmTerm: object }; + internals.wasmTerm = { + setDefaultCursor: (style: string, blink: boolean) => defaults.push([style, blink]), + }; + internals.isOpen = true; + term.options.cursorStyle = 'underline'; + term.options.cursorBlink = true; + expect(defaults).toEqual([ + ['underline', false], + ['underline', true], + ]); + }); +}); + describe('Terminal bell and title', () => { // Bell and title come from the engine's readEffects, not from scanning the output // (ADR 047). From 277e901ce1efd033cc57e083177a405be7166465 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:30:55 -0400 Subject: [PATCH 068/295] feat(client): send pastes through ghostty_terminal_paste Pasted text goes to the terminal's new paste(), which hands it to ghostty_terminal_paste as a text/plain clipboard paste and sends the bytes Ghostty writes through onData (ADR 049): bracketed whenever mode 2004 is on, single lines too, unsafe control bytes replaced with spaces, and newlines sent as CR without 2004. Text that could run commands is still sent, never refused (ADR 037). The paste UX is unchanged: Cmd+V and the context menu through the paste event, Ctrl+V through the clipboard, and Ctrl+V for an empty text/plain. --- .../049.client.official-mouse-cursor-paste.md | 2 +- docs/specs/client.md | 4 +- src/client/index.ts | 16 +-- src/client/paste.test.ts | 26 ++--- src/client/paste.ts | 13 +-- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 101 ++++++++++++++++++ src/client/vendor/ghostty-web/terminal.ts | 13 +++ src/client/vendor/official-terminal.test.ts | 48 +++++++++ src/client/vendor/terminal.test.ts | 32 ++++++ 10 files changed, 220 insertions(+), 36 deletions(-) diff --git a/docs/adrs/049.client.official-mouse-cursor-paste.md b/docs/adrs/049.client.official-mouse-cursor-paste.md index 549d1f1..7d092b7 100644 --- a/docs/adrs/049.client.official-mouse-cursor-paste.md +++ b/docs/adrs/049.client.official-mouse-cursor-paste.md @@ -74,7 +74,7 @@ Paste: - Control bytes in pasted text become spaces in both modes. A pasted ESC, ^C or ^U can no longer act as a key. - With mode 2004 off, newlines are sent as `\r`, and `\r\n` as `\r\r`. -Overall: about 200 lines of encoders and the regex scan go. Every mouse, cursor-style and paste path is now Ghostty's. +Overall: every mouse, cursor-style and paste path is now Ghostty's. The TS encoders, `encodePaste` and the regex scan go, about 280 lines, and the binding gains about 330 lines of wasm calls. ## Considered Options diff --git a/docs/specs/client.md b/docs/specs/client.md index 5e0799c..3339188 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -182,7 +182,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default) and mode 12, and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | -| Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | +| Multiline text paste | Text is encoded by Ghostty's `ghostty_terminal_paste`: bracketed whenever mode 2004 is enabled (single lines too), control bytes replaced with spaces, and without 2004 sent with newlines as CR (they may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | IME / dictation at cursor | The terminal keeps focus on its input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, is followed by the renderer, which repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | | Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (the renderer's overscan canvas moved by the offset, ADR 043; the one-row strip is gone); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | @@ -191,7 +191,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Official libghostty-vt engine, opt-in | `?engine=official` runs the terminal on Ghostty's official, unpatched libghostty-vt wasm (pinned, sha256- and signature-checked offline), with current Ghostty parsing and query replies; the Coder engine stays the default (superseded by the row below) | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md) | ✅ | | Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders moved to the official APIs in ADR 049; theme colours from render state remain a follow-up | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | -| Mouse, cursor style and paste from libghostty-vt | Mouse reports (clicks, motion, drags past the edge, wheel ticks) are encoded by Ghostty's mouse encoder in every tracking mode and format, with Alt and buttons 8/9, at a device-pixel geometry that includes the padding; the cursor shape and blink come from Ghostty's state, with the config as its default; pastes go through `ghostty_terminal_paste` (bracketed whenever mode 2004 is on, control bytes stripped). X10-format reports past column 95 are dropped: they cannot travel as text frames | [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ⬜ | +| Mouse, cursor style and paste from libghostty-vt | Mouse reports (clicks, motion, drags past the edge, wheel ticks) are encoded by Ghostty's mouse encoder in every tracking mode and format, with Alt and buttons 8/9, at a device-pixel geometry that includes the padding; the cursor shape and blink come from Ghostty's state, with the config as its default; pastes go through `ghostty_terminal_paste` (bracketed whenever mode 2004 is on, control bytes stripped). X10-format reports past column 95 are dropped: they cannot travel as text frames | [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) |✅ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index 89ccab7..0604c24 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,7 +3,7 @@ import { Composer } from './compose'; import { EdgeExtender } from './edge'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; -import { encodePaste } from './paste'; +import { nonTextPaste } from './paste'; import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; import { FitAddon, Ghostty, Terminal } from './vendor/ghostty-web'; @@ -373,13 +373,15 @@ function sendCtrlV(): void { if (ws && ws.readyState === WebSocket.OPEN) ws.send('\x16'); } -// Bracket multiline text only when the terminal reports mode 2004. Otherwise -// send it raw: newlines may act as Enter, but Ctrl+V is not a text fallback -// (remote TUIs can interpret it as an image-paste shortcut). See ADR 037. +// Text goes through the terminal, which encodes it as Ghostty does: bracketed +// while mode 2004 is on, control bytes stripped, and without 2004 newlines sent +// as CR (they may act as Enter). Ctrl+V is not a text fallback (remote TUIs can +// interpret it as an image-paste shortcut). See ADR 037 and ADR 049. function sendPaste(text: string): void { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(encodePaste(text, term.getMode(2004))); - } + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const ctrlV = nonTextPaste(text); + if (ctrlV) ws.send(ctrlV); + else term.paste(text); } // Ctrl+V never reaches us as a 'paste' event on macOS (see above), so read diff --git a/src/client/paste.test.ts b/src/client/paste.test.ts index a2df2d5..3382930 100644 --- a/src/client/paste.test.ts +++ b/src/client/paste.test.ts @@ -1,24 +1,14 @@ import { describe, expect, test } from 'bun:test'; -import { encodePaste } from './paste'; +import { nonTextPaste } from './paste'; -describe('encodePaste', () => { - test('sends single-line text literally in either mode', () => { - expect(encodePaste('hello', false)).toBe('hello'); - expect(encodePaste('hello', true)).toBe('hello'); - }); - - test('brackets multiline text only when mode 2004 is on', () => { - expect(encodePaste('one\ntwo', true)).toBe('\x1b[200~one\ntwo\x1b[201~'); - expect(encodePaste('one\r\ntwo', true)).toBe('\x1b[200~one\r\ntwo\x1b[201~'); - }); - - test('sends multiline text rather than Ctrl+V or bracket delimiters when mode 2004 is off', () => { - expect(encodePaste('one\ntwo', false)).toBe('one\ntwo'); - expect(encodePaste('one\r\ntwo', false)).toBe('one\r\ntwo'); +describe('nonTextPaste', () => { + test('forwards Ctrl+V for an image-only clipboard (empty text/plain)', () => { + expect(nonTextPaste('')).toBe('\x16'); }); - test('forwards Ctrl+V for an image-only clipboard (empty text/plain)', () => { - expect(encodePaste('', false)).toBe('\x16'); - expect(encodePaste('', true)).toBe('\x16'); + test('leaves any text to the terminal, never Ctrl+V', () => { + expect(nonTextPaste('hello')).toBeNull(); + expect(nonTextPaste('one\ntwo')).toBeNull(); + expect(nonTextPaste(' ')).toBeNull(); }); }); diff --git a/src/client/paste.ts b/src/client/paste.ts index 4928c84..6fdbf73 100644 --- a/src/client/paste.ts +++ b/src/client/paste.ts @@ -1,10 +1,7 @@ // An empty text/plain payload is a non-text paste (e.g. an image), whose -// receiving TUI may read the native clipboard on Ctrl+V. Never substitute -// Ctrl+V for nonempty text: in remote apps it can mean something else entirely. -export function encodePaste(text: string, bracketedPaste: boolean): string { - if (!text) return '\x16'; - if (bracketedPaste && /[\r\n]/.test(text)) return `\x1b[200~${text}\x1b[201~`; - // Without mode 2004, raw text is the only interoperable fallback. Its - // newlines may be interpreted as Enter by the receiving app. - return text; +// receiving TUI may read the native clipboard on Ctrl+V: the paste is Ctrl+V. +// Never substitute Ctrl+V for nonempty text: in remote apps it can mean +// something else entirely. Text is encoded by the terminal (term.paste, ADR 049). +export function nonTextPaste(text: string): string | null { + return text ? null : '\x16'; } diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index a3b9617..c973c32 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -36,4 +36,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `index.ts`: engine API nothing in webtty reaches is removed: `getColors` (and the render-state cursor colour only it returned), `hasBracketedPaste` and `hasFocusEvents` (`getMode(2004)` and `getMode(1004)` remain), and the key encoder's `setKittyFlags`; in `types.ts` the SGR types (`SgrAttributeTag`, `SgrAttribute`, `RGBColor`), `KittyKeyFlags`, `KeyEvent`'s `consumedMods`, `composing` and `unshiftedCodepoint` (the binding never read them), `RenderStateColors`, `RGB`, `Cursor`, the Coder-era `TerminalConfig`, `TerminalMode`, and a second, unused `EventEmitter` with its `IEvent` and `IDisposable`. The `Key`, `KeyAction`, `KeyEncoderOption` and `Mods` enums are kept whole: the binding translates them to the official enums by name at load. - `addons/fit.ts`, `interfaces.ts`, `providers/osc8-link-provider.ts`, `link-detector.ts`, `renderer.ts`, `types.ts`: unreachable code removed. `FitAddon.fit` and `observeResize` with their resize state (webtty resizes from `proposeDimensions`, ADR 033), so `ITerminalCore` keeps only `element`; `OSC8LinkProvider.findLinkRange` (private, never called); `LinkDetector.invalidateRows`; the renderer's `setTheme` and the ANSI `palette` array nothing read; `ILink.dispose`. - `ghostty.ts`, `input-handler.ts`, `terminal.ts`, `wheel.ts`: mouse reports are encoded by Ghostty's mouse encoder (ADR 049). The binding adds `setMouseGeometry` and `encodeMouse` (`ghostty_mouse_encoder_*`, `ghostty_mouse_event_*`, with Ghostty's per-cell motion deduplication, the tracking mode and format set from the terminal when a mouse mode changes). Upstream's `encodeMouseSGR`, `encodeMouseX10` and `pixelToCell` go, with webtty's `mouseMotionButton` and `isNewMouseCell` (Ghostty reports hover as 35 and motion once per cell), and `MouseTrackingConfig` becomes `hasMouseTracking` and `encode`. Upstream sent Meta as Alt and dropped Alt, sent the back and forward buttons as codes 3 and 4, knew only the SGR and X10 formats, and clamped the cell from below only; `mouseMods` and `MOUSE_BUTTONS` follow Ghostty (Shift, Alt, Ctrl; buttons 8 and 9). Move and release are heard on the document, so a drag past the terminal's edge keeps reporting and its release is sent; back and forward are `preventDefault`ed while reported. `terminal.ts` maps client positions to device-pixel cells with the container as the screen (`mouseSurface`), drops reports that are not valid UTF-8 (`mouseReportText`), and encodes each wheel tick as a press of button 4 or 5; `wheelSgr` is removed. +- `ghostty.ts`, `terminal.ts`: pastes are encoded by `ghostty_terminal_paste` (ADR 049). The binding's new `paste` passes the text as a `text/plain` clipboard paste with a mime reader callback and `allow_unsafe` set, and the bytes come back through `WRITE_PTY`. `Terminal.paste`, removed above as unused, is back with Ghostty's encoding: bracketed whenever mode 2004 is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004; upstream bracketed the text unchanged and sent it raw otherwise. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index b75f358..c0f6a96 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -135,6 +135,14 @@ export interface GhosttyTerminal { */ encodeMouse(input: MouseInput): Uint8Array; + // Paste (ADR 049) + /** + * Pastes text as Ghostty does (`ghostty_terminal_paste`): bracketed while mode 2004 + * is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004. + * The bytes are queued as a reply, read with `readResponse`. + */ + paste(text: string): void; + // Replies and effects hasResponse(): boolean; readResponse(): string | null; @@ -195,6 +203,7 @@ interface OfficialWasmExports { outWritten: number ): number; ghostty_terminal_vt_write(term: number, ptr: number, len: number): void; + ghostty_terminal_paste(term: number, paste: number, outWritten: number): number; /** The GhosttyTerminalScrollViewport struct is passed by value, i.e. by pointer on wasm32. */ ghostty_terminal_scroll_viewport(term: number, behavior: number): void; /** The GhosttyPoint struct is passed by value, i.e. by pointer on wasm32. */ @@ -639,6 +648,21 @@ function resolveOfficialLayout(layout: VtLayout) { nine: layout.value('GhosttyMouseButton', 'NINE'), } satisfies Record, }, + paste: { + size: layout.size('GhosttyPaste'), + sizeField: layout.offset('GhosttyPaste', 'size'), + location: layout.offset('GhosttyPaste', 'location'), + source: layout.offset('GhosttyPaste', 'source'), + mimes: layout.offset('GhosttyPaste', 'mimes'), + mimesLen: layout.offset('GhosttyPaste', 'mimes_len'), + reader: layout.offset('GhosttyPaste', 'reader'), + allowUnsafe: layout.offset('GhosttyPaste', 'allow_unsafe'), + readerRead: layout.offset('GhosttyMimeReader', 'read'), + writerWrite: layout.offset('GhosttyWriter', 'write'), + writerUserdata: layout.offset('GhosttyWriter', 'userdata'), + STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), + CLIPBOARD: layout.value('GhosttyPasteSource', 'CLIPBOARD'), + }, result: { SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), @@ -1024,6 +1048,11 @@ export class OfficialTerminal implements GhosttyTerminal { private mouseModes = -1; private mouseGeometry = ''; + // Paste: the mime reader callback, installed on the first paste, and the text it + // writes during a `paste` call (ADR 049). + private pasteReader = 0; + private pasteText = { ptr: 0, len: 0 }; + // Effects. private readonly responses: Uint8Array[] = []; private bell = false; @@ -1440,6 +1469,78 @@ export class OfficialTerminal implements GhosttyTerminal { return this.mouseEncoder; } + // ========================================================================== + // Paste (ADR 049) + // ========================================================================== + + paste(text: string): void { + const ex = this.ex; + const P = this.L.paste; + const S = this.L.string; + const bytes = new TextEncoder().encode(text); + const mime = new TextEncoder().encode('text/plain'); + // One allocation: the paste struct, the one-entry mime array, the mime and the text. + const mimesAt = Math.ceil(P.size / 8) * 8; + const mimeAt = mimesAt + S.size; + const textAt = mimeAt + mime.length; + const len = textAt + bytes.length; + const base = ex.ghostty_wasm_alloc(len); + if (!base) throw new Error('libghostty-vt: out of memory'); + try { + const u8 = this.u8(); + u8.fill(0, base, base + P.size); + u8.set(mime, base + mimeAt); + u8.set(bytes, base + textAt); + const dv = this.dv(); + dv.setUint32(base + mimesAt + S.ptr, base + mimeAt, true); + dv.setUint32(base + mimesAt + S.len, mime.length, true); + dv.setUint32(base + P.sizeField, P.size, true); + dv.setInt32(base + P.location, P.STANDARD, true); + dv.setInt32(base + P.source, P.CLIPBOARD, true); + dv.setUint32(base + P.mimes, base + mimesAt, true); + dv.setUint32(base + P.mimesLen, 1, true); + dv.setUint32(base + P.reader + P.readerRead, this.ensurePasteReader(), true); + // Text that could run commands (a newline without 2004) is sent, never refused: + // ADR 037, and webtty has no confirmation dialog. + dv.setUint8(base + P.allowUnsafe, 1); + this.pasteText = { ptr: base + textAt, len: bytes.length }; + const rc = ex.ghostty_terminal_paste(this.term, base, 0); + if (rc !== 0) throw new Error(`libghostty-vt terminal_paste failed: ${rc}`); + } finally { + this.pasteText = { ptr: 0, len: 0 }; + ex.ghostty_wasm_free(base, len); + } + } + + /** + * The GhosttyMimeReader callback: `(userdata, mime, writer)`, the GhosttyString and + * GhosttyWriter structs passed by pointer on wasm32. It writes the text of the paste in + * progress through the writer, a function in the wasm's table, and returns its result. + */ + private ensurePasteReader(): number { + if (this.pasteReader) return this.pasteReader; + const P = this.L.paste; + const table = this.ex.__indirect_function_table; + const installed = this.vt.fnTable.install( + ((_userdata: number, _mime: number, writer: number) => { + const { ptr, len } = this.pasteText; + if (len === 0) return 1; + const dv = this.dv(); + const write = table.get(dv.getUint32(writer + P.writerWrite, true)) as ( + userdata: number, + data: number, + len: number + ) => number; + return write(dv.getUint32(writer + P.writerUserdata, true), ptr, len) ? 1 : 0; + }) as (...args: number[]) => number, + [I32, I32, I32], + [I32] + ); + this.callbacks.push(installed); + this.pasteReader = installed.index; + return this.pasteReader; + } + // ========================================================================== // Replies and effects // ========================================================================== diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index be9426d..70c6623 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -641,6 +641,19 @@ export class Terminal implements ITerminalCore { } } + /** + * Pastes text as Ghostty does (`ghostty_terminal_paste`): bracketed while the app has + * mode 2004 on, unsafe control bytes replaced with spaces, and newlines sent as CR + * without 2004. The bytes go out through onData. (webtty: upstream bracketed the text + * as it was, and only with 2004 on; ADR 049) + */ + paste(text: string): void { + this.assertOpen(); + if (this.options.disableStdin) return; + this.wasmTerm!.paste(text); + this.processTerminalResponses(); + } + /** * Write data to terminal */ diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 788db34..eab54ff 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -897,3 +897,51 @@ describe('official engine: mouse encoder (ADR 049)', () => { t.free(); }); }); + +describe('official engine: paste (ADR 049)', () => { + const paste = (t: GhosttyTerminal, text: string) => { + t.paste(text); + return t.readResponse(); + }; + + test('brackets every paste while mode 2004 is on, single lines too', () => { + const t = term(); + t.write('\x1b[?2004h'); + expect(paste(t, 'hello')).toBe('\x1b[200~hello\x1b[201~'); + expect(paste(t, 'one\ntwo\r\n')).toBe('\x1b[200~one\ntwo\r\n\x1b[201~'); + t.free(); + }); + + test('without 2004, sends the text with newlines as CR', () => { + const t = term(); + expect(paste(t, 'hello')).toBe('hello'); + expect(paste(t, 'one\ntwo')).toBe('one\rtwo'); + expect(paste(t, 'one\r\ntwo\n')).toBe('one\r\rtwo\r'); + t.free(); + }); + + test('replaces unsafe control bytes with spaces in both modes, keeping tabs', () => { + const t = term(); + const unsafe = 'a\x00b\x08c\x1bd\x7fe\x03f\x15g\x1ah\x16i\x17j\tk'; + expect(paste(t, unsafe)).toBe('a b c d e f g h i j\tk'); + t.write('\x1b[?2004h'); + // An end marker inside the text cannot end the paste early + expect(paste(t, 'x\x1b[201~rm -rf ~\n')).toBe('\x1b[200~x [201~rm -rf ~\n\x1b[201~'); + t.free(); + }); + + test('passes UTF-8 through, and an empty paste writes nothing', () => { + const t = term(); + expect(paste(t, 'héllo 中 🙂')).toBe('héllo 中 🙂'); + expect(paste(t, '')).toBeNull(); + t.free(); + }); + + test('a large paste arrives whole and in order', () => { + const t = term(); + t.write('\x1b[?2004h'); + const text = Array.from({ length: 5000 }, (_, i) => `line ${i}`).join('\n'); + expect(paste(t, text)).toBe(`\x1b[200~${text}\x1b[201~`); + t.free(); + }); +}); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 51319bc..c8647db 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -248,6 +248,38 @@ describe('Terminal cursor style', () => { }); }); +describe('Terminal paste', () => { + // Pastes are encoded by ghostty_terminal_paste and leave through onData (ADR 049). + async function pasteTerm(options: object = {}) { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(40, 10); + const term = new Terminal({ ghostty: {} as never, ...options }); + const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; + internals.wasmTerm = wasmTerm; + internals.isOpen = true; + const sent: string[] = []; + term.onData((data) => sent.push(data)); + return { term, wasmTerm, sent }; + } + + test('sends the text as Ghostty encodes it for the mode', async () => { + const { term, wasmTerm, sent } = await pasteTerm(); + term.paste('one\ntwo\x1b'); + wasmTerm.write('\x1b[?2004h'); + term.paste('three'); + expect(sent).toEqual(['one\rtwo ', '\x1b[200~three\x1b[201~']); + wasmTerm.free(); + }); + + test('sends nothing with stdin disabled', async () => { + const { term, wasmTerm, sent } = await pasteTerm({ disableStdin: true }); + term.paste('hello'); + expect(sent).toEqual([]); + expect(wasmTerm.hasResponse()).toBe(false); + wasmTerm.free(); + }); +}); + describe('Terminal bell and title', () => { // Bell and title come from the engine's readEffects, not from scanning the output // (ADR 047). From f88a45ac17d37d2192648156229c51c20b2fc5bb Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:45:39 -0400 Subject: [PATCH 069/295] fix(server): end a replay's extra queue allowance once it drains A replay's queued pieces were allowed on top of maxPending for the rest of the connection, so live output could later queue up to the limit plus the replay before the client was reset. The allowance now ends when the queue empties (ADR 048). --- src/server/flow.test.ts | 18 ++++++++++++++++++ src/server/flow.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/server/flow.test.ts b/src/server/flow.test.ts index 71fc0cd..304b6df 100644 --- a/src/server/flow.test.ts +++ b/src/server/flow.test.ts @@ -240,6 +240,24 @@ describe('OutputPacer', () => { expect(ws.closed?.code).toBe(FELL_BEHIND); }); + test("a replay's allowance ends once its queue drains", () => { + const { pacer } = setup(null); + const ws = new FakeSocket(); + pacer.add(ws, true); + pacer.send(ws, 'r'.repeat(2500)); + let acked = 0; + while (ws.received.length > acked) { + acked = ws.received.length; + pacer.ack(ws, acked); + } + // Block the client again, then queue just over maxPending (1000) of live output. + pacer.broadcast('x'.repeat(100)); + for (let i = 0; i < 100; i++) pacer.broadcast('x'.repeat(10)); + expect(ws.closed).toBeNull(); + pacer.broadcast('x'.repeat(10)); + expect(ws.closed?.code).toBe(FELL_BEHIND); + }); + test('a legacy client gets a replay whole', () => { const { pacer } = setup(null); const ws = new FakeSocket(); diff --git a/src/server/flow.ts b/src/server/flow.ts index e432815..669f2ec 100644 --- a/src/server/flow.ts +++ b/src/server/flow.ts @@ -163,8 +163,9 @@ export class OutputPacer { * anything queued for it. An acknowledging client gets it in pieces of * {@link FlowLimits.chunk}, so a large replay is paced like live output instead of * filling the send buffer at once. What it queues is allowed on top of - * {@link FlowLimits.maxPending}: the replay is already held as the scrollback, and a - * scrollback over the limit would otherwise close every client that connects. A legacy + * {@link FlowLimits.maxPending} until the queue drains: the replay is already held as the + * scrollback, and a scrollback over the limit would otherwise close every client that + * connects. A legacy * client gets it whole as before: Bun's shim accepts one large message, but drops and * reorders pieces sent past its 16 MB backlog. */ @@ -241,6 +242,8 @@ export class OutputPacer { this.transmit(ws, flow, message); } flow.pending.splice(0, i); + // A drained queue holds no replay any more, so live output gets the plain limit. + if (flow.pending.length === 0) flow.maxPending = this.limits.maxPending; } private fellBehind(ws: FlowSocket): void { From 5f51756c456ef14a12952019739f4a291d13771b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Tue, 13 Oct 2026 22:45:39 -0400 Subject: [PATCH 070/295] refactor(client): drop onSelectionChange and fix comments left by the cleanup - Terminal.onSelectionChange had no subscriber after the copy moved into the selection manager; removed. - dispose() removed the wheel listener without capture, so it never detached; it now matches how it is added. - Comments and ADR 047's follow-up rows still described the code ADR 049 replaced. --- docs/adrs/047.client.official-engine-only.md | 6 +++--- src/client/compose.ts | 4 ++-- src/client/index.ts | 4 ++-- src/client/vendor/ghostty-web/PROVENANCE.md | 4 ++-- src/client/vendor/ghostty-web/terminal.ts | 10 +--------- 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/docs/adrs/047.client.official-engine-only.md b/docs/adrs/047.client.official-engine-only.md index 6f59c5e..ad0e67b 100644 --- a/docs/adrs/047.client.official-engine-only.md +++ b/docs/adrs/047.client.official-engine-only.md @@ -51,9 +51,9 @@ Make the official libghostty-vt wasm the only engine now, and delete the Coder e | Coder wasm and sidecar | Now | Above. | | Size reports through `OPT_SIZE` | Now | Already what runs on the official engine; deleting the `kitty.ts` replies removes dead code. | | Official key encoder | Done | `OfficialKeyEncoder` already runs on `ghostty_key_encoder_*`. The name translation from ghostty-web's `Key` enum stays (ADR 041: layouts and enums come from `ghostty_type_json`). | -| Cursor style from the render state | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | Possible with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, but the renderer would take shape and blink from `getCursor()` each frame. Hollow block needs mapping, and Ghostty's blink also follows mode 12, which changes behaviour for apps. The DECSCUSR scan in `terminal.ts` works and stays until an ADR 013 update with wasm tests and a vim/nvim check. | -| Official mouse encoder | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | The TS encoders carry webtty's fixes (hover Cb=35, per-cell motion, release outside) and ADR 045's wheel pacing. Replacing them changes behaviour that ADR 017 and 045 settled, and needs its own ADR and a vim/htop/tmux/less check in three browsers. | -| `terminal_paste` / paste encoding | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | Ghostty brackets every paste while mode 2004 is on and filters unsafe bytes. ADR 014 (Ctrl+V for images) and ADR 037 (bracket multi-line only) decided otherwise. A change is a product decision for its own ADR. | +| Cursor style from the render state | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | Possible with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, but the renderer would take shape and blink from `getCursor()` each frame. Hollow block needs mapping, and Ghostty's blink also follows mode 12, which changes behaviour for apps. At the time of this ADR the DECSCUSR scan in `terminal.ts` stayed until an ADR 013 update with wasm tests and a vim/nvim check; ADR 049 made that change. | +| Official mouse encoder | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | The TS encoders carry webtty's fixes (hover Cb=35, per-cell motion, release outside) and ADR 045's wheel pacing. Replacing them changes behaviour that ADR 017 and 045 settled, so it needed its own ADR, which ADR 049 is. | +| `terminal_paste` / paste encoding | Done ([ADR 049](049.client.official-mouse-cursor-paste.md)) | Ghostty brackets every paste while mode 2004 is on and filters unsafe bytes. ADR 014 (Ctrl+V for images) and ADR 037 (bracket multi-line only) decided otherwise. A change was a product decision for its own ADR, which ADR 049 is. | | Theme colours from the render state | Follow-up | The renderer, edge strips, padding and IME composer paint from the static theme, so OSC 10/11/12 and DECSCNM change cell colours but not the canvas clear or cursor colour. Fixing that touches several components and ADR 036. | Kitty keyboard flags (ADR 042, Consequences) remain future work as before. diff --git a/src/client/compose.ts b/src/client/compose.ts index e3285c6..1d605a8 100644 --- a/src/client/compose.ts +++ b/src/client/compose.ts @@ -41,8 +41,8 @@ export class Composer { container.addEventListener('focusout', this.onCompositionEnd); } - // ghostty-web's onRender never fires and onCursorMove only reports row changes, so - // the overlay follows the cursor every frame while composing. setPx only writes + // The terminal has no per-frame cursor event, so the overlay follows the cursor + // every frame while composing. setPx only writes // styles that changed. private readonly tick = (): void => { if (!this.composing) { diff --git a/src/client/index.ts b/src/client/index.ts index 0604c24..48db2ce 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -266,8 +266,8 @@ connect(); // trackpad and a TrackPoint apart and picks how each event moves: // - on the normal screen, unless the app tracks the mouse, SmoothScroll glides the // scrollback pixel by pixel, advanced just before each render; -// - otherwise the terminal sends the app SGR scroll buttons, or arrow keys on the -// alternate screen, paced the same way. +// - otherwise the terminal sends the app mouse wheel reports in the app's mouse format +// (ADR 049), or arrow keys on the alternate screen, paced the same way. term.attachCustomWheelEventHandler( (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), ); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index c973c32..7e3f1d2 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -17,7 +17,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`, `renderer.ts`, `ghostty.ts`, `types.ts`: the cursor shape and blink are Ghostty's (ADR 049). The configured `cursorStyle` / `cursorBlink` are passed in `GhosttyTerminalConfig` and set as `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, which DECSCUSR Ps 0 and RIS restore; an option change calls the binding's new `setDefaultCursor`. `render()` draws the `style` and `blinking` that `getCursor()` reports (upstream drew only the options, and Ghostty's render-state style was hard-coded to block), and a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. webtty's earlier output scan for DECSCUSR (`scanDecscusr`, `decscusrShape`) is removed. -- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends SGR scroll buttons 64/65 at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, now in `wheel.ts`). +- `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends scroll buttons 64/65 (since ADR 049 encoded by Ghostty's mouse encoder in the app's mouse format) at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. - `ghostty.ts`, `types.ts`, `index.ts`: the terminal runs on Ghostty's official libghostty-vt wasm (ADR 042, ADR 047). Upstream's `GhosttyTerminal` class, which ran the Coder wasm, is replaced: `GhosttyTerminal` and `KeyEncoder` are interfaces, implemented by `OfficialTerminal` and `OfficialKeyEncoder`, and `Ghostty.load(path)` loads the official wasm (by default from `../ghostty-vt/ghostty-vt-small.wasm`). The interface adds `setCellPixelSize`, the pixel arguments of `resize`, `setViewportOffset` and `readEffects`, and drops upstream's `isDirty`, `getGrapheme` and `getScrollbackGrapheme`, which nothing calls: graphemes are read as strings (`getGraphemeString`, `getScrollbackGraphemeString`). `GhosttyTerminalConfig` drops `scrollbackLimit`, and its colours are undefined when unset instead of 0, so `#000000` is black. The binding drops the kitty keyboard query reply (`CSI ? flags u`), since keys are never encoded with the kitty flags. Upstream's Coder-only types (`GhosttyWasmExports`, the struct sizes, `TerminalHandle`) are removed. @@ -28,7 +28,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. - `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). - `input-handler.ts`: the paste path is removed (the `paste` listeners, `handlePaste`, the `insertFromPaste` case of `handleBeforeInput`, `emitPasteData` and the paste de-duplication). It never ran: webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no `insertFromPaste`. -- `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. +- `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. `Terminal.onSelectionChange`, which only that copy used, is removed. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`, `interfaces.ts`, `index.ts`, `renderer.ts`: xterm.js API nothing in webtty reaches is removed. `terminal.ts`: `unicode`, `onKey`, `onRender` (never fired), `onCursorMove` and its per-frame cursor check, `writeln`, `paste`, `input`, `clear`, `blur`, `hasSelection`, `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `attachCustomKeyEventHandler`, `registerLinkProvider`, `scrollPages`, `scrollToTop`, `hasBracketedPaste` and `hasFocusEvents`. `input-handler.ts`: the `onBell`, `onKey` and `customKeyEventHandler` constructor arguments, `setCustomKeyEventHandler` and `isActive`. `selection-manager.ts`: `selectAll`, `select`, `selectLines`, `getSelectionPosition`, `deselect`, `focus`, `getSelectionCoords`, `normalizeSelection` and the dirty-row set nothing read (`getDirtySelectionRows`, `clearDirtySelectionRows`, `markCurrentSelectionDirty`); the drag auto-scroll calls the terminal's `scrollLines` typed instead of through `any`. `interfaces.ts`: `IKeyEvent`, `IUnicodeVersionProvider` and `IBufferRange`. `index.ts`: `init()` and its module-level instance, so `ITerminalOptions.ghostty` is required. diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 70c6623..629ad6a 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -203,14 +203,12 @@ export class Terminal implements ITerminalCore { private dataEmitter = new EventEmitter(); private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); private bellEmitter = new EventEmitter(); - private selectionChangeEmitter = new EventEmitter(); private titleChangeEmitter = new EventEmitter(); private scrollEmitter = new EventEmitter(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent = this.dataEmitter.event; public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; public readonly onBell: IEvent = this.bellEmitter.event; - public readonly onSelectionChange: IEvent = this.selectionChangeEmitter.event; public readonly onTitleChange: IEvent = this.titleChangeEmitter.event; public readonly onScroll: IEvent = this.scrollEmitter.event; @@ -597,11 +595,6 @@ export class Terminal implements ITerminalCore { // Connect selection manager to renderer this.renderer.setSelectionManager(this.selectionManager); - // Forward selection change events - this.selectionManager.onSelectionChange(() => { - this.selectionChangeEmitter.fire(); - }); - // Initialize link detection system this.linkDetector = new LinkDetector(this); @@ -1012,7 +1005,6 @@ export class Terminal implements ITerminalCore { this.dataEmitter.dispose(); this.resizeEmitter.dispose(); this.bellEmitter.dispose(); - this.selectionChangeEmitter.dispose(); this.titleChangeEmitter.dispose(); this.scrollEmitter.dispose(); } @@ -1178,7 +1170,7 @@ export class Terminal implements ITerminalCore { // Remove event listeners if (this.element) { - this.element.removeEventListener('wheel', this.handleWheel); + this.element.removeEventListener('wheel', this.handleWheel, { capture: true }); this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true }); this.element.removeEventListener('mousemove', this.handleMouseMove); this.element.removeEventListener('mouseleave', this.handleMouseLeave); From 4f95617cb964453e6159b940fb66f545c169380f Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 02:06:57 -0400 Subject: [PATCH 071/295] fix(client): ignore DEC mode 12 for the cursor blink, as Ghostty does Since ADR 049 the cursor's blink came from Ghostty's mode 12 bit, which both DECSCUSR and mode 12 write. vim's t_ve sends CSI ?12l on exit, so the cursor stopped blinking for good. Ghostty's app ignores mode 12 whenever cursor-style-blink is set (termio/stream_handler.zig), and webtty's cursorStyleBlink is always set, but that check is not part of libghostty-vt. The binding now keeps the blink the last DECSCUSR, DECSTR or RIS chose (or the configured default), scanning only chunks that can hold one, and puts mode 12 back to it after every write and default change. --- docs/adrs/013.client.cursor-style.md | 2 +- .../049.client.official-mouse-cursor-paste.md | 8 ++- docs/specs/client.md | 2 +- docs/specs/config.md | 4 +- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/ghostty.ts | 71 +++++++++++++++++++ src/client/vendor/official-terminal.test.ts | 36 ++++++++-- 7 files changed, 113 insertions(+), 12 deletions(-) diff --git a/docs/adrs/013.client.cursor-style.md b/docs/adrs/013.client.cursor-style.md index a41008b..5c02d74 100644 --- a/docs/adrs/013.client.cursor-style.md +++ b/docs/adrs/013.client.cursor-style.md @@ -36,7 +36,7 @@ The intercept lives in `src/client/cursor.ts`, isolated from the WebSocket and t **Update (ADR 041):** DECSCUSR is now applied inside the vendored terminal (`src/client/vendor/ghostty-web/terminal.ts`): `write()` scans the output (`scanDecscusr`, which also carries a sequence split across writes) and keeps the app's shape apart from `options.cursorStyle` / `options.cursorBlink`, which stay the configured default. Ps 0 now clears the app's shape, so the configured cursor shows again (it was blinking block). `cursor.ts` and its call in `index.ts` are deleted. The Coder wasm does not export the cursor style, so the output is still scanned; reading it from the render state is ADR 041 step 5. ([ADR 047](047.client.official-engine-only.md) removed the Coder wasm but kept the scan: the official render state does expose the cursor style, and reading it from there is a follow-up with its own update to this ADR, since Ghostty's blink also follows mode 12.) -**Update (ADR 049):** the scan is gone. The configured style and blink are Ghostty's defaults (`DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`), Ghostty applies DECSCUSR, and the renderer draws the shape and blink that the render state reports. Ps 0 and RIS still restore the configured default. Following Ghostty, mode 12 now also turns blinking on and off, the shape is kept per screen, and DECSTR restores the default. See [ADR 049](049.client.official-mouse-cursor-paste.md). +**Update (ADR 049):** the scan is gone. The configured style and blink are Ghostty's defaults (`DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`), Ghostty applies DECSCUSR, and the renderer draws the shape and blink that the render state reports. Ps 0 and RIS still restore the configured default. Following Ghostty, the shape is kept per screen and DECSTR restores the default. Mode 12 is ignored, as in Ghostty with `cursor-style-blink` set, so only DECSCUSR changes the blink. See [ADR 049](049.client.official-mouse-cursor-paste.md). ## Considered Options diff --git a/docs/adrs/049.client.official-mouse-cursor-paste.md b/docs/adrs/049.client.official-mouse-cursor-paste.md index 7d092b7..af2c538 100644 --- a/docs/adrs/049.client.official-mouse-cursor-paste.md +++ b/docs/adrs/049.client.official-mouse-cursor-paste.md @@ -42,6 +42,7 @@ The binding gains a mouse encoder per terminal (`OfficialTerminal.encodeMouse`, - The renderer takes the shape and blink from `getCursor()` every frame (`style`, `blinking`), and repaints the cursor cell when either changes (ADR 015). The binding already reads both from the render state, and maps a hollow block to a block. - The configured `cursorStyle` / `cursorBlink` go to `GhosttyTerminalConfig` and are set with `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK` when the terminal is created, including by `reset()`. An option change at runtime calls the new `setDefaultCursor`. `scanDecscusr`, `decscusrShape`, `CursorShape` and the app-shape state in `terminal.ts` go. - ADR 013's behaviour is kept. The config sets the default, DECSCUSR 1–6 overrides it, `CSI 0 SP q` and RIS restore it, and changing the config while an app has chosen a shape does not override that shape. +- **Mode 12 is ignored**, as in Ghostty with `cursor-style-blink` set. Ghostty's app config leaves mode 12 (AT&T blink) to the program only while `cursor-style-blink` is unset; set to true or false, mode 12 is ignored and DECSCUSR still sets the blink (`setMode` in `src/termio/stream_handler.zig`), so a program that turns blinking off with mode 12 and never restores it (vim's `t_ve` sends `CSI ? 12 l` on exit) cannot leave the cursor steady for good. webtty's `cursorStyleBlink` always has a value, so it is always the set case. libghostty-vt has no switch for this: that check lives in Ghostty's app, and in the library DECSCUSR and mode 12 both write the one `cursor_blinking` mode bit that the render state reports. So the binding keeps the blink DECSCUSR chose: after each write it finds the last DECSCUSR, DECSTR or RIS in the chunk (`scanCursorBlink`, carrying a sequence split across writes; a chunk with none of `" q"`, `"!p"` or `ESC c` is not scanned), applies it (Ps 1/3/5 blink, 2/4/6 steady, Ps 0, DECSTR and RIS the configured default), and puts mode 12 back to that value with `GHOSTTY_TERMINAL_OPT_MODE` when it differs. `setDefaultCursor` does the same while the app has not chosen a blink. DECRQM for mode 12 then reports the blink in effect. ### Paste: `ghostty_terminal_paste` @@ -63,8 +64,8 @@ Mouse, visible to apps: Cursor style: - The output scan's CPU is gone. The shape is now whatever Ghostty's state says, so these follow Ghostty: - - Mode 12 (`CSI ? 12 h/l`, ATT610) turns blinking on and off. Vim's xterm `t_vs`/`t_ve` send it, so a cursor can stop or start blinking where the scan ignored it. - - The shape is kept per screen: leaving the alternate screen shows the main screen's shape again. Blink is a mode, so it is shared by both screens. + - Mode 12 (`CSI ? 12 h/l`, ATT610) is ignored, as in Ghostty with `cursor-style-blink` set: only DECSCUSR changes the blink, and Ps 0, DECSTR and RIS restore `cursorStyleBlink`. Vim's xterm `t_vs`/`t_ve` send mode 12, and the cursor keeps blinking after vim exits. Following mode 12 instead (Ghostty with `cursor-style-blink` unset) left it steady. + - The shape is kept per screen: leaving the alternate screen shows the main screen's shape again. Blink is shared by both screens. - DECSTR (`CSI ! p`) restores the default. - Ps 7 and above are ignored. @@ -82,7 +83,8 @@ Overall: every mouse, cursor-style and paste path is now Ghostty's. The TS encod - **Fix the TS encoders.** It would mean adding Alt, buttons 8/9, three more formats, mode 9, clamping and drags outside the container, which re-implements `mouse_encode.zig` and keeps it in step with Ghostty by hand. - **`ghostty_paste_encode` instead of `ghostty_terminal_paste`.** Same bytes today, with no callback. But the terminal call is the one Ghostty recommends: it reads the modes itself and is where kitty paste events live. - **Binary frames for X10 above column 95.** Needs a protocol and server change for a format that modern apps do not use. Deferred. -- **Ignore mode 12 and keep blink from DECSCUSR only.** That would need the scan again, or a second, JS-side state that could disagree with Ghostty's. Ghostty itself follows mode 12. +- **Follow mode 12, as libghostty-vt does by default.** First chosen here, to keep every blink change in Ghostty's state with no scan. It matches Ghostty only with `cursor-style-blink` unset, which webtty's config cannot express, and it left the cursor steady after vim. Replaced by ignoring mode 12, at the cost of a scan that runs only on chunks that can hold DECSCUSR, DECSTR or RIS, and one mode read per write. +- **Ignore mode 12 without a scan.** Not possible from outside the library: DECSCUSR and mode 12 write the same mode bit, and libghostty-vt has no callback for either. ## Related Decisions diff --git a/docs/specs/client.md b/docs/specs/client.md index 3339188..7f9bbca 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -180,7 +180,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Output acknowledgements | Output is acknowledged as it is parsed, so the server sends it in order at the tab's pace; a tab closed with `4003` for falling behind resets and reconnects to the replay | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | -| Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default) and mode 12, and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | +| Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Multiline text paste | Text is encoded by Ghostty's `ghostty_terminal_paste`: bracketed whenever mode 2004 is enabled (single lines too), control bytes replaced with spaces, and without 2004 sent with newlines as CR (they may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | IME / dictation at cursor | The terminal keeps focus on its input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md), [ADR 041](../adrs/041.client.libghostty-vt.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 3f9e9e6..a28e96c 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -91,7 +91,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (and blink via mode 12), and DECSCUSR 0 or a reset restores it. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -112,7 +112,7 @@ All keys are optional — omit any key to use the default value. | `cols` | number | `80` | Initial terminal width in columns | | `rows` | number | `24` | Initial terminal height in rows | | `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. | -| `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR or mode 12; `CSI 0 SP q` and a terminal reset restore it. | +| `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. DEC mode 12 (`CSI ? 12 h/l`) is ignored, as in Ghostty when `cursor-style-blink` is set, so an app that turns blinking off that way (vim's `t_ve`) does not leave the cursor steady. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | | `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 7e3f1d2..b78dc23 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -16,7 +16,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code, a release outside the terminal no longer leaves a button held, motion is reported once per cell instead of per `mousemove`, and its own `wheel` listener is removed: the terminal's paced `handleWheel` is the only wheel path, where a wheel over the padding (the container itself the target, so the terminal's capture listener did not stop it) was sent a second time, unpaced. - `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. - `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. -- `terminal.ts`, `renderer.ts`, `ghostty.ts`, `types.ts`: the cursor shape and blink are Ghostty's (ADR 049). The configured `cursorStyle` / `cursorBlink` are passed in `GhosttyTerminalConfig` and set as `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, which DECSCUSR Ps 0 and RIS restore; an option change calls the binding's new `setDefaultCursor`. `render()` draws the `style` and `blinking` that `getCursor()` reports (upstream drew only the options, and Ghostty's render-state style was hard-coded to block), and a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. webtty's earlier output scan for DECSCUSR (`scanDecscusr`, `decscusrShape`) is removed. +- `terminal.ts`, `renderer.ts`, `ghostty.ts`, `types.ts`: the cursor shape and blink are Ghostty's (ADR 049). The configured `cursorStyle` / `cursorBlink` are passed in `GhosttyTerminalConfig` and set as `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, which DECSCUSR Ps 0 and RIS restore; an option change calls the binding's new `setDefaultCursor`. `render()` draws the `style` and `blinking` that `getCursor()` reports (upstream drew only the options, and Ghostty's render-state style was hard-coded to block), and a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. webtty's earlier output scan for DECSCUSR (`scanDecscusr`, `decscusrShape`) is removed. DEC mode 12 is ignored, as in Ghostty with `cursor-style-blink` set: the binding keeps the blink set by the last DECSCUSR, DECSTR or RIS (`scanCursorBlink`, run only on chunks that can hold one) or the default, and puts Ghostty's mode 12 bit back to it after every write and default change (`syncBlink`, `GHOSTTY_TERMINAL_OPT_MODE`). - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends scroll buttons 64/65 (since ADR 049 encoded by Ghostty's mouse encoder in the app's mouse format) at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. - `terminal.ts`: focus that lands on the contenteditable container (padding clicks, tab focus, `focus()`, a window regaining focus) is sent to the textarea, and the render loop anchors the textarea at the cursor cell, one cell high (`cursorCellOffset`, public `getCursorCellOffset`), so IME text never lands in the container and OS panels follow the cursor. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index c0f6a96..358a8bc 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -453,6 +453,7 @@ function resolveOfficialLayout(layout: VtLayout) { SCROLLBACK_MAX_BYTES: opt('SCROLLBACK_MAX_BYTES'), SCROLLBACK_MAX_LINES: opt('SCROLLBACK_MAX_LINES'), MODE_DEFAULT: opt('MODE_DEFAULT'), + MODE: opt('MODE'), DEFAULT_CURSOR_STYLE: opt('DEFAULT_CURSOR_STYLE'), DEFAULT_CURSOR_BLINK: opt('DEFAULT_CURSOR_BLINK'), }, @@ -899,6 +900,40 @@ const MOUSE_REPORT_MAX = 64; * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. */ const MOUSE_MODES = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; +/** DEC private mode 12, AT&T cursor blink (ATT610). */ +const MODE_CURSOR_BLINK = 12; +/** Bytes as one char each, for scanning ASCII sequences in byte output. */ +const LATIN1 = new TextDecoder('latin1'); + +// What sets the cursor blink while mode 12 is ignored: DECSCUSR (CSI Ps SP q), DECSTR +// (CSI ! p) and RIS (ESC c). The last two restore the default, as Ps 0 does. +const CURSOR_BLINK_SEQ = /\x1b(?:\[(\d*) q|\[!p|c)/g; +const CURSOR_BLINK_PREFIX = /^\x1b(?:\[(?:\d{0,3} ?|!)?)?$/; + +/** + * Scans output for the sequences that set the cursor blink while DEC mode 12 is + * ignored, and returns the last one as a DECSCUSR Ps from 0 to 6 (DECSTR and RIS + * as 0; undefined if none), plus the unfinished sequence at the end of the chunk to + * pass back in as `carry` with the next one. Most chunks hold none of them and are + * not scanned. (webtty, ADR 049: Ghostty ignores mode 12 when `cursor-style-blink` + * is set, in its app's stream handler, which libghostty-vt does not include.) + */ +export function scanCursorBlink( + data: string, + carry: string = '' +): { ps: number | undefined; carry: string } { + const text = carry + data; + let ps: number | undefined; + if (carry !== '' || text.includes(' q') || text.includes('!p') || text.includes('\x1bc')) { + for (const match of text.matchAll(CURSOR_BLINK_SEQ)) { + const value = match[1] === undefined || match[1] === '' ? 0 : Number(match[1]); + if (value <= 6) ps = value; + } + } + const esc = text.lastIndexOf('\x1b'); + const tail = esc >= 0 && text.length - esc <= 6 ? text.slice(esc) : ''; + return { ps, carry: CURSOR_BLINK_PREFIX.test(tail) ? tail : '' }; +} function blankCell(): GhosttyCell { return { @@ -1029,6 +1064,12 @@ export class OfficialTerminal implements GhosttyTerminal { private cursorVisible = true; private cursorBlinking = false; private cursorStyle: RenderStateCursor['style'] = 'block'; + // The blink DECSCUSR set, or the default while it has not set one; DEC mode 12 is + // ignored, so Ghostty's mode bit is put back to it after every write (ADR 049). + private blinkDefault = false; + private blinkIsDefault = true; + private blink = false; + private blinkCarry = ''; private defFg = 0xffffff; private defBg = 0; private readonly palette = new Uint8Array(256 * 3); @@ -1162,6 +1203,16 @@ export class OfficialTerminal implements GhosttyTerminal { this.u8().set(bytes, ptr); ex.ghostty_terminal_vt_write(this.term, ptr, bytes.length); ex.ghostty_wasm_free(ptr, bytes.length); + const scan = scanCursorBlink( + typeof data === 'string' ? data : LATIN1.decode(bytes), + this.blinkCarry + ); + this.blinkCarry = scan.carry; + if (scan.ps !== undefined) { + this.blinkIsDefault = scan.ps === 0; + this.blink = scan.ps === 0 ? this.blinkDefault : scan.ps % 2 === 1; + } + this.syncBlink(); } this.generation++; if (this.titleChanged) this.readTitle(); @@ -1214,6 +1265,9 @@ export class OfficialTerminal implements GhosttyTerminal { this.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); this.u8()[at.u8] = blink ? 1 : 0; this.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); + this.blinkDefault = blink; + if (this.blinkIsDefault) this.blink = blink; + this.syncBlink(); // The cursor is read from the render state, which only a content change refreshes. this.generation++; } @@ -1603,6 +1657,23 @@ export class OfficialTerminal implements GhosttyTerminal { u8[ptr + C.b] = rgb & 0xff; } + /** + * Puts Ghostty's mode 12 bit, which its render state reports as the cursor blink, + * back to the blink DECSCUSR set. Ghostty with `cursor-style-blink` set ignores mode + * 12 (`setMode` in its `termio/stream_handler.zig`), and webtty's `cursorStyleBlink` + * is always set, so a program's `CSI ? 12 l` (vim's `t_ve`) no longer stops the + * blink for good. (webtty, ADR 049) + */ + private syncBlink(): void { + if (this.getMode(MODE_CURSOR_BLINK) === this.blink) return; + const at = this.at; + const M = this.L.mode; + const dv = this.dv(); + dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); + dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); + this.setOption(this.L.opt.MODE, at.mode, 'MODE 12'); + } + private setOption(option: number, value: number, what: string): void { const rc = this.ex.ghostty_terminal_set(this.term, option, value); if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index eab54ff..714e616 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -288,12 +288,40 @@ describe('official engine: cursor and modes', () => { t.free(); }); - test('follows Ghostty: mode 12 blinks, the shape is per screen, DECSTR restores', () => { - const t = term(); - t.write('\x1b[6 q\x1b[?12h'); + test('mode 12 does not change the blink; DECSCUSR does, and Ps 0 restores the config', () => { + const t = term(40, 10, { ...THEME, cursorStyle: 'bar', cursorBlink: true }); + // vim's t_vs / t_ve, as Ghostty with cursor-style-blink set (ADR 049) + t.write('\x1b[?12l\x1b[?25h'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + expect(t.getMode(12)).toBe(true); + t.write('\x1b[?12h'); + expect(t.getCursor().blinking).toBe(true); + // DECSCUSR steady, then mode 12 on in the same chunk and in the next + t.write('\x1b[2 q\x1b[?12h'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.write('\x1b[?12;25h'); + expect(t.getCursor().blinking).toBe(false); + t.write('\x1b[1 q\x1b[?12l'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: true }); + t.write('\x1b[4 q'); + expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: false }); + t.write('\x1b[?12h\x1b[0 q\x1b[?12l'); expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - t.write('\x1b[?12l'); + // A DECSCUSR split across writes, after the blink default changed + t.setDefaultCursor('bar', false); expect(t.getCursor().blinking).toBe(false); + t.write('\x1b[5 '); + t.write('q\x1b[?12l'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b'); + t.write('c\x1b[?12h'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: false }); + t.free(); + }); + + test('follows Ghostty: the shape is per screen, DECSTR restores', () => { + const t = term(); + t.write('\x1b[6 q'); t.write('\x1b[?1049h\x1b[2 q'); expect(t.getCursor().style).toBe('block'); t.write('\x1b[?1049l'); From a1671fa9f0987c67838ef81d2d6af173268ebda5 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 02:08:59 -0400 Subject: [PATCH 072/295] fix(client): copy wide characters without a space after each one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSelection copied the spacer cell that follows a wide character as a space, so "中文字" was copied as "中 文 字", and emoji the same way. Spacer cells (width 0), including the last column a wide character wrapped from, are now skipped, and a selection that starts on the right half of a wide character takes the whole character. Graphemes and combining marks are copied as before. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/selection-manager.ts | 10 ++- src/client/vendor/selection-manager.test.ts | 68 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index b78dc23..224566c 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -28,6 +28,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. - `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). - `input-handler.ts`: the paste path is removed (the `paste` listeners, `handlePaste`, the `insertFromPaste` case of `handleBeforeInput`, `emitPasteData` and the paste de-duplication). It never ran: webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no `insertFromPaste`. +- `selection-manager.ts`: `getSelection` skips spacer cells (width 0), the cell after a wide character and the last column a wide character wrapped from, which upstream copied as a space ("中 文 字"); a selection that starts on the right half of a wide character takes the whole character. - `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. `Terminal.onSelectionChange`, which only that copy used, is removed. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 4fc7716..479426b 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -153,13 +153,21 @@ export class SelectionManager { let lastNonEmpty = -1; // Determine column range for this row - const colStart = absRow === startAbsRow ? startCol : 0; + let colStart = absRow === startAbsRow ? startCol : 0; const colEnd = absRow === endAbsRow ? endCol : line.length - 1; + // A selection that starts on the right half of a wide character takes all of it + if (colStart > 0 && line[colStart]?.width === 0 && line[colStart - 1]?.width === 2) { + colStart--; + } // Build the line text let lineText = ''; for (let col = colStart; col <= colEnd; col++) { const cell = line[col]; + // A spacer (width 0) holds nothing: the cell after a wide character, or the + // last column when a wide character wrapped to the next row. (webtty: upstream + // copied it as a space, so "中文" came out as "中 文".) + if (cell && cell.width === 0) continue; if (cell && cell.codepoint !== 0) { // Use grapheme lookup for cells with multi-codepoint characters let char: string; diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index f4659aa..563f006 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -135,3 +135,71 @@ describe('SelectionManager copy on select', () => { expect(changes()).toBe(3); }); }); + +describe('SelectionManager text of wide characters', () => { + const doc = globalThis.document; + let ghostty: Ghostty; + + beforeEach(async () => { + globalThis.document = target() as unknown as Document; + ghostty = await Ghostty.load(WASM); + }); + + afterEach(() => { + globalThis.document = doc; + }); + + /** The text a selection from (startCol, startRow) to (endCol, endRow) copies; rows are absolute. */ + function selected( + wasmTerm: GhosttyTerminal, + start: [number, number], + end: [number, number], + ): string { + const m = new SelectionManager( + { cols: wasmTerm.cols, rows: wasmTerm.rows, options: {} } as unknown as Terminal, + { getCanvas: () => target() } as unknown as CanvasRenderer, + wasmTerm, + { style: {} } as HTMLTextAreaElement, + ); + const internal = m as unknown as Record; + internal.selectionStart = { col: start[0], absoluteRow: start[1] }; + internal.selectionEnd = { col: end[0], absoluteRow: end[1] }; + return m.getSelection(); + } + + // Emoji with a skin tone, with VS16 (U+2764 U+FE0F) and a ZWJ family, and combining marks + test('copies no space for the spacer after a CJK character or an emoji', () => { + const t = ghostty.createTerminal(30, 4); + t.write( + '中文字 ok\r\n\u{1f44d}\u{1f3fd} \u2764\ufe0f \u{1f468}\u200d\u{1f469}\u200d\u{1f467} x\r\ne\u0301 a\u20dd', + ); + expect(selected(t, [0, 0], [29, 2])).toBe( + '中文字 ok\n\u{1f44d}\u{1f3fd} \u2764\ufe0f \u{1f468}\u200d\u{1f469}\u200d\u{1f467} x\ne\u0301 a\u20dd', + ); + // A selection that starts or ends on the right half of a wide character + expect(selected(t, [1, 0], [3, 0])).toBe('中文'); + t.free(); + }); + + test('copies no space for a spacer at the last column', () => { + // A wide character that does not fit wraps, leaving a spacer at the last column + const wrapped = ghostty.createTerminal(5, 4); + wrapped.write('abcd中ef'); + expect(selected(wrapped, [0, 0], [4, 1])).toBe('abcd\n中ef'); + expect(selected(wrapped, [2, 0], [4, 0])).toBe('cd'); + wrapped.free(); + // A wide character ending at the last column + const fits = ghostty.createTerminal(6, 4); + fits.write('abcd中ef'); + expect(selected(fits, [3, 0], [1, 1])).toBe('d中\nef'); + fits.free(); + }); + + test('copies scrollback rows without spacers', () => { + const t = ghostty.createTerminal(10, 2); + t.write('中文 1\r\n日本語 2\r\nlast\r\nrow'); + expect(t.getScrollbackLength()).toBe(2); + expect(selected(t, [0, 0], [9, 3])).toBe('中文 1\n日本語 2\nlast\nrow'); + t.free(); + }); +}); From 37959375cc9508a736c6d51b7f19167a92e7f70a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 02:11:13 -0400 Subject: [PATCH 073/295] fix(client): move a trackpad gesture's first deltas at once The first delta of every gesture comes after a pause, so it was taken as the start of a sparse stream and glided over up to 125 ms, and the next over about 33 ms: the view started each gesture behind the fingers. A stream now turns sparse on its second long gap in a row, as it already stops being sparse on its second short one; a TrackPoint's first delta is a few pixels, so its streams still glide (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 +- src/client/wheel-stream.test.ts | 23 +++++++++++++++++ src/client/wheel-stream.ts | 29 ++++++++++++++-------- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index ca09f53..3f1f2bb 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -27,7 +27,7 @@ In detail: - **Classify the device as VS Code does** (`MouseWheelClassifier` in `scrollableElement.ts`, MIT). Each event gets a score: 1 when both axes move; otherwise 0.5, plus 0.25 for a non-integer delta, minus 0.5 when it and the previous delta are multiples of each other (a wheel's steady notches). The last 5 scores are weighted 1/2, 1/4, 1/8, … and a total of at most 0.5 is a physical wheel. Line and page deltas (`deltaMode` 1 or 2) are always a wheel. - **Glide over 125 ms with an ease-out cubic**, VS Code's `smoothScrollDuration`. A notch during a glide adds to the glide's target and restarts the glide from the current position, so fast notches scroll as far as they ask, without stepping. A notch within 200 ms of one in the same direction continues linearly over its gap plus a frame instead of easing out again, so a spinning wheel, or a TrackPoint's slow 4 px steps every 150 ms, scroll steadily instead of pulsing. -- **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. +- **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream turns sparse on its second long gap in a row, so a trackpad gesture's first deltas after a pause move at once instead of gliding (gliding them put the view up to 125 ms behind the fingers at the start of every gesture), and it stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. - **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. Once on the spring, a stream stays on it until it pauses for 125 ms or reverses: leaving it on two short gaps in a row applied the spring's pending distance at once (985 px in one frame in a replay with 22 ms gaps), and leaving it as the gaps grew at the end of a push sped the view up and then stopped it dead. The spring trails a steady stream by 2/ω, about 70 ms, further than the linear glide it takes over from, and opening that gap at once slowed the view by half for a few frames (160 → 89 px per frame); so it takes over at the input's speed and at the stiffness whose lag matches where the view is, relaxing to ω over 150 ms (the slowest frame after the switch is now 107 px). Once no event has come for 60 ms, the spring's stiffness doubles, so the view stops in about 200 ms instead of coasting for 350 ms; it is settled within half a device pixel. End to end, a hard push's frames went from a frame-to-frame standard deviation of 24–43 px to 10.5–15 px, with 13 → 55 ms of lag. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. - **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. diff --git a/src/client/wheel-stream.test.ts b/src/client/wheel-stream.test.ts index 24a5d69..1419cf3 100644 --- a/src/client/wheel-stream.test.ts +++ b/src/client/wheel-stream.test.ts @@ -108,6 +108,29 @@ describe('WheelClassifier', () => { }); }); +describe('WheelStream at the start of a gesture', () => { + const at = (deltaY: number, timeStamp: number) => + ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; + + test("a trackpad gesture's first deltas after a pause move at once", () => { + const s = new WheelStream(); + s.forScrollback(at(-20.5, 1000)); + s.forScrollback(at(-21.25, 1016)); + // A pause, then a new gesture: no glide, the view follows the fingers at once. + expect(s.forScrollback(at(-3.25, 5000)).duration).toBe(0); + expect(s.forScrollback(at(-5.5, 5016)).duration).toBe(0); + }); + + test('a sparse stream glides from its second long gap', () => { + const s = new WheelStream(); + s.forScrollback(at(-20.5, 1000)); + s.forScrollback(at(-21.25, 1016)); + // The pause before the stream is one long gap: still at once. + expect(s.forScrollback(at(-23.75, 5000)).duration).toBe(0); + expect(s.forScrollback(at(-63.25, 5040)).duration).toBeGreaterThan(0); + }); +}); + describe('WheelStream with reduced motion', () => { function reduced(run: () => void) { const g = globalThis as { matchMedia?: unknown }; diff --git a/src/client/wheel-stream.ts b/src/client/wheel-stream.ts index ca7ff78..62ae1f4 100644 --- a/src/client/wheel-stream.ts +++ b/src/client/wheel-stream.ts @@ -7,8 +7,8 @@ * - A wheel notch eases out over the wheel glide time, or, soon after one in the * same direction, continues linearly over its gap plus a frame, so a spinning * wheel does not pulse. - * - A sparse delta, 25 ms or more after the previous one, glides linearly over - * that gap plus a frame, so a stream scrolls at the speed it is sent at; the + * - A sparse delta, 25 ms or more after the previous one (from the second such gap + * in a row), glides linearly over that gap plus a frame, so a stream scrolls at the speed it is sent at; the * extra frame keeps the glide going when the next delta is a little late. A * stream stays sparse until two short gaps in a row, so one delta that comes a * little early does not snap or flush. @@ -119,8 +119,9 @@ export class WheelStream { /** `timeStamp` and direction of the previous event. */ private lastWheel: number | null = null; private lastDir = 0; - /** In a sparse stream, how many short gaps in a row it has had, and its average gap. */ + /** In a sparse stream, how many long and short gaps in a row there have been, and its average gap. */ private sparse = false; + private longGaps = 0; private shortGaps = 0; private averageGap = 0; /** Following the spring: until the stream pauses, every delta keeps doing so. */ @@ -150,20 +151,28 @@ export class WheelStream { private next(e: WheelEvent): Classified { const gap = this.lastWheel === null ? 0 : e.timeStamp - this.lastWheel; + // The very first event comes after a pause too. + const long = this.lastWheel === null || gap >= SPARSE_GAP_MS; this.lastWheel = e.timeStamp; const dir = Math.sign(e.deltaY); const sameDir = dir === this.lastDir; this.lastDir = dir; if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); - if (gap >= SPARSE_GAP_MS) { - // A stream's first gap is the pause before it; count it as the slowest gap. - const capped = Math.min(gap, WHEEL_GLIDE_MS); - this.averageGap = this.sparse ? 0.4 * capped + 0.6 * this.averageGap : WHEEL_GLIDE_MS; - this.sparse = true; + // A stream turns sparse on its second long gap in a row, as it stops being sparse on + // its second short one: the first long gap is the pause before any gesture, and + // gliding a trackpad's first deltas put the view behind the fingers at every start. + if (long) { this.shortGaps = 0; - } else if (this.sparse && ++this.shortGaps >= 2) { - this.sparse = false; + if ((++this.longGaps >= 2 || this.sparse) && gap > 0) { + // A stream's first gap is the pause before it; count it as the slowest gap. + const capped = Math.min(gap, WHEEL_GLIDE_MS); + this.averageGap = this.sparse ? 0.4 * capped + 0.6 * this.averageGap : WHEEL_GLIDE_MS; + this.sparse = true; + } + } else { + this.longGaps = 0; + if (this.sparse && ++this.shortGaps >= 2) this.sparse = false; } const spring: Pace = { duration: WHEEL_GLIDE_MS, omega: STREAM_OMEGA }; // A fast stream stays on the spring until it pauses: leaving it on a short gap From 568f948652b0415bb75734d39c8bec0a6e567dab Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:05:45 -0400 Subject: [PATCH 074/295] perf(client): copy the edge strips only when the canvas changed EdgeExtender copied the canvas's last line and column into the edge strips on every animation frame, so the page never let the compositor go idle and every frame was presented. The renderer now counts its draws (visibleArea.version) and the strips copy only when that or the visible area changed. --- src/client/edge.ts | 16 ++++++++++++++++ src/client/vendor/ghostty-web/renderer.ts | 16 +++++++++++++++- src/client/vendor/renderer.test.ts | 19 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/client/edge.ts b/src/client/edge.ts index 5d84a07..bd1f7a3 100644 --- a/src/client/edge.ts +++ b/src/client/edge.ts @@ -57,6 +57,8 @@ export interface VisibleArea { * view ends inside that row, through its glyphs; its own last line is the background. */ lastLine: number; + /** Changes whenever the canvas is drawn on; absent: assume it changed every frame. */ + version?: number; } interface Strip { @@ -97,6 +99,8 @@ export class EdgeExtender { private readonly bottom = createStrip(); private readonly right = createStrip(); private frame: number | null = null; + /** What the strips were last copied from, so an unchanged frame copies nothing. */ + private painted = ''; constructor( private readonly container: HTMLElement, @@ -130,6 +134,7 @@ export class EdgeExtender { const dpr = window.devicePixelRatio || 1; if (this.bottom) place(this.bottom, rects.bottom, dpr); if (this.right) place(this.right, rects.right, dpr); + this.painted = ''; if (this.frame === null && (this.bottom?.visible || this.right?.visible)) { this.frame = window.requestAnimationFrame(this.paint); } @@ -149,6 +154,17 @@ export class EdgeExtender { : null); const src = area?.canvas; if (!area || !src || src.width === 0 || area.height === 0) return; + // Copying every frame kept the compositor from ever idling; copy only on a change. + const from = + area.version === undefined + ? '' + : `${area.version} ${area.y} ${area.height} ${area.lastLine} ${src.width}`; + const unchanged = from !== '' && from === this.painted; + this.painted = from; + if (unchanged) { + this.frame = window.requestAnimationFrame(this.paint); + return; + } if (this.bottom?.visible) { const { ctx, el } = this.bottom; ctx.drawImage(src, 0, area.lastLine, src.width, 1, 0, 0, el.width, el.height); diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index e75c17f..60a4647 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -428,6 +428,7 @@ export class CanvasRenderer { * Resize canvas to fit terminal dimensions */ public resize(cols: number, rows: number): void { + this.drawCount++; const cssWidth = cols * this.metrics.width; const cssHeight = rows * this.metrics.height; @@ -780,6 +781,7 @@ export class CanvasRenderer { cols: number, rowAt: (k: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } ): void { + this.drawCount++; const state = this.rowState; const total = state.length; const free = (k: number) => k < 0 || k >= total || state[k] === ROW_INVALID; @@ -838,6 +840,7 @@ export class CanvasRenderer { cols: number, { line, grapheme }: { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } ): void { + this.drawCount++; if (line) { this.renderLine(line, k, cols, grapheme); return; @@ -878,6 +881,7 @@ export class CanvasRenderer { * the canvas: the rows that came in, which are invalid. (webtty, ADR 043) */ private shiftRows(rows: number): void { + this.drawCount++; const { width, height } = this.canvas; const dy = rows * this.metrics.height * this.devicePixelRatio; const kept = height - Math.abs(dy); @@ -918,6 +922,7 @@ export class CanvasRenderer { cols: number, grapheme: GraphemeLookup = this.screenGraphemes(this.currentBuffer, y) ): void { + this.drawCount++; const lineY = y * this.metrics.height; const lineWidth = cols * this.metrics.width; @@ -1122,6 +1127,7 @@ export class CanvasRenderer { * Render cursor at canvas row `y`, which shows screen row `screenRow` (webtty) */ private renderCursor(x: number, y: number, screenRow: number): void { + this.drawCount++; const cursorX = x * this.metrics.width; const cursorY = y * this.metrics.height; @@ -1252,6 +1258,7 @@ export class CanvasRenderer { opacity: number = 1, row: number = 0 ): void { + this.drawCount++; const ctx = this.ctx; const top = row * this.metrics.height; const canvasHeight = visibleRows * this.metrics.height; @@ -1313,6 +1320,9 @@ export class CanvasRenderer { * The offset the viewport is shown at, in CSS pixels: viewport row 0 starts * this far below the element's top. Hit testing adds it. (webtty, ADR 043) */ + /** Counts the calls that draw on the canvas: `visibleArea.version`. (webtty) */ + private drawCount = 0; + public get scrollOffset(): number { return this.shownScrollOffset; } @@ -1320,13 +1330,15 @@ export class CanvasRenderer { /** * The canvas the rows are drawn on and the part of it in view, in canvas pixels; the * rest is overscan. `lastLine` is the bottom pixel line of the viewport's last row, - * which at a fraction of a line is below the part in view. (webtty, ADR 043) + * which at a fraction of a line is below the part in view. `version` changes whenever + * the canvas is drawn on, so a copy of its pixels can tell it is stale. (webtty, ADR 043) */ public get visibleArea(): { canvas: HTMLCanvasElement; y: number; height: number; lastLine: number; + version: number; } { const dpr = this.devicePixelRatio; const bottom = this.shownTop + this.shownScrollOffset + this.shownRows * this.metrics.height; @@ -1335,6 +1347,7 @@ export class CanvasRenderer { y: Math.round(this.shownTop * dpr), height: this.shownRows * this.metrics.height * dpr, lastLine: Math.round(bottom * dpr) - 1, + version: this.drawCount, }; } @@ -1408,6 +1421,7 @@ export class CanvasRenderer { * Clear entire canvas */ public clear(): void { + this.drawCount++; // clearRect first because fillRect composites rather than replaces, // so transparent/translucent backgrounds wouldn't clear previous content. this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index c25dd46..8d68ae5 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -728,6 +728,25 @@ describe('overscan and copied scrolls (ADR 043)', () => { term.free(); }); + test('visibleArea.version moves when the canvas is drawn on and not on an idle frame', async () => { + const t = await terminal(); + const live = renderer(2); + live.r.render(t.term, true, 0, t.provider, 0); + live.r.render(t.term, false, 0, t.provider, 0); + const idle = live.r.visibleArea.version; + // Nothing changed: the edge strips may skip their copy. + live.r.render(t.term, false, 0, t.provider, 0); + expect(live.r.visibleArea.version).toBe(idle); + // Output, and a scroll, draw on the canvas. + t.term.write('more output\r\n'); + live.r.render(t.term, false, 0, t.provider, 0); + const afterOutput = live.r.visibleArea.version; + expect(afterOutput).toBeGreaterThan(idle); + live.r.render(t.term, false, 3, t.provider, 0); + expect(live.r.visibleArea.version).toBeGreaterThan(afterOutput); + t.term.free(); + }); + test(`a hovered OSC 8 link underlines and repaints only its own range`, async () => { const ghostty = await Ghostty.load(WASM); const term = ghostty.createTerminal(cols, rows); From 13276b36de23d9806e2a21d6f173a0d376a97ba0 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:05:46 -0400 Subject: [PATCH 075/295] perf(client): cancel only zooming wheel events The terminal cancelled every wheel event, and Chrome then holds each event of a gesture until the page has handled the previous one: with a trackpad's two events per frame, half missed a frame and scrolling ran about a frame behind native. Only ctrl+wheel and pinch are cancelled now, and overscroll-behavior: none keeps the page from rubber-banding or swiping back. Event to presentation in headless Chrome: about 22 ms to 6.4 ms mean, against about 12 ms for a native scrolling page (ADR 045). --- docs/adrs/045.client.smooth-mouse-wheel.md | 1 + src/client/index.css | 6 +++++ src/client/vendor/ghostty-web/terminal.ts | 7 +++-- src/client/vendor/terminal.test.ts | 31 ++++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 3f1f2bb..963da82 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -30,6 +30,7 @@ In detail: - **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream turns sparse on its second long gap in a row, so a trackpad gesture's first deltas after a pause move at once instead of gliding (gliding them put the view up to 125 ms behind the fingers at the start of every gesture), and it stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. - **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. Once on the spring, a stream stays on it until it pauses for 125 ms or reverses: leaving it on two short gaps in a row applied the spring's pending distance at once (985 px in one frame in a replay with 22 ms gaps), and leaving it as the gaps grew at the end of a push sped the view up and then stopped it dead. The spring trails a steady stream by 2/ω, about 70 ms, further than the linear glide it takes over from, and opening that gap at once slowed the view by half for a few frames (160 → 89 px per frame); so it takes over at the input's speed and at the stiffness whose lag matches where the view is, relaxing to ω over 150 ms (the slowest frame after the switch is now 107 px). Once no event has come for 60 ms, the spring's stiffness doubles, so the view stops in about 200 ms instead of coasting for 350 ms; it is settled within half a device pixel. End to end, a hard push's frames went from a frame-to-frame standard deviation of 24–43 px to 10.5–15 px, with 13 → 55 ms of lag. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. +- **Only zooming wheel events are cancelled.** The terminal's wheel listener calls `preventDefault` only for `ctrlKey` events (pinch and ctrl+wheel), and `html`/`body` have `overscroll-behavior: none`, so the page neither rubber-bands nor swipes back. When a page cancels wheel events, Chrome holds each event of the gesture until the page has handled the previous one; with a trackpad's two events per frame, half of them missed a frame. Measured in headless Chrome with synthesized trackpad gestures, event to presentation went from about 22 ms to about 6.4 ms mean (a native `overflow: auto` page: about 12 ms); webtty's own work was 0–2 ms of it. - **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. diff --git a/src/client/index.css b/src/client/index.css index acede79..b12acfc 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -13,6 +13,12 @@ body, background: #000; } +html, +body { + /* Wheel events are not cancelled (ADR 045): no rubber-band and no swipe-back. */ + overscroll-behavior: none; +} + #terminal { position: relative; /* The overscan canvas (ADR 043) extends below the box; clip, unlike hidden, cannot be diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 629ad6a..df96150 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -1439,8 +1439,11 @@ export class Terminal implements ITerminalCore { * Handle wheel events for scrolling (Phase 2) */ private handleWheel = (e: WheelEvent): void => { - // Always prevent default browser scrolling - e.preventDefault(); + // Cancel only pinch and ctrl+wheel, which would zoom the page. A cancelled wheel + // event makes Chrome hold the gesture's next event until the page has handled it, + // which put scrolling about a frame behind native; nothing else on the page scrolls, + // and overscroll-behavior stops rubber-banding and swipe-back. (webtty, ADR 045) + if (e.ctrlKey) e.preventDefault(); e.stopPropagation(); // Allow custom handler to override diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index c8647db..24f829b 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -350,3 +350,34 @@ describe('Terminal link hover', () => { wasmTerm.free(); }); }); + +describe('Terminal wheel events', () => { + // A cancelled wheel event makes Chrome hold the gesture's next one until the page has + // handled it: only zooming (pinch, ctrl+wheel) is cancelled (ADR 045). + const wheel = (ctrlKey: boolean) => { + let cancelled = false; + const e = { + ctrlKey, + deltaX: 0, + deltaY: 10, + deltaMode: 0, + preventDefault: () => { + cancelled = true; + }, + stopPropagation: () => {}, + }; + const term = new Terminal({ ghostty: {} as never }) as unknown as { + handleWheel: (e: unknown) => void; + }; + term.handleWheel(e); + return cancelled; + }; + + test('a scroll is not cancelled', () => { + expect(wheel(false)).toBe(false); + }); + + test('pinch and ctrl+wheel are cancelled, so the page does not zoom', () => { + expect(wheel(true)).toBe(true); + }); +}); From 79861b0ed3efeb80c92be1fb310ecb9f8dfb0014 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:12:04 -0400 Subject: [PATCH 076/295] docs(client): record the manual check of uncancelled wheel events --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 963da82..d9dcb72 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -30,7 +30,7 @@ In detail: - **Glide a sparse delta at the input's speed.** A pixel delta that the classifier does not call a wheel, arriving 25 ms or more after the previous one, glides linearly over that gap plus one frame (at most 125 ms). A steady stream then scrolls at the speed it is sent at, and the extra frame keeps the glide going when the next delta is a little late. A TrackPoint's light 4.000244 px steps count as wheel notches (the classifier rounds them to integers that are multiples of each other), so they get the wheel glide. A stream turns sparse on its second long gap in a row, so a trackpad gesture's first deltas after a pause move at once instead of gliding (gliding them put the view up to 125 ms behind the fingers at the start of every gesture), and it stays sparse until two short gaps in a row: the capture has a 25 ms gap that replays as 24.9 ms, and treating that one delta as a trackpad's snapped the scrollback 50 lines in one frame and flushed 33 ticks to an app in one frame. - **Follow a spring while a sparse stream is fast.** When a stream's deltas come every 60 ms or faster on average (a running average of its gaps, the pause before it counting as 125 ms), the scrollback follows a critically damped spring (ω = 28 rad/s) toward the target instead of gliding linearly over each gap, and the spring takes over at the current speed. A hard TrackPoint push sends a delta every 29–45 ms, and gliding over each gap swung the speed with them. Replaying the capture through the code, the frames of a hard push went from 93–223 px to a steady 156–166 px (summed frame-to-frame change 833 → 451 px), for about 35 ms more lag; slower streams keep the linear glide, which a spring would make pulse between deltas 120 ms apart. The exact spring step (`springStep`) is stable at any frame time and does not overshoot. Once on the spring, a stream stays on it until it pauses for 125 ms or reverses: leaving it on two short gaps in a row applied the spring's pending distance at once (985 px in one frame in a replay with 22 ms gaps), and leaving it as the gaps grew at the end of a push sped the view up and then stopped it dead. The spring trails a steady stream by 2/ω, about 70 ms, further than the linear glide it takes over from, and opening that gap at once slowed the view by half for a few frames (160 → 89 px per frame); so it takes over at the input's speed and at the stiffness whose lag matches where the view is, relaxing to ω over 150 ms (the slowest frame after the switch is now 107 px). Once no event has come for 60 ms, the spring's stiffness doubles, so the view stops in about 200 ms instead of coasting for 350 ms; it is settled within half a device pixel. End to end, a hard push's frames went from a frame-to-frame standard deviation of 24–43 px to 10.5–15 px, with 13 → 55 ms of lag. - **A new glide starts from the latest frame of the one it replaces**, or a frame before the event when none is running. A frame's timestamp precedes the events handled in it, so a glide starting at the event would not move on its first frame; replaying the TrackPoint capture at 60 fps that way stalled every other frame. -- **Only zooming wheel events are cancelled.** The terminal's wheel listener calls `preventDefault` only for `ctrlKey` events (pinch and ctrl+wheel), and `html`/`body` have `overscroll-behavior: none`, so the page neither rubber-bands nor swipes back. When a page cancels wheel events, Chrome holds each event of the gesture until the page has handled the previous one; with a trackpad's two events per frame, half of them missed a frame. Measured in headless Chrome with synthesized trackpad gestures, event to presentation went from about 22 ms to about 6.4 ms mean (a native `overflow: auto` page: about 12 ms); webtty's own work was 0–2 ms of it. +- **Only zooming wheel events are cancelled.** The terminal's wheel listener calls `preventDefault` only for `ctrlKey` events (pinch and ctrl+wheel), and `html`/`body` have `overscroll-behavior: none`, so the page neither rubber-bands nor swipes back. When a page cancels wheel events, Chrome holds each event of the gesture until the page has handled the previous one; with a trackpad's two events per frame, half of them missed a frame. Measured in headless Chrome with synthesized trackpad gestures, event to presentation went from about 22 ms to about 6.4 ms mean (a native `overflow: auto` page: about 12 ms); webtty's own work was 0–2 ms of it. Checked by hand on a Mac trackpad in Chrome, Safari and Firefox (2026-10-14): scrolling felt better than with every event cancelled, two-finger swipes do not navigate back or forward, and pinch and ctrl+scroll do not zoom the page. - **The glide advances inside ghostty-web's render loop**, just before it renders (`attachBeforeRender`), so a frame shows that frame's position. With its own `requestAnimationFrame`, the glide ran after the render loop and every frame showed the previous frame's position. - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. From 6ef2c4ea3bba100d175ea1b4c6ee31f0f41a5e67 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:12:54 -0400 Subject: [PATCH 077/295] docs: describe the terminal as libghostty-vt with a renderer from ghostty-web --- README.md | 2 +- docs/specs/webtty.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c0edffe..7b2c034 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![npm version](https://img.shields.io/npm/v/webtty)](https://www.npmjs.com/package/webtty) [![CI](https://github.com/jesse23/webtty/actions/workflows/ci.yml/badge.svg)](https://github.com/jesse23/webtty/actions/workflows/ci.yml) -Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. Powered by [ghostty-web](https://github.com/coder/ghostty-web). +Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. Terminal emulation by [Ghostty](https://ghostty.org)'s official [libghostty-vt](https://github.com/ghostty-org/ghostty), with a renderer and input layer derived from [ghostty-web](https://github.com/coder/ghostty-web). - [Why webtty?](docs/awesome-web.md#terminal) diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index 92c3cfc..cc0875b 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -8,7 +8,7 @@ webtty runs CLI/TUI applications in a browser tab. Point a browser at a running webtty server and you get a full terminal — colors, cursor, resize, keyboard input — backed by a real PTY on the host machine. -The goal is zero client-side installation and full TUI support on any platform. webtty uses `ghostty-web` as the terminal renderer (WASM-backed, same API as xterm.js) and `@lydell/node-pty` for cross-platform PTY support. +The goal is zero client-side installation and full TUI support on any platform. webtty emulates the terminal with Ghostty's official libghostty-vt (WebAssembly, vendored and signature-checked), draws it with a canvas renderer and input layer derived from ghostty-web (vendored and maintained in this repo; see ADR 041 and ADR 047), and uses `@lydell/node-pty` for cross-platform PTY support. **Persona:** Developers who want shell or TUI app access from any browser tab. From 1524b08248609e7abe79d80e1a0dd3ed9173398e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:23:58 -0400 Subject: [PATCH 078/295] fix(config): add STIX Two Text last in the default font stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome draws a grapheme cluster in the first font that covers all of it, and Menlo has no enclosing marks, so a⃝ and 1⃝ drew a missing-glyph box. STIX Two Text, last in the stack, fills only clusters no earlier font covers: cell metrics and Menlo's glyphs are unchanged. STIX Two Math was measured too but draws its own glyphs in the Powerline/Nerd Font private use area, and STIXGeneral takes over about 2,000 math symbols. --- docs/specs/config.md | 12 +++++++++--- src/config.test.ts | 12 ++++++++++++ src/config.ts | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/specs/config.md b/docs/specs/config.md index a28e96c..09b3581 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -1,6 +1,6 @@ # SPEC: Config -**Last Updated:** 2026-10-13 +**Last Updated:** 2026-10-14 --- @@ -119,11 +119,17 @@ All keys are optional — omit any key to use the default value. | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | | `fontSize` | number | `13` | Font size in px | -| `fontFamily` | string | `"Menlo, Consolas, 'DejaVu Sans Mono', monospace"` | CSS font-family stack | +| `fontFamily` | string | `"Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'"` | CSS font-family stack. See [Default font stack](#default-font-stack). | | `theme` | object | Campbell | Terminal color palette — see theme keys below | | `keyboardBindings` | array | `[]` | Custom key-to-sequence bindings sent to the PTY. See [key-bindings spec](key-bindings.md) for schema and examples. | +### Default font stack + +Chrome draws a grapheme cluster in the first font of the stack that has every codepoint in it, or in a system fallback font. Menlo has no enclosing marks (U+20DD-U+20E0), so `a⃝` or `1⃝` came out as a missing-glyph box. `'STIX Two Text'` (shipped with macOS) has them and is added last, so it is used only for clusters no earlier font covers whole: the cell size, which is measured from the first available font, and every glyph Menlo or the system fallback drew before stay the same. The exceptions are about 120 letterlike symbols, modifier letters and ligatures Menlo lacks (`℃`, `₹`, `ℹ`, `ℓ`, `ℝ`, `ff`), which now come from STIX Two Text instead of the system fallback. It goes last rather than after Menlo because on Linux or Windows, where Menlo is missing, a STIX font installed there would otherwise become the cell font; where it is not installed, the name is skipped. `'STIX Two Math'` covers the same marks but also draws its own symbols in the Private Use Area that Powerline and Nerd Font glyphs use, so a missing-glyph box became an unrelated glyph; `STIXGeneral` takes over about 2,000 math symbols from the system fallback. Neither font covers U+20E3 (keycap) without VS16, so `1⃣` stays a box. + +A `fontFamily` written in `config.json` replaces the whole stack, so it keeps whatever it was set to; append `'STIX Two Text'` to it to get the marks. The first run writes only `port` and `host`, so a config without `fontFamily` gets the new default. + ### Theme keys All theme keys are optional; omitted keys fall back to the Campbell (Windows Terminal default) palette. @@ -172,7 +178,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "copyOnSelect": true, "rightClickBehavior": "default", "fontSize": 13, - "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace", + "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", "keyboardBindings": [ { "key": "enter", "mods": ["shift"], "chars": "\u001b\r" } diff --git a/src/config.test.ts b/src/config.test.ts index c7341c7..cab140e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -62,6 +62,18 @@ describe('loadConfig — first run', () => { expect(DEFAULT_CONFIG.term).toBe('xterm-256color'); }); + test('DEFAULT_CONFIG.fontFamily ends with STIX Two Text for enclosing marks', () => { + expect(DEFAULT_CONFIG.fontFamily).toBe( + "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + ); + }); + + test('first run leaves fontFamily out of the file, so the default applies', () => { + const config = loadConfig(); + expect(fs.readFileSync(configPath, 'utf8')).not.toContain('fontFamily'); + expect(config.fontFamily).toBe(DEFAULT_CONFIG.fontFamily); + }); + test('returns config that equals DEFAULT_CONFIG after first run', () => { const config = loadConfig(); expect(config.port).toBe(DEFAULT_CONFIG.port); diff --git a/src/config.ts b/src/config.ts index b1558f3..5b7f981 100644 --- a/src/config.ts +++ b/src/config.ts @@ -166,7 +166,9 @@ export const DEFAULT_CONFIG: Config = { cols: 80, rows: 24, fontSize: 13, - fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace", + // 'STIX Two Text' comes last: it fills only clusters no earlier font covers whole, + // such as a letter with an enclosing mark (a⃝, U+20DD), which Menlo lacks. + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", cursorStyle: 'bar', cursorStyleBlink: true, copyOnSelect: true, From 888e0abda3afea1656bbe16ebf82fa7dd69029e3 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:29:29 -0400 Subject: [PATCH 079/295] test(client): pin selection gestures before moving them to libghostty-vt --- src/client/vendor/selection-manager.test.ts | 160 ++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index 563f006..b002bd4 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -203,3 +203,163 @@ describe('SelectionManager text of wide characters', () => { t.free(); }); }); + +// Behaviour the selection keeps on any engine API: mouse gestures over rows, absolute +// rows across scrolling and output, and the word and line a click picks. +describe('SelectionManager gestures', () => { + const doc = globalThis.document; + const nav = globalThis.navigator; + let documentTarget: ReturnType; + let ghostty: Ghostty; + let writes: string[]; + + beforeEach(async () => { + documentTarget = target(); + globalThis.document = documentTarget as unknown as Document; + writes = []; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText: async (text: string) => void writes.push(text) } }, + }); + ghostty = await Ghostty.load(WASM); + }); + + afterEach(() => { + globalThis.document = doc; + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: nav }); + }); + + /** A manager over `wasmTerm` with 8x16 cells, scrolled up `viewportY()` lines. */ + function manager(wasmTerm: GhosttyTerminal, viewportY: () => number = () => 0) { + const canvas = { + ...target(), + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: wasmTerm.cols * 8, + bottom: wasmTerm.rows * 16, + }), + }; + const terminal = { + cols: wasmTerm.cols, + rows: wasmTerm.rows, + options: { copyOnSelect: true }, + getViewportY: viewportY, + scrollLines: () => {}, + } as unknown as Terminal; + const renderer = { + getCanvas: () => canvas, + getMetrics: () => ({ width: 8, height: 16 }), + scrollOffset: 0, + } as unknown as CanvasRenderer; + const m = new SelectionManager(terminal, renderer, wasmTerm, { + style: {}, + } as HTMLTextAreaElement); + return { m, canvas }; + } + + /** Drags from cell (c0, r0) to cell (c1, r1), in viewport rows, and releases. */ + function drag(canvas: ReturnType, c0: number, r0: number, c1: number, r1: number) { + canvas.dispatch('mousedown', { button: 0, offsetX: c0 * 8 + 1, offsetY: r0 * 16 + 4 }); + canvas.dispatch('mousemove', { offsetX: c1 * 8 + 1, offsetY: r1 * 16 + 4 }); + documentTarget.dispatch('mouseup', {}); + } + + const click = (canvas: ReturnType, detail: number, col: number, row: number) => + canvas.dispatch('click', { detail, offsetX: col * 8 + 1, offsetY: row * 16 + 4 }); + + test('a drag backwards selects the same text as forwards', () => { + const t = ghostty.createTerminal(20, 4); + t.write('hello world\r\nsecond line'); + const { m, canvas } = manager(t); + drag(canvas, 5, 1, 6, 0); + expect(m.getSelection()).toBe('world\nsecond'); + expect(m.getSelectionRange()).toEqual({ startCol: 6, startRow: 0, endCol: 5, endRow: 1 }); + t.free(); + }); + + test('a drag while scrolled selects scrollback rows, which stay selected at the bottom', () => { + const t = ghostty.createTerminal(10, 2); + t.write('one\r\ntwo\r\nthree\r\nfour'); + let viewportY = 2; + const { m, canvas } = manager(t, () => viewportY); + drag(canvas, 0, 0, 4, 1); + expect(m.getSelection()).toBe('one\ntwo'); + viewportY = 0; + expect(m.getSelection()).toBe('one\ntwo'); + expect(m.getSelectionRange()).toEqual({ startCol: 0, startRow: -2, endCol: 4, endRow: -1 }); + t.free(); + }); + + test('a selection keeps its text while output arrives and scrolls it into history', () => { + const t = ghostty.createTerminal(10, 3); + t.write('keep me\r\n'); + const { m, canvas } = manager(t); + drag(canvas, 0, 0, 6, 0); + t.write('a\r\nb\r\nc\r\nd'); + expect(t.getScrollbackLength()).toBe(2); + expect(m.getSelection()).toBe('keep me'); + expect(m.getSelectionRange()).toEqual({ startCol: 0, startRow: -2, endCol: 6, endRow: -2 }); + t.free(); + }); + + test('a double click selects a word with path and address characters', () => { + const t = ghostty.createTerminal(70, 2); + t.write('ls /usr/local/bin/foo.txt a@b.c x+y ~/q "quoted" (paren) $HOME'); + const { m, canvas } = manager(t); + const word = (col: number) => { + click(canvas, 2, col, 0); + return m.getSelection(); + }; + expect(word(8)).toBe('/usr/local/bin/foo.txt'); + expect(word(27)).toBe('a@b.c'); + expect(word(33)).toBe('x+y'); + expect(word(37)).toBe('~/q'); + expect(word(43)).toBe('quoted'); + expect(word(52)).toBe('paren'); + expect(word(59)).toBe('HOME'); + expect(writes).toEqual([ + '/usr/local/bin/foo.txt', + 'a@b.c', + 'x+y', + '~/q', + 'quoted', + 'paren', + 'HOME', + ]); + t.free(); + }); + + test('a double click on an empty cell selects nothing', () => { + const t = ghostty.createTerminal(20, 2); + t.write('word'); + const { m, canvas } = manager(t); + click(canvas, 2, 15, 0); + expect(m.hasSelection()).toBe(false); + expect(writes).toEqual([]); + t.free(); + }); + + test('a triple click selects the line without trailing spaces; an empty line selects nothing', () => { + const t = ghostty.createTerminal(20, 3); + t.write('one two \r\n\r\nthree'); + const { m, canvas } = manager(t); + click(canvas, 3, 15, 1); + expect(m.hasSelection()).toBe(false); + click(canvas, 3, 15, 0); + expect(m.getSelection()).toBe('one two'); + click(canvas, 3, 0, 2); + expect(m.getSelection()).toBe('three'); + expect(writes).toEqual(['one two', 'three']); + t.free(); + }); + + test('a triple click in scrollback selects that line', () => { + const t = ghostty.createTerminal(10, 2); + t.write('first\r\nsecond\r\nthird'); + const { m, canvas } = manager(t, () => 1); + click(canvas, 3, 2, 0); + expect(m.getSelection()).toBe('first'); + t.free(); + }); +}); From f883106066bf8eaacded9be9cf507999d4af6219 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:36:52 -0400 Subject: [PATCH 080/295] docs(client): ADR 050, selection words, lines and text from libghostty-vt --- ...050.client.selection-from-libghostty-vt.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/adrs/050.client.selection-from-libghostty-vt.md diff --git a/docs/adrs/050.client.selection-from-libghostty-vt.md b/docs/adrs/050.client.selection-from-libghostty-vt.md new file mode 100644 index 0000000..97af55d --- /dev/null +++ b/docs/adrs/050.client.selection-from-libghostty-vt.md @@ -0,0 +1,71 @@ +# ADR 050: client — Selection words, lines and text from libghostty-vt + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +`selection-manager.ts` (887 lines) was still almost all of Coder's ghostty-web code. ADR 041 and later ADRs fixed it in place: wide-character spacers (a1671fa), `copyOnSelect`, and selection rows outside the viewport for overscan (ADR 043). Three parts of it re-implement what a terminal engine knows better: + +- **Text.** `getSelection` read each row as cells (`getLine`, `getScrollbackLine`), looked up graphemes, skipped spacer cells, trimmed trailing spaces and put a newline after every row. It had no idea of soft wraps, so a long command line or URL that wrapped was copied with a newline in it. +- **Words.** A double click matched `[\w\-./~@+]` on one row. `\w` is ASCII only, so `café` selected `caf` and a CJK word selected nothing; `?`, `=`, `&` and `#` were boundaries, so a URL with a query was cut at the `?`; and a word that wrapped stopped at the row's edge. +- **Lines.** A triple click took column 0 to the last non-blank cell of one row, so a wrapped line selected only the row clicked. + +The pinned official wasm (Ghostty `05dcf887`, ADR 042) exports Ghostty's selection API (`include/ghostty/vt/selection.h`, `terminal.h`, `grid_ref.h`, `point.h`): + +- `GhosttySelection`: two `GhosttyGridRef` endpoints (inclusive, in either order) and a `rectangle` flag. +- `ghostty_terminal_grid_ref` resolves a `GhosttyPoint` (`ACTIVE`, `VIEWPORT`, `SCREEN` or `HISTORY` coordinates) to a grid reference, and `ghostty_terminal_point_from_grid_ref` goes back. +- `ghostty_terminal_select_word` (default or custom boundary codepoints), `ghostty_terminal_select_word_between`, `ghostty_terminal_select_line` (custom whitespace, optional semantic-prompt bounds), `ghostty_terminal_select_all` and `ghostty_terminal_select_output` derive a selection from a reference. +- `ghostty_terminal_selection_format_buf` / `_alloc` format a selection (plain, VT or HTML) with `unwrap` (join soft-wrapped rows) and `trim` (drop trailing whitespace). Plain with both set is Ghostty's own copy (`Screen.selectionString`). +- `GHOSTTY_TERMINAL_OPT_SELECTION` / `GHOSTTY_TERMINAL_DATA_SELECTION` install and read a terminal-owned selection, kept on tracked references; the render state reports it per row (`GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION`) and per cell (`…_CELLS_DATA_SELECTED`). +- `ghostty_terminal_selection_adjust`, `_order`, `_ordered`, `_contains` and `_equal` work on snapshots, and `ghostty_tracked_grid_ref_*` keeps a reference valid across scrolling, pruning and reflow. +- `ghostty_selection_gesture_*` is a pointer state machine: press, drag, release, autoscroll tick and deep press, with click counting by time and distance, cell, word, line or command-output behaviour per click count, and word- or line-granular drags. + +The manifest (`ghostty_type_json`) describes every struct and enum used here. + +## Decision + +Ghostty decides what a click selects and what a selection copies. webtty keeps the mouse handling and the selection state. + +- **Binding.** `GhosttyTerminal` gains `selectWord(cell)`, `selectLine(cell)` and `selectionText(start, end)`, with cells in screen rows (`ScreenCell`: row 0 is the oldest scrollback row, as Ghostty's `SCREEN` points). They call `ghostty_terminal_grid_ref` with `SCREEN` points, `ghostty_terminal_select_word` and `ghostty_terminal_select_line` with Ghostty's default boundaries and whitespace, `ghostty_terminal_point_from_grid_ref` for the result, and `ghostty_terminal_selection_format_alloc` with plain output, `unwrap` and `trim`. Struct offsets come from the manifest, as in ADR 041. `selectionText` clamps endpoints to the screen, so a selection whose rows have left history copies what is left. +- **SelectionManager.** Its absolute rows were already scrollback length plus active row, which is Ghostty's screen row, so the state, the drag, the auto-scroll and the renderer's range (ADR 043) are unchanged. `getSelection` calls `selectionText`. A double click calls `selectWord`, a triple click `selectLine`, at the cell under the pointer; when Ghostty finds nothing (an empty cell, a blank line), nothing is selected, as before. `getWordAtCell`, the per-row text builder with its spacer and wide-character rules, and the triple-click row scan are deleted (186 lines; the file is 722 lines). + +Kept as before: drag with its half-cell threshold, double and triple click, `copyOnSelect` copying once per gesture, selections made while scrolled and in scrollback, selections kept while output arrives and cleared on typing, a font change, a new press and a click outside (the current rules), the right-click `copyPaste` path of ADR 011 (it reads `getSelection`), and the highlight drawn by `renderer.ts` from `getSelectionRange`. + +## Consequences + +Behaviour that now follows Ghostty: + +- **Soft wraps are joined.** A selection across a soft-wrapped row copies one line: a wrapped command or URL pastes whole. Hard newlines stay. +- **Words.** Boundaries are whitespace and `` ' " │ ` | : ; , ( ) [ ] { } < > $ `` (Ghostty's default `selection-word-chars`, checked on the pinned wasm); every other character, non-ASCII letters included, is part of a word. A URL with `?`, `=`, `&` or `#` selects whole, `café` and `中文字` select whole, and a word that wraps selects across the wrap. Before, only `[A-Za-z0-9_\-./~@+]` counted. +- **Double click on whitespace** selects the run of whitespace and copies nothing (it trims to empty), as in Ghostty. Before, it selected nothing. +- **Triple click** selects the whole logical line across soft wraps, without leading or trailing whitespace. Before, it selected one row from column 0, indent included. +- **Wide characters.** Ghostty skips spacers and takes the whole character from either half, as a1671fa did. A selection that ends on the spacer at the end of a row, where a wide character did not fit and wrapped, now copies that character. +- **Work per copy.** The text is built in one wasm call instead of decoding every selected row into JS cells; a click resolves one grid reference and reads back two. + +Not changed, and why: + +- **Highlight state stays in JS.** The renderer draws the selection over overscan rows outside the viewport (ADR 043), in canvas rows. The binding keeps Ghostty's viewport at the bottom and reads scrollback through a second render state a page at a time, so the render state's per-row selection covers only the active screen. Reading it would add a call per row per frame for nothing the range does not already give. +- **The terminal-owned selection and tracked references are not used.** Absolute rows drift by the rows pruned once history is full (10,000 lines by default, ADR 046): a selection held while a full scrollback keeps growing moves up with the text. This is not new: the old code used the same rows. Tracked references (`ghostty_terminal_grid_ref_track`) would fix it. Installing the selection in the terminal would also hand its lifetime to Ghostty, which would have to be checked against the clearing rules above. Left as a follow-up. +- **The gesture state machine is not used.** The browser already counts clicks (`MouseEvent.detail`) with the OS settings, and the manager's DOM handling (document-level drag, auto-scroll, outside clicks, the context menu) would stay. It would add word- and line-granular drags after a double or triple click, which webtty does not have; that can be a later ADR. + +Size: `selection-manager.ts` loses 186 lines of Coder code and gains 21; the binding gains about 190 lines, most of them layout and comments. + +## Considered Options + +- **Words, lines and text from Ghostty, state and highlight in JS (chosen).** Replaces the parts that encode terminal knowledge, keeps the renderer and the gesture code that webtty has already fixed. +- **Install the selection in the terminal and render from Ghostty's state.** Fixes the pruning drift, but needs the renderer to read selection rows from tracked references every frame, and moves the clearing rules to Ghostty. +- **Use `ghostty_selection_gesture_*` for the whole gesture.** Removes the click counting and threshold, adds granular drags, but re-feeds time and distance the browser already resolved, and most of the manager is DOM code that stays either way. +- **Keep the TS code and fix it.** Unicode word characters, soft-wrap joining and wrapped lines would each re-implement part of Ghostty's `Screen.selectWord`, `selectLine` and `selectionString`. + +## Related Decisions + +- [ADR 011 — Config, help, and UX polish](011.cli.config-and-help.md) +- [ADR 041 — Own the terminal layer on official libghostty-vt](041.client.libghostty-vt.md) +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md) +- [ADR 043 — Copy rows on scroll, then overscan](043.client.scroll-copy-and-overscan.md) +- [ADR 046 — Scrollback in lines](046.client.scrollback-lines.md) +- [ADR 049 — Mouse reports, cursor style and paste through libghostty-vt](049.client.official-mouse-cursor-paste.md) From 24cb2d98dc04b4befde94eae8316e9386750f892 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:36:52 -0400 Subject: [PATCH 081/295] feat(client): bind Ghostty's word, line and selection text calls --- src/client/vendor/ghostty-web/ghostty.ts | 193 +++++++++++++++++++- src/client/vendor/official-terminal.test.ts | 62 +++++++ 2 files changed, 250 insertions(+), 5 deletions(-) diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 358a8bc..982cc48 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -66,6 +66,21 @@ export interface MouseGeometry { paddingLeft: number; } +/** + * A cell by screen row (ADR 050): row 0 is the oldest scrollback row and + * `getScrollbackLength()` the top row of the active screen, as Ghostty's `SCREEN` points. + */ +export interface ScreenCell { + col: number; + row: number; +} + +/** A range of cells in screen rows, both ends inclusive. */ +export interface ScreenRange { + start: ScreenCell; + end: ScreenCell; +} + /** Bell and title changes since the last `readEffects` (ADR 042). */ export interface TerminalEffects { bell: boolean; @@ -125,6 +140,17 @@ export interface GhosttyTerminal { getGraphemeString(row: number, col: number): string; getScrollbackGraphemeString(offset: number, col: number): string; + // Selection (ADR 050), in screen rows on the active screen + /** The word at a cell by Ghostty's word boundaries, soft wraps followed; null if empty. */ + selectWord(cell: ScreenCell): ScreenRange | null; + /** The line at a cell, soft wraps followed and outer whitespace left out; null when blank. */ + selectLine(cell: ScreenCell): ScreenRange | null; + /** + * The text from `start` to `end` (inclusive, in either order) as Ghostty copies it: + * soft-wrapped rows joined, trailing whitespace trimmed, spacer cells skipped. + */ + selectionText(start: ScreenCell, end: ScreenCell): string; + // Mouse reports (ADR 049) /** The geometry `encodeMouse` positions are in. */ setMouseGeometry(geometry: MouseGeometry): void; @@ -209,6 +235,18 @@ interface OfficialWasmExports { /** The GhosttyPoint struct is passed by value, i.e. by pointer on wasm32. */ ghostty_terminal_grid_ref(term: number, point: number, outRef: number): number; ghostty_grid_ref_row(ref: number, outRow: number): number; + ghostty_terminal_point_from_grid_ref(term: number, ref: number, tag: number, out: number): number; + ghostty_terminal_select_word(term: number, options: number, outSelection: number): number; + ghostty_terminal_select_line(term: number, options: number, outSelection: number): number; + /** GhosttyTerminalSelectionFormatOptions is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_selection_format_alloc( + term: number, + allocator: number, + options: number, + outPtr: number, + outLen: number + ): number; + ghostty_free(allocator: number, ptr: number, len: number): void; ghostty_grid_ref_hyperlink_uri(ref: number, buf: number, len: number, outLen: number): number; /** GhosttyRow is a u64 passed by value (a BigInt on the JS side). */ ghostty_row_get(row: bigint, data: number, out: number): number; @@ -584,11 +622,37 @@ function resolveOfficialLayout(layout: VtLayout) { y: layout.offset('GhosttyPointCoordinate', 'y'), ACTIVE: layout.value('GhosttyPointTag', 'ACTIVE'), HISTORY: layout.value('GhosttyPointTag', 'HISTORY'), + SCREEN: layout.value('GhosttyPointTag', 'SCREEN'), }, gridRef: { size: layout.size('GhosttyGridRef'), sizeField: layout.offset('GhosttyGridRef', 'size'), }, + selection: { + size: layout.size('GhosttySelection'), + sizeField: layout.offset('GhosttySelection', 'size'), + start: layout.offset('GhosttySelection', 'start'), + end: layout.offset('GhosttySelection', 'end'), + word: { + size: layout.size('GhosttyTerminalSelectWordOptions'), + sizeField: layout.offset('GhosttyTerminalSelectWordOptions', 'size'), + ref: layout.offset('GhosttyTerminalSelectWordOptions', 'ref'), + }, + line: { + size: layout.size('GhosttyTerminalSelectLineOptions'), + sizeField: layout.offset('GhosttyTerminalSelectLineOptions', 'size'), + ref: layout.offset('GhosttyTerminalSelectLineOptions', 'ref'), + }, + format: { + size: layout.size('GhosttyTerminalSelectionFormatOptions'), + sizeField: layout.offset('GhosttyTerminalSelectionFormatOptions', 'size'), + emit: layout.offset('GhosttyTerminalSelectionFormatOptions', 'emit'), + unwrap: layout.offset('GhosttyTerminalSelectionFormatOptions', 'unwrap'), + trim: layout.offset('GhosttyTerminalSelectionFormatOptions', 'trim'), + selection: layout.offset('GhosttyTerminalSelectionFormatOptions', 'selection'), + }, + PLAIN: layout.value('GhosttyFormatterFormat', 'PLAIN'), + }, string: { size: layout.size('GhosttyString'), ptr: layout.offset('GhosttyString', 'ptr'), @@ -1021,6 +1085,11 @@ export class OfficialTerminal implements GhosttyTerminal { | 'scroll' | 'point' | 'gridRef' + | 'sel' + | 'selOpts' + | 'coord' + | 'outPtr' + | 'outLen' | 'row64' | 'str' | 'mode' @@ -1141,6 +1210,14 @@ export class OfficialTerminal implements GhosttyTerminal { scroll: [L.scroll.size, 8], point: [L.point.size, 8], gridRef: [L.gridRef.size, 8], + sel: [L.selection.size, 8], + selOpts: [ + Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), + 8, + ], + coord: [8, 8], + outPtr: [4, 4], + outLen: [4, 4], row64: [8, 8], str: [L.string.size, 8], mode: [L.mode.size, 8], @@ -1437,6 +1514,64 @@ export class OfficialTerminal implements GhosttyTerminal { return this.graphemeOf(this.rowAt(offset), col); } + // ========================================================================== + // Selection (ADR 050) + // ========================================================================== + + selectWord(cell: ScreenCell): ScreenRange | null { + const W = this.L.selection.word; + return this.selectAt(W, cell, (opts, out) => + this.ex.ghostty_terminal_select_word(this.term, opts, out) + ); + } + + selectLine(cell: ScreenCell): ScreenRange | null { + const W = this.L.selection.line; + return this.selectAt(W, cell, (opts, out) => + this.ex.ghostty_terminal_select_line(this.term, opts, out) + ); + } + + selectionText(start: ScreenCell, end: ScreenCell): string { + const ex = this.ex; + const at = this.at; + const S = this.L.selection; + const F = S.format; + this.info(); + // Rows that left history, or a selection made before a resize, are clamped. + const last = this.sbRows + this._rows - 1; + const clamp = (c: ScreenCell): ScreenCell => ({ + col: Math.max(0, Math.min(c.col, this._cols - 1)), + row: Math.max(0, Math.min(c.row, last)), + }); + this.clearSelectionStruct(); + if (!this.screenRef(clamp(start), at.sel + S.start)) return ''; + if (!this.screenRef(clamp(end), at.sel + S.end)) return ''; + this.u8().fill(0, at.selOpts, at.selOpts + F.size); + const dv = this.dv(); + dv.setUint32(at.selOpts + F.sizeField, F.size, true); + dv.setInt32(at.selOpts + F.emit, S.PLAIN, true); + // Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed. + dv.setUint8(at.selOpts + F.unwrap, 1); + dv.setUint8(at.selOpts + F.trim, 1); + dv.setUint32(at.selOpts + F.selection, at.sel, true); + dv.setUint32(at.outPtr, 0, true); + dv.setUint32(at.outLen, 0, true); + const rc = ex.ghostty_terminal_selection_format_alloc( + this.term, + 0, + at.selOpts, + at.outPtr, + at.outLen + ); + const ptr = this.dv().getUint32(at.outPtr, true); + const len = this.dv().getUint32(at.outLen, true); + if (rc !== 0 || !ptr) return ''; + const text = new TextDecoder().decode(this.u8().slice(ptr, ptr + len)); + ex.ghostty_free(0, ptr, len); + return text; + } + // ========================================================================== // Mouse reports (ADR 049) // ========================================================================== @@ -2255,8 +2390,8 @@ export class OfficialTerminal implements GhosttyTerminal { // Private: single-cell lookups (grid_ref) // ========================================================================== - /** Resolves a point to a grid reference at `at.gridRef`. */ - private gridRef(tag: number, x: number, y: number): boolean { + /** Resolves a point to a grid reference at `out` (by default `at.gridRef`). */ + private gridRef(tag: number, x: number, y: number, out: number = this.at.gridRef): boolean { const L = this.L; const at = this.at; const P = L.point; @@ -2265,9 +2400,57 @@ export class OfficialTerminal implements GhosttyTerminal { dv.setInt32(at.point + P.tag, tag, true); dv.setUint16(at.point + P.value + P.x, x, true); dv.setUint32(at.point + P.value + P.y, y, true); - this.u8().fill(0, at.gridRef, at.gridRef + L.gridRef.size); - dv.setUint32(at.gridRef + L.gridRef.sizeField, L.gridRef.size, true); - return this.ex.ghostty_terminal_grid_ref(this.term, at.point, at.gridRef) === 0; + this.u8().fill(0, out, out + L.gridRef.size); + dv.setUint32(out + L.gridRef.sizeField, L.gridRef.size, true); + return this.ex.ghostty_terminal_grid_ref(this.term, at.point, out) === 0; + } + + // ========================================================================== + // Private: selection (ADR 050) + // ========================================================================== + + private screenRef(cell: ScreenCell, out: number): boolean { + if (cell.col < 0 || cell.col >= this._cols || cell.row < 0) return false; + return this.gridRef(this.L.point.SCREEN, cell.col, cell.row, out); + } + + /** The screen cell of the grid reference at `ref`, or null if it has none. */ + private screenCellOf(ref: number): ScreenCell | null { + const at = this.at; + const P = this.L.point; + const rc = this.ex.ghostty_terminal_point_from_grid_ref(this.term, ref, P.SCREEN, at.coord); + if (rc !== 0) return null; + const dv = this.dv(); + return { col: dv.getUint16(at.coord + P.x, true), row: dv.getUint32(at.coord + P.y, true) }; + } + + /** Zeroes the GhosttySelection at `at.sel` and sets its size field. */ + private clearSelectionStruct(): void { + const S = this.L.selection; + this.u8().fill(0, this.at.sel, this.at.sel + S.size); + this.dv().setUint32(this.at.sel + S.sizeField, S.size, true); + } + + /** + * Runs a `select_*` call whose options are a sized struct with one grid reference, + * set to `cell`, and returns the selection it derives in screen cells. + */ + private selectAt( + options: { size: number; sizeField: number; ref: number }, + cell: ScreenCell, + call: (options: number, outSelection: number) => number + ): ScreenRange | null { + const at = this.at; + const S = this.L.selection; + this.info(); + this.u8().fill(0, at.selOpts, at.selOpts + options.size); + this.dv().setUint32(at.selOpts + options.sizeField, options.size, true); + if (!this.screenRef(cell, at.selOpts + options.ref)) return null; + this.clearSelectionStruct(); + if (call(at.selOpts, at.sel) !== 0) return null; + const start = this.screenCellOf(at.sel + S.start); + const end = this.screenCellOf(at.sel + S.end); + return start && end ? { start, end } : null; } private hyperlinkAt(tag: number, x: number, y: number): string | null { diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 714e616..d94b4b1 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -973,3 +973,65 @@ describe('official engine: paste (ADR 049)', () => { t.free(); }); }); + +describe('official engine: selection (ADR 050)', () => { + test('selectWord uses Ghostty word boundaries and follows soft wraps', () => { + const t = term(10, 4); + t.write('a=b?c&d x\r\nab "cd" e\r\nx foo/bar/baz'); + const at = (col: number, row: number) => t.selectWord({ col, row }); + expect(at(2, 0)).toEqual({ start: { col: 0, row: 0 }, end: { col: 6, row: 0 } }); + expect(at(4, 1)).toEqual({ start: { col: 4, row: 1 }, end: { col: 5, row: 1 } }); + // "foo/bar/baz" wraps after column 9: the word runs on into the next row + expect(at(3, 2)).toEqual({ start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }); + expect(at(1, 3)).toEqual({ start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }); + expect(at(9, 3)).toBeNull(); + t.free(); + }); + + test('selectWord and selectLine take screen rows, scrollback included', () => { + const t = term(10, 2); + t.write('first one\r\nsecond\r\nthird'); + expect(t.getScrollbackLength()).toBe(1); + expect(t.selectWord({ col: 1, row: 0 })).toEqual({ + start: { col: 0, row: 0 }, + end: { col: 4, row: 0 }, + }); + expect(t.selectLine({ col: 0, row: 2 })).toEqual({ + start: { col: 0, row: 2 }, + end: { col: 4, row: 2 }, + }); + expect(t.selectionText({ col: 0, row: 0 }, { col: 9, row: 2 })).toBe( + 'first one\nsecond\nthird', + ); + t.free(); + }); + + test('selectLine follows soft wraps and leaves out outer whitespace; a blank line is null', () => { + const t = term(6, 4); + t.write(' abcdefgh \r\n\r\nx'); + expect(t.selectLine({ col: 1, row: 1 })).toEqual({ + start: { col: 2, row: 0 }, + end: { col: 3, row: 1 }, + }); + expect(t.selectLine({ col: 0, row: 2 })).toBeNull(); + t.free(); + }); + + test('selectionText joins soft-wrapped rows, trims, takes either order and clamps', () => { + const t = term(5, 3); + t.write('abcdefg\r\nhi '); + expect(t.selectionText({ col: 0, row: 0 }, { col: 4, row: 2 })).toBe('abcdefg\nhi'); + expect(t.selectionText({ col: 4, row: 2 }, { col: 0, row: 0 })).toBe('abcdefg\nhi'); + expect(t.selectionText({ col: -3, row: -1 }, { col: 99, row: 99 })).toBe('abcdefg\nhi'); + expect(t.selectionText({ col: 3, row: 1 }, { col: 4, row: 1 })).toBe(''); + t.free(); + }); + + test('selectionText reads the alternate screen while it is active', () => { + const t = term(10, 2); + t.write('a\r\nb\r\nc\x1b[?1049h\x1b[Halt'); + expect(t.getScrollbackLength()).toBe(0); + expect(t.selectionText({ col: 0, row: 0 }, { col: 9, row: 0 })).toBe('alt'); + t.free(); + }); +}); From b524b69f55a9ebc5e219f5f0728cc2e5a04b66fc Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:36:52 -0400 Subject: [PATCH 082/295] feat(client): select words and lines and copy text through libghostty-vt --- .../vendor/ghostty-web/selection-manager.ts | 207 ++---------------- src/client/vendor/selection-manager.test.ts | 57 ++++- 2 files changed, 73 insertions(+), 191 deletions(-) diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 479426b..8ebdfdd 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -3,8 +3,8 @@ * * Features: * - Mouse drag selection - * - Double-click word selection - * - Text extraction from terminal buffer + * - Double-click word and triple-click line selection (Ghostty's, ADR 050) + * - Text extraction (Ghostty's, ADR 050) * - Clipboard copy on select (the terminal's copyOnSelect option) * - Visual selection highlighting (integrated into CanvasRenderer cell rendering) * - Auto-scroll during drag selection @@ -15,7 +15,6 @@ import type { GhosttyTerminal } from './ghostty'; import type { IEvent } from './interfaces'; import type { CanvasRenderer } from './renderer'; import type { Terminal } from './terminal'; -import type { GhosttyCell } from './types'; // ============================================================================ // Type Definitions @@ -118,95 +117,14 @@ export class SelectionManager { */ getSelection(): string { if (!this.selectionStart || !this.selectionEnd) return ''; - - // Get absolute row coordinates (not clamped to viewport) - let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; - let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; - - // Swap if selection goes backwards - if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { - [startCol, endCol] = [endCol, startCol]; - [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; - } - - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - let text = ''; - - for (let absRow = startAbsRow; absRow <= endAbsRow; absRow++) { - // Fetch line based on absolute row position - // Absolute row < scrollbackLength means it's in scrollback - // Absolute row >= scrollbackLength means it's in the screen buffer - let line: GhosttyCell[] | null = null; - - if (absRow < scrollbackLength) { - // Row is in scrollback - line = this.wasmTerm.getScrollbackLine(absRow); - } else { - // Row is in screen buffer - const screenRow = absRow - scrollbackLength; - line = this.wasmTerm.getLine(screenRow); - } - - if (!line) continue; - - // Track the last non-empty column for trimming trailing spaces - let lastNonEmpty = -1; - - // Determine column range for this row - let colStart = absRow === startAbsRow ? startCol : 0; - const colEnd = absRow === endAbsRow ? endCol : line.length - 1; - // A selection that starts on the right half of a wide character takes all of it - if (colStart > 0 && line[colStart]?.width === 0 && line[colStart - 1]?.width === 2) { - colStart--; - } - - // Build the line text - let lineText = ''; - for (let col = colStart; col <= colEnd; col++) { - const cell = line[col]; - // A spacer (width 0) holds nothing: the cell after a wide character, or the - // last column when a wide character wrapped to the next row. (webtty: upstream - // copied it as a space, so "中文" came out as "中 文".) - if (cell && cell.width === 0) continue; - if (cell && cell.codepoint !== 0) { - // Use grapheme lookup for cells with multi-codepoint characters - let char: string; - if (cell.grapheme_len > 0) { - // Row is in scrollback or screen - determine which and use appropriate method - if (absRow < scrollbackLength) { - char = this.wasmTerm.getScrollbackGraphemeString(absRow, col); - } else { - const screenRow = absRow - scrollbackLength; - char = this.wasmTerm.getGraphemeString(screenRow, col); - } - } else { - char = String.fromCodePoint(cell.codepoint); - } - lineText += char; - if (char.trim()) { - lastNonEmpty = lineText.length; - } - } else { - lineText += ' '; - } - } - - // Trim trailing spaces from each line - if (lastNonEmpty >= 0) { - lineText = lineText.substring(0, lastNonEmpty); - } else { - lineText = ''; - } - - text += lineText; - - // Add newline between rows (but not after the last row) - if (absRow < endAbsRow) { - text += '\n'; - } - } - - return text; + // Absolute rows are Ghostty's screen rows. Ghostty builds the text as it copies: + // soft-wrapped rows joined, trailing spaces trimmed, spacer cells skipped. (ADR 050) + const { col: startCol, absoluteRow: startRow } = this.selectionStart; + const { col: endCol, absoluteRow: endRow } = this.selectionEnd; + return this.wasmTerm.selectionText( + { col: startCol, row: startRow }, + { col: endCol, row: endRow } + ); } /** @@ -479,59 +397,18 @@ export class SelectionManager { }; document.addEventListener('mouseup', this.boundMouseUpHandler); - // Handle click events for double-click (word) and triple-click (line) selection - // Use event.detail which browsers set to click count (1, 2, 3, etc.) + // Double click selects a word and triple click a line, by Ghostty's rules (ADR 050). + // event.detail is the browser's click count. canvas.addEventListener('click', (e: MouseEvent) => { - // event.detail: 1 = single, 2 = double, 3 = triple click - if (e.detail === 2) { - // Double-click - select word - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const word = this.getWordAtCell(cell.col, cell.row); - - if (word) { - const absoluteRow = this.viewportRowToAbsolute(cell.row); - this.selectionStart = { col: word.startCol, absoluteRow }; - this.selectionEnd = { col: word.endCol, absoluteRow }; - this.requestRender(); - this.selectionMade(); - } - } else if (e.detail >= 3) { - // Triple-click (or more) - select line content (like native Ghostty) - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const absoluteRow = this.viewportRowToAbsolute(cell.row); - - // Find actual line length (exclude trailing empty cells) - // Use scrollback-aware line retrieval (like getSelection does) - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - let line: GhosttyCell[] | null = null; - if (absoluteRow < scrollbackLength) { - // Row is in scrollback - line = this.wasmTerm.getScrollbackLine(absoluteRow); - } else { - // Row is in screen buffer - const screenRow = absoluteRow - scrollbackLength; - line = this.wasmTerm.getLine(screenRow); - } - // Find last non-empty cell (-1 means empty line) - let endCol = -1; - if (line) { - for (let i = line.length - 1; i >= 0; i--) { - if (line[i] && line[i].codepoint !== 0 && line[i].codepoint !== 32) { - endCol = i; - break; - } - } - } - - // Only select if line has content (endCol >= 0) - if (endCol >= 0) { - // Select line content only (not trailing whitespace) - this.selectionStart = { col: 0, absoluteRow }; - this.selectionEnd = { col: endCol, absoluteRow }; - this.requestRender(); - this.selectionMade(); - } - } + if (e.detail < 2) return; + const cell = this.pixelToCell(e.offsetX, e.offsetY); + const at = { col: cell.col, row: this.viewportRowToAbsolute(cell.row) }; + const range = e.detail === 2 ? this.wasmTerm.selectWord(at) : this.wasmTerm.selectLine(at); + if (!range) return; + this.selectionStart = { col: range.start.col, absoluteRow: range.start.row }; + this.selectionEnd = { col: range.end.col, absoluteRow: range.end.row }; + this.requestRender(); + this.selectionMade(); }); // Right-click (context menu) - position textarea to show browser's native menu @@ -725,48 +602,6 @@ export class SelectionManager { }; } - /** - * Get word boundaries at a cell position - */ - private getWordAtCell(col: number, row: number): { startCol: number; endCol: number } | null { - const absoluteRow = this.viewportRowToAbsolute(row); - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - let line: GhosttyCell[] | null; - if (absoluteRow < scrollbackLength) { - line = this.wasmTerm.getScrollbackLine(absoluteRow); - } else { - const screenRow = absoluteRow - scrollbackLength; - line = this.wasmTerm.getLine(screenRow); - } - if (!line) return null; - - // Word characters: letters, numbers, and common path/URL characters - // Matches native Ghostty behavior where double-click selects entire paths - // Includes: / (path sep), . (extensions), ~ (home), @ (emails), + (encodings) - const isWordChar = (cell: GhosttyCell) => { - if (!cell || cell.codepoint === 0) return false; - const char = String.fromCodePoint(cell.codepoint); - return /[\w\-./~@+]/.test(char); - }; - - // Only return if we're actually on a word character - if (!isWordChar(line[col])) return null; - - // Find start of word - let startCol = col; - while (startCol > 0 && isWordChar(line[startCol - 1])) { - startCol--; - } - - // Find end of word - let endCol = col; - while (endCol < line.length - 1 && isWordChar(line[endCol + 1])) { - endCol++; - } - - return { startCol, endCol }; - } - /** * A mouse selection is made: copy it when the terminal's copyOnSelect is on, building * its text once, and report the change. (webtty) diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index b002bd4..44d39be 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -181,17 +181,20 @@ describe('SelectionManager text of wide characters', () => { t.free(); }); - test('copies no space for a spacer at the last column', () => { - // A wide character that does not fit wraps, leaving a spacer at the last column + test('copies no space for a spacer at the last column, and joins the soft wrap', () => { + // A wide character that does not fit wraps, leaving a spacer at the last column. + // Ghostty joins soft-wrapped rows without a newline (ADR 050). const wrapped = ghostty.createTerminal(5, 4); wrapped.write('abcd中ef'); - expect(selected(wrapped, [0, 0], [4, 1])).toBe('abcd\n中ef'); - expect(selected(wrapped, [2, 0], [4, 0])).toBe('cd'); + expect(selected(wrapped, [0, 0], [4, 1])).toBe('abcd中ef'); + expect(selected(wrapped, [2, 0], [3, 0])).toBe('cd'); + // Ending on the spacer takes the wide character it stands for (Ghostty's formatter) + expect(selected(wrapped, [2, 0], [4, 0])).toBe('cd中'); wrapped.free(); // A wide character ending at the last column const fits = ghostty.createTerminal(6, 4); fits.write('abcd中ef'); - expect(selected(fits, [3, 0], [1, 1])).toBe('d中\nef'); + expect(selected(fits, [3, 0], [1, 1])).toBe('d中ef'); fits.free(); }); @@ -362,4 +365,48 @@ describe('SelectionManager gestures', () => { expect(m.getSelection()).toBe('first'); t.free(); }); + + // Ghostty's selection rules (ADR 050) + + test('a selection across a soft wrap copies one line; a hard newline stays', () => { + const t = ghostty.createTerminal(10, 4); + t.write('0123456789abcdef\r\nnext'); + const { m, canvas } = manager(t); + drag(canvas, 0, 0, 3, 2); + expect(m.getSelection()).toBe('0123456789abcdef\nnext'); + t.free(); + }); + + test('a double click selects a word that wraps onto the next row', () => { + const t = ghostty.createTerminal(10, 3); + t.write('cd /usr/local/bin'); + const { m, canvas } = manager(t); + click(canvas, 2, 1, 1); + expect(m.getSelection()).toBe('/usr/local/bin'); + expect(m.getSelectionRange()).toEqual({ startCol: 3, startRow: 0, endCol: 6, endRow: 1 }); + t.free(); + }); + + test("a double click selects a URL query and non-ASCII words by Ghostty's word characters", () => { + const t = ghostty.createTerminal(40, 2); + t.write('see //x.io/a?b=c&d#e or café 中文字'); + const { m, canvas } = manager(t); + click(canvas, 2, 8, 0); + expect(m.getSelection()).toBe('//x.io/a?b=c&d#e'); + click(canvas, 2, 24, 0); + expect(m.getSelection()).toBe('café'); + click(canvas, 2, 30, 0); + expect(m.getSelection()).toBe('中文字'); + t.free(); + }); + + test('a triple click selects a soft-wrapped line whole, without its indent', () => { + const t = ghostty.createTerminal(8, 3); + t.write(' wrapped line\r\nx'); + const { m, canvas } = manager(t); + click(canvas, 3, 0, 1); + expect(m.getSelection()).toBe('wrapped line'); + expect(m.getSelectionRange()).toEqual({ startCol: 2, startRow: 0, endCol: 5, endRow: 1 }); + t.free(); + }); }); From 8f7761235328b8b303a4c31abfb0946428578c24 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 03:37:12 -0400 Subject: [PATCH 083/295] docs(client): record the selection move to libghostty-vt in the spec and provenance --- docs/specs/client.md | 5 ++++- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 7f9bbca..b7690eb 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,6 +1,6 @@ # SPEC: Client -**Last Updated:** 2026-10-13 +**Last Updated:** 2026-10-14 --- @@ -157,6 +157,8 @@ Controlled by two config keys from `GET /api/config`: Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these are the only reliable copy paths. +What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). + ## Behavior Notes ### Tab close on session end @@ -192,6 +194,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Official engine only | Every tab runs Ghostty's official libghostty-vt wasm; the Coder engine, its wasm, `/ghostty-vt.wasm` and `?engine` are removed without a release on an official default, after ADR 042's acceptance record and a manual check in Chrome, Firefox and Safari. The `kitty.ts` size replies, the stream-scanned bell and title, `colorsSet` and the `scrollback`-derived history go with it; history is always `scrollbackLines`. Cursor style, mouse and paste encoders moved to the official APIs in ADR 049; theme colours from render state remain a follow-up | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 042](../adrs/042.client.official-vt-engine.md), [ADR 047](../adrs/047.client.official-engine-only.md) | ✅ | | Scrollback in lines | Terminal history keeps the configured number of lines (`scrollbackLines`, default 10,000, a little under from page pruning) up to a 32 MiB ceiling, instead of a few hundred rows | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 046](../adrs/046.client.scrollback-lines.md) | ✅ | | Mouse, cursor style and paste from libghostty-vt | Mouse reports (clicks, motion, drags past the edge, wheel ticks) are encoded by Ghostty's mouse encoder in every tracking mode and format, with Alt and buttons 8/9, at a device-pixel geometry that includes the padding; the cursor shape and blink come from Ghostty's state, with the config as its default; pastes go through `ghostty_terminal_paste` (bracketed whenever mode 2004 is on, control bytes stripped). X10-format reports past column 95 are dropped: they cannot travel as text frames | [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) |✅ | +| Selection from libghostty-vt | A double click selects Ghostty's word (URLs with queries, non-ASCII words, across a soft wrap), a triple click the whole logical line without its indent, and copied text is Ghostty's: soft-wrapped rows joined without a newline, trailing whitespace trimmed, wide characters whole. Drag, `copyOnSelect`, scrollback selection and the highlight are unchanged | [ADR 050](../adrs/050.client.selection-from-libghostty-vt.md) | ✅ | | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 224566c..38575cd 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -28,7 +28,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. - `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). - `input-handler.ts`: the paste path is removed (the `paste` listeners, `handlePaste`, the `insertFromPaste` case of `handleBeforeInput`, `emitPasteData` and the paste de-duplication). It never ran: webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no `insertFromPaste`. -- `selection-manager.ts`: `getSelection` skips spacer cells (width 0), the cell after a wide character and the last column a wide character wrapped from, which upstream copied as a space ("中 文 字"); a selection that starts on the right half of a wide character takes the whole character. +- `selection-manager.ts`, `ghostty.ts`: Ghostty picks and copies the selection (ADR 050). The binding adds `selectWord`, `selectLine` and `selectionText` on screen rows (`ghostty_terminal_grid_ref` with `SCREEN` points, `ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_point_from_grid_ref`, and `ghostty_terminal_selection_format_alloc` with plain output, `unwrap` and `trim`). A double click selects Ghostty's word and a triple click its line, both across soft wraps, and `getSelection` returns Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed, spacer cells skipped, a wide character taken whole from either half. Upstream's `getWordAtCell` (`[\w\-./~@+]` on one row), its triple-click row scan and its per-row text builder are deleted (186 lines), with webtty's earlier spacer fix to that builder (upstream copied "中文" as "中 文"). - `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the new `copyOnSelect` option (default true). A drag, double or triple click copies the selection only while it is on, building the text once (`selectionMade`); upstream always copied, so webtty's own copy on `onSelectionChange` wrote the clipboard a second time and `copyOnSelect: false` still copied. `Terminal.onSelectionChange`, which only that copy used, is removed. - `glide.ts`, `wheel.ts` (new), `terminal.ts`, `interfaces.ts`: wheel input to apps (ADR 045). `wheel.ts` holds the wheel helpers for apps: `wheelTicks`, `wheelArrows` (on the alternate screen without mouse tracking, one arrow key per line of travel, keeping the remainder, instead of `round(deltaY / 33)` per event capped at 5) and `wheelArrowKey` (`ESC O A/B` under application cursor keys, which `less` sets), and `TickPacer`, which spreads an event's ticks over time at the `Pace` set through the new `attachWheelPace` instead of sending them in one burst. `glide.ts` is the motion both `TickPacer` and webtty's scrollback glide follow (`Glide`, `springStep`). The render loop calls the callback set by the new `attachBeforeRender` before it renders. Upstream's own eased normal-screen wheel scrolling (`smoothScrollTo`, `animateScroll`) and its `smoothScrollDuration` option are removed: webtty's custom wheel handler scrolls the normal screen, and that path left `viewportY` fractional. - `interfaces.ts`, `types.ts`, `terminal.ts`, `ghostty.ts`: the `scrollbackLines` option (ADR 046, ADR 047). The terminal passes it in `GhosttyTerminalConfig.scrollbackLines`; the binding sets `SCROLLBACK_MAX_LINES` from it (default 10,000, NULL for 0) with `SCROLLBACK_MAX_BYTES` at 32 MiB. From 43e617088e8a235e5c84d59b923d144407e955e2 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 04:03:23 -0400 Subject: [PATCH 084/295] docs(config): list the characters STIX Two Text now draws accurately --- docs/specs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/config.md b/docs/specs/config.md index 09b3581..79b4ee4 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -126,7 +126,7 @@ All keys are optional — omit any key to use the default value. ### Default font stack -Chrome draws a grapheme cluster in the first font of the stack that has every codepoint in it, or in a system fallback font. Menlo has no enclosing marks (U+20DD-U+20E0), so `a⃝` or `1⃝` came out as a missing-glyph box. `'STIX Two Text'` (shipped with macOS) has them and is added last, so it is used only for clusters no earlier font covers whole: the cell size, which is measured from the first available font, and every glyph Menlo or the system fallback drew before stay the same. The exceptions are about 120 letterlike symbols, modifier letters and ligatures Menlo lacks (`℃`, `₹`, `ℹ`, `ℓ`, `ℝ`, `ff`), which now come from STIX Two Text instead of the system fallback. It goes last rather than after Menlo because on Linux or Windows, where Menlo is missing, a STIX font installed there would otherwise become the cell font; where it is not installed, the name is skipped. `'STIX Two Math'` covers the same marks but also draws its own symbols in the Private Use Area that Powerline and Nerd Font glyphs use, so a missing-glyph box became an unrelated glyph; `STIXGeneral` takes over about 2,000 math symbols from the system fallback. Neither font covers U+20E3 (keycap) without VS16, so `1⃣` stays a box. +Chrome draws a grapheme cluster in the first font of the stack that has every codepoint in it, or in a system fallback font. Menlo has no enclosing marks (U+20DD-U+20E0), so `a⃝` or `1⃝` came out as a missing-glyph box. `'STIX Two Text'` (shipped with macOS) has them and is added last, so it is used only for clusters no earlier font covers whole: the cell size, which is measured from the first available font, and every glyph Menlo or the system fallback drew before stay the same. The exceptions are about 120 other characters Menlo lacks, which now come from STIX Two Text instead of the system fallback: letterlike symbols (`℃`, `ℹ`, `ℓ`), some currency signs (`₹`, `₺`, `₽`), modifier letters and phonetic extensions (`ᴀ`, `ᶁ`), historic Cyrillic (`Ѣ`, `Ѫ`), `ẞ`, some punctuation (`‖`, `※`, `⁄`) and ligatures (`ff`). It goes last rather than after Menlo because on Linux or Windows, where Menlo is missing, a STIX font installed there would otherwise become the cell font; where it is not installed, the name is skipped. `'STIX Two Math'` covers the same marks but also draws its own symbols in the Private Use Area that Powerline and Nerd Font glyphs use, so a missing-glyph box became an unrelated glyph; `STIXGeneral` takes over about 2,000 math symbols from the system fallback. Neither font covers U+20E3 (keycap) without VS16, so `1⃣` stays a box. A `fontFamily` written in `config.json` replaces the whole stack, so it keeps whatever it was set to; append `'STIX Two Text'` to it to get the marks. The first run writes only `port` and `host`, so a config without `fontFamily` gets the new default. From b30a19391b488be5796800fbd8f2f79e687dba17 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:01:02 -0400 Subject: [PATCH 085/295] docs(client): ADR 051, drag, double click and right-click follow app mouse tracking --- docs/adrs/011.cli.config-and-help.md | 2 + ...ouse-tracking-selection-and-right-click.md | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 docs/adrs/051.client.mouse-tracking-selection-and-right-click.md diff --git a/docs/adrs/011.cli.config-and-help.md b/docs/adrs/011.cli.config-and-help.md index 44632ce..fce8a1d 100644 --- a/docs/adrs/011.cli.config-and-help.md +++ b/docs/adrs/011.cli.config-and-help.md @@ -134,6 +134,8 @@ Several small UX issues surfaced during review: - `copyOnSelect: boolean` (default `true`) — auto-copy to clipboard on mouseup, like kitty and Windows Terminal. - `rightClickBehavior: "default" | "copyPaste"` (default `"default"`) — `"copyPaste"` copies + clears selection on right-click (Windows Terminal style); `"default"` leaves the native context menu untouched. Invalid values silently fall back to `"default"`. +**Update (ADR 051):** `rightClickBehavior` is `"contextMenu"` (default; `"default"` is still accepted), `"copyPaste"`, `"paste"` or `"none"`, and invalid values fall back to `"contextMenu"`. While the app tracks the mouse, a drag or right-click goes only to the app: webtty selects nothing, copies nothing and shows no menu, unless Shift is held. See [ADR 051](051.client.mouse-tracking-selection-and-right-click.md). + ## Related Decisions - [ADR 002 — CLI start/stop](002.cli.start-stop.md): established `startServer` and `isServerRunning` used by the no-arg path diff --git a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md new file mode 100644 index 0000000..bd708ee --- /dev/null +++ b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md @@ -0,0 +1,84 @@ +# ADR 051: client — Drag, double click and right-click follow app mouse tracking + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +webtty listens to the mouse in two places, and neither asks the other: + +- `input-handler.ts` reports every press, release and drag to the app while it tracks the mouse (any of DEC modes 9, 1000, 1002, 1003), encoded by Ghostty (ADR 049). +- `selection-manager.ts` starts a selection on every left press, and selects a word or line on a double or triple click, whether or not the app tracks the mouse. With `copyOnSelect` on (the default, ADR 011), the release copies it. + +So in an app that tracks the mouse (vim with `set mouse=a`, htop, tmux, Claude Code and pi in fullscreen), a drag both reached the app and drew webtty's highlight, and the release wrote the clipboard. Claude Code and pi copy on release themselves, through the native clipboard on the machine they run on (pbcopy, PowerShell `Set-Clipboard`, wl-copy or xclip; OSC 52 only over SSH), so webtty's copy overwrote theirs with whatever cells happened to be under its own highlight. A double click in vim selected a word in vim and another in webtty. + +The right button had the same split. The app got the press and release, and the browser showed its context menu over the app. `rightClickBehavior` (ADR 011) had two values: `"default"` (browser menu) and `"copyPaste"` (copy and clear an existing selection, otherwise the menu), applied in `index.ts`, while `selection-manager.ts` moved its textarea under the pointer for every `contextmenu` so the menu offers Copy and Paste. + +Native terminals agree on what to do: + +- Ghostty, kitty, WezTerm, xterm, Windows Terminal and VS Code send clicks, drags and right-clicks only to an app that tracks the mouse, and select nothing themselves. Shift is the bypass: Shift+drag selects in the terminal and sends nothing to the app. Claude Code's docs tell users to hold Shift for a native selection in terminals like webtty (Terminal.app uses Fn, iTerm2 Option). +- The right button is configurable: Ghostty's `right-click-action` (`context-menu`, `paste`, `copy`, `copy-or-paste`, `ignore`), VS Code's `terminal.integrated.rightClickBehavior` (`default`, `copyPaste`, `paste`, `selectWord`, `nothing`). Windows Terminal copies a selection or pastes on right-click. + +The user agreed the design below. + +## Decision + +### One decision for the pointer + +A new module, `mouse-route.ts` in the vendored terminal, holds the rule, and both listeners call it: + +- `pressToApp(tracking, shiftKey)`: a press goes to the app when the app tracks the mouse and Shift is not held. Otherwise it is webtty's. +- When the press is the app's, `selection-manager.ts` starts no selection: no highlight, no copy on release, and a double or triple click selects no word or line. An existing selection is left alone. `input-handler.ts` reports the press, the drag and the release, as today. +- When the press is webtty's because Shift is held, `input-handler.ts` reports nothing for that button: not the press, not the motion while it is held, not its release, even if Shift is let go mid-drag. `selection-manager.ts` selects and copies as it does without tracking (Ghostty and xterm do the same). +- Without tracking, nothing changes: drag, double and triple click select, and the release copies when `copyOnSelect` is on. + +### One function for the right button + +`rightClickAction` in the same module decides what a `contextmenu` event does, from whether the app tracks the mouse, Shift, whether the menu came from the keyboard, `rightClickBehavior` and whether there is a selection: + +| | Result | +|---|---| +| App tracks the mouse, right button without Shift | `app`: the app already got the press and release; the browser menu is suppressed (`preventDefault`) | +| Shift+right-click, a keyboard menu (Menu key, Shift+F10), or the app does not track the mouse | `rightClickBehavior` | +| `"contextMenu"` (default) | `menu`: the browser menu, with the textarea under the pointer so it offers Copy and Paste | +| `"copyPaste"` | with a selection, `copy`: copy it and clear it; without, `menu` (ADR 011's behaviour) | +| `"paste"` | `paste`: paste the clipboard as Ctrl+V does (ADR 037, ADR 049: `ghostty_terminal_paste`, or `\x16` for an empty or non-text clipboard) | +| `"none"` | nothing, and no menu | + +A menu is from the keyboard when no right press (or macOS Ctrl+click, which opens the menu with the left button) came since the last key press: the terminal notes such a press on `mousedown` and forgets it on `keydown` and once a `contextmenu` has used it. This covers the menu opening on press (macOS, Linux) and on release (Windows). + +`Terminal` owns the one `contextmenu` listener (capture, on the container) and acts on the result: `menu` calls the selection manager's textarea placement, `copy` copies and clears, `paste` fires the new `onPasteRequest`, which `index.ts` answers with the same clipboard read as Ctrl+V, and `app` and `none` cancel the menu. The `index.ts` listener for `"copyPaste"` and the selection manager's own `contextmenu` listener go. A future `"webtty"` value, a menu drawn by webtty, is one more case of `rightClickAction` and one more action; it is reserved here and not added. + +### Config + +- `rightClickBehavior`: `"contextMenu"` (default), `"copyPaste"`, `"paste"`, `"none"`. `"default"`, the old name of `"contextMenu"`, is still accepted. Any other value falls back to `"contextMenu"`. The default is the same on every platform; Windows Terminal users may prefer `"copyPaste"`. +- `copyOnSelect` is unchanged, and now reads as: copy a selection made with the mouse when the mouse button is released. A drag the app receives is not a selection. + +## Consequences + +- In an app that tracks the mouse, a drag and its release reach only the app. webtty no longer draws a highlight over it or overwrites the clipboard the app just wrote, and a double click no longer selects a second word. +- Shift+drag, Shift+double and Shift+triple click select in webtty in such an app, and the app gets nothing. Apps that read Shift+click (rare; Shift is the bypass in every native terminal) lose it. +- A right-click in such an app shows no browser menu. Shift+right-click, or the keyboard, shows it (or does what `rightClickBehavior` says). +- Without tracking (a shell, `less` without `--mouse`), the left button behaves as before, and right-click as before for `"contextMenu"` (the old `"default"`) and `"copyPaste"`. +- The keyboard menu depends on the key reaching the browser: a key the terminal encodes and cancels (F10 is sent to the app) opens no menu, and the Menu key is not on every keyboard. +- The decision is two small pure functions with unit tests; the listeners only call them. + +## Considered Options + +- **Follow tracking, Shift to bypass (chosen).** What every native terminal does, and what Claude Code tells its users to do. +- **Keep selecting while the app tracks the mouse, and only stop the copy.** Leaves a highlight that does not match the app's own selection, and a double click that selects twice. +- **A modifier other than Shift (Alt, or Fn/Option as Terminal.app and iTerm2).** Alt-click is reported to apps since ADR 049, and Option-click is how macOS users move the cursor in some apps; Shift is the one bypass shared by Ghostty, kitty, WezTerm, xterm, Windows Terminal and VS Code. +- **Show the browser menu over a tracking app.** The app also acts on the right button, so both react to one click. +- **A webtty-drawn menu (`"webtty"`).** Wanted later; there is nothing to put in it yet. `rightClickAction` is where it plugs in. +- **Change the default to `"copyPaste"` on Windows.** Keeps one default everywhere; documented instead. + +## Related Decisions + +- [ADR 011 — Config, help, and UX polish](011.cli.config-and-help.md): `copyOnSelect` and `rightClickBehavior` +- [ADR 017 — SGR mouse scroll](017.client.mouse-scroll.md) +- [ADR 037 — Preserve text when bracketed paste is off](037.client.multiline-text-paste.md) +- [ADR 049 — Mouse reports, cursor style and paste through libghostty-vt](049.client.official-mouse-cursor-paste.md) +- [ADR 050 — Selection words, lines and text from libghostty-vt](050.client.selection-from-libghostty-vt.md) From 8592ea0d3498f9b9d3cc21173d4b7c91851e9d8b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:01:24 -0400 Subject: [PATCH 086/295] feat(config): add contextMenu, paste and none right-click behaviours --- src/config.test.ts | 27 ++++++++++++++++++++++++--- src/config.ts | 26 ++++++++++++++++++-------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index cab140e..039ef07 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -245,9 +245,30 @@ describe('loadConfig — reads and merges', () => { expect(loadConfig().rightClickBehavior).toBe('copyPaste'); }); - test('falls back rightClickBehavior to default for invalid value', () => { - writeConfig(JSON.stringify({ rightClickBehavior: 'bogus' })); - expect(loadConfig().rightClickBehavior).toBe('default'); + test('defaults rightClickBehavior to contextMenu', () => { + writeConfig(JSON.stringify({})); + expect(loadConfig().rightClickBehavior).toBe('contextMenu'); + }); + + test('accepts each rightClickBehavior value', () => { + for (const value of ['contextMenu', 'copyPaste', 'paste', 'none'] as const) { + writeConfig(JSON.stringify({ rightClickBehavior: value })); + expect(loadConfig().rightClickBehavior).toBe(value); + } + }); + + test('reads the old rightClickBehavior "default" as contextMenu', () => { + writeConfig(JSON.stringify({ rightClickBehavior: 'default' })); + expect(loadConfig().rightClickBehavior).toBe('contextMenu'); + }); + + test('falls back rightClickBehavior to contextMenu for an invalid value', () => { + for (const value of ['bogus', 'webtty', 'Paste', 'nothing']) { + writeConfig(JSON.stringify({ rightClickBehavior: value })); + expect(loadConfig().rightClickBehavior).toBe('contextMenu'); + } + writeConfig(JSON.stringify({ rightClickBehavior: 3 })); + expect(loadConfig().rightClickBehavior).toBe('contextMenu'); }); test('throws on invalid JSON', () => { diff --git a/src/config.ts b/src/config.ts index 5b7f981..1e92f39 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,8 +60,20 @@ export interface Theme { brightWhite?: string; } -/** Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. */ -export type RightClickBehavior = 'default' | 'copyPaste'; +/** + * What a right-click does when the app does not take it (it takes it while it tracks the + * mouse, unless Shift is held): `"contextMenu"` shows the browser menu; `"copyPaste"` + * copies and clears a selection, or shows the menu without one; `"paste"` pastes the + * clipboard; `"none"` does nothing. See ADR 051. + */ +export type RightClickBehavior = 'contextMenu' | 'copyPaste' | 'paste' | 'none'; + +const RIGHT_CLICK_BEHAVIORS: readonly string[] = ['contextMenu', 'copyPaste', 'paste', 'none']; + +/** A configured `rightClickBehavior`: `"default"` is the old name of `"contextMenu"`, and anything unknown is `"contextMenu"`. */ +export function parseRightClickBehavior(value: string): RightClickBehavior { + return RIGHT_CLICK_BEHAVIORS.includes(value) ? (value as RightClickBehavior) : 'contextMenu'; +} /** Full webtty configuration. All keys are optional in the config file; missing keys fall back to {@link DEFAULT_CONFIG}. */ export interface Config { @@ -91,9 +103,9 @@ export interface Config { cursorStyle: 'block' | 'bar' | 'underline'; /** Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. */ cursorStyleBlink: boolean; - /** Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style). */ + /** Copy a mouse selection to the clipboard when the button is released (kitty / Windows Terminal style). A drag the app receives is not a selection. */ copyOnSelect: boolean; - /** `"copyPaste"` copies selection + clears it on right-click if selection exists; `"default"` always shows native context menu. */ + /** What a right-click does when the app does not take it; see {@link RightClickBehavior}. */ rightClickBehavior: RightClickBehavior; /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ mouseScrollSpeed: number; @@ -172,7 +184,7 @@ export const DEFAULT_CONFIG: Config = { cursorStyle: 'bar', cursorStyleBlink: true, copyOnSelect: true, - rightClickBehavior: 'default' as RightClickBehavior, + rightClickBehavior: 'contextMenu' as RightClickBehavior, mouseScrollSpeed: 1, padding: 0, logs: false, @@ -287,9 +299,7 @@ export function loadConfig(): Config { ...(typeof p.cursorStyleBlink === 'boolean' && { cursorStyleBlink: p.cursorStyleBlink }), ...(typeof p.copyOnSelect === 'boolean' && { copyOnSelect: p.copyOnSelect }), ...(typeof p.rightClickBehavior === 'string' && { - rightClickBehavior: (p.rightClickBehavior === 'copyPaste' - ? 'copyPaste' - : 'default') as RightClickBehavior, + rightClickBehavior: parseRightClickBehavior(p.rightClickBehavior), }), ...(typeof p.mouseScrollSpeed === 'number' && p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), From c386d7cfc3cabe74cc14de954d5714fe73a0345a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:04:53 -0400 Subject: [PATCH 087/295] feat(client): decide whether a press or a right-click is the app's in one module --- src/client/vendor/ghostty-web/mouse-route.ts | 78 ++++++++++++++++ src/client/vendor/mouse-route.test.ts | 97 ++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/client/vendor/ghostty-web/mouse-route.ts create mode 100644 src/client/vendor/mouse-route.test.ts diff --git a/src/client/vendor/ghostty-web/mouse-route.ts b/src/client/vendor/ghostty-web/mouse-route.ts new file mode 100644 index 0000000..b39f986 --- /dev/null +++ b/src/client/vendor/ghostty-web/mouse-route.ts @@ -0,0 +1,78 @@ +/** + * Who a mouse press and a right-click belong to: the app, while it tracks the mouse, or + * webtty (selection, menu, copy, paste). Shift is the bypass, as in Ghostty, kitty, + * WezTerm, xterm, Windows Terminal and VS Code. (webtty, ADR 051) + */ + +/** What a right-click does when the app does not take it (the config's `rightClickBehavior`). */ +export type RightClickBehavior = 'contextMenu' | 'copyPaste' | 'paste' | 'none'; + +/** + * What a `contextmenu` event does: + * - `app`: nothing more; the app got the press and release, and the browser menu is cancelled + * - `menu`: the browser menu + * - `copy`: copy the selection and clear it, no menu + * - `paste`: paste the clipboard, no menu + * - `none`: nothing, no menu + */ +export type RightClickAction = 'app' | 'menu' | 'copy' | 'paste' | 'none'; + +/** A press goes to the app while it tracks the mouse, unless Shift is held. */ +export function pressToApp(tracking: boolean, shiftKey: boolean): boolean { + return tracking && !shiftKey; +} + +export interface RightClick { + /** The app tracks the mouse (any DEC mouse tracking mode is on). */ + tracking: boolean; + shiftKey: boolean; + /** The menu was opened from the keyboard (Menu key, Shift+F10), not by a button. */ + fromKeyboard: boolean; + behavior: RightClickBehavior; + /** There is a selection with text to copy. */ + hasSelection: boolean; +} + +/** + * The one decision for a `contextmenu` event. A right button the app takes is the app's; + * Shift+right-click, a keyboard menu, or any right-click while the app does not track + * the mouse follow `behavior`. A webtty-drawn menu would be one more behaviour here. + */ +export function rightClickAction(click: RightClick): RightClickAction { + if (!click.fromKeyboard && pressToApp(click.tracking, click.shiftKey)) return 'app'; + switch (click.behavior) { + case 'copyPaste': + return click.hasSelection ? 'copy' : 'menu'; + case 'paste': + return 'paste'; + case 'none': + return 'none'; + default: + return 'menu'; + } +} + +/** + * Tells a `contextmenu` opened by a button from one opened from the keyboard. A right + * press, or a Ctrl+left press (macOS opens the menu on it), arms it; a key press or any + * other press disarms it, and a `contextmenu` uses it up. The menu opens on press on + * macOS and Linux and on release on Windows, after the press either way. + */ +export class MenuSource { + private armed = false; + + press(e: Pick): void { + this.armed = e.button === 2 || (e.button === 0 && e.ctrlKey); + } + + key(): void { + this.armed = false; + } + + /** Whether this `contextmenu` came from the keyboard; call once per event. */ + fromKeyboard(e: Pick): boolean { + const fromButton = this.armed || e.button === 2; + this.armed = false; + return !fromButton; + } +} diff --git a/src/client/vendor/mouse-route.test.ts b/src/client/vendor/mouse-route.test.ts new file mode 100644 index 0000000..16e10dc --- /dev/null +++ b/src/client/vendor/mouse-route.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { + MenuSource, + pressToApp, + type RightClickAction, + type RightClickBehavior, + rightClickAction, +} from './ghostty-web/mouse-route'; + +describe('pressToApp', () => { + test('a press is the app’s only while it tracks the mouse and Shift is up', () => { + expect(pressToApp(true, false)).toBe(true); + expect(pressToApp(true, true)).toBe(false); + expect(pressToApp(false, false)).toBe(false); + expect(pressToApp(false, true)).toBe(false); + }); +}); + +describe('rightClickAction', () => { + const behaviors: RightClickBehavior[] = ['contextMenu', 'copyPaste', 'paste', 'none']; + + /** What `behavior` does when the app does not take the click. */ + const own = (behavior: RightClickBehavior, hasSelection: boolean): RightClickAction => + ({ + contextMenu: 'menu' as const, + copyPaste: hasSelection ? ('copy' as const) : ('menu' as const), + paste: 'paste' as const, + none: 'none' as const, + })[behavior]; + + for (const tracking of [false, true]) { + for (const shiftKey of [false, true]) { + for (const fromKeyboard of [false, true]) { + for (const behavior of behaviors) { + for (const hasSelection of [false, true]) { + const toApp = tracking && !shiftKey && !fromKeyboard; + const expected = toApp ? 'app' : own(behavior, hasSelection); + test(`tracking ${tracking}, shift ${shiftKey}, keyboard ${fromKeyboard}, ${behavior}, selection ${hasSelection}: ${expected}`, () => { + expect( + rightClickAction({ tracking, shiftKey, fromKeyboard, behavior, hasSelection }), + ).toBe(expected); + }); + } + } + } + } + } + + test('an unknown behaviour shows the menu', () => { + const behavior = 'webtty' as RightClickBehavior; + expect( + rightClickAction({ + tracking: false, + shiftKey: false, + fromKeyboard: false, + behavior, + hasSelection: true, + }), + ).toBe('menu'); + }); +}); + +describe('MenuSource', () => { + const right = { button: 2, ctrlKey: false }; + + test('a menu after a right press is from the button, on press or after release', () => { + const s = new MenuSource(); + s.press(right); + expect(s.fromKeyboard({ button: 2 })).toBe(false); + // Windows: the menu comes after the release; Chrome may report button 0 + s.press(right); + expect(s.fromKeyboard({ button: 0 })).toBe(false); + }); + + test('a macOS Ctrl+click menu is from the button', () => { + const s = new MenuSource(); + s.press({ button: 0, ctrlKey: true }); + expect(s.fromKeyboard({ button: 0 })).toBe(false); + }); + + test('a menu with no press before it, or after a key, is from the keyboard', () => { + const s = new MenuSource(); + expect(s.fromKeyboard({ button: 0 })).toBe(true); + s.press(right); + s.key(); + expect(s.fromKeyboard({ button: 0 })).toBe(true); + s.press({ button: 0, ctrlKey: false }); + expect(s.fromKeyboard({ button: 0 })).toBe(true); + }); + + test('a press is used up by one menu', () => { + const s = new MenuSource(); + s.press(right); + expect(s.fromKeyboard({ button: 0 })).toBe(false); + expect(s.fromKeyboard({ button: 0 })).toBe(true); + }); +}); From fddb7f946eb5de156617dd77a3eaee71a971a56b Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:05:11 -0400 Subject: [PATCH 088/295] feat(client): leave drags and double clicks to an app that tracks the mouse, Shift to select --- .../vendor/ghostty-web/input-handler.ts | 24 +++++++++++- .../vendor/ghostty-web/selection-manager.ts | 12 ++++++ src/client/vendor/input-handler.test.ts | 26 +++++++++++++ src/client/vendor/selection-manager.test.ts | 37 +++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 842b1fc..9487652 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -14,6 +14,7 @@ */ import type { Ghostty, MouseButton, MouseInput } from './ghostty'; +import { pressToApp } from './mouse-route'; import type { KeyEncoder } from './ghostty'; import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; @@ -248,6 +249,9 @@ export class InputHandler { private isComposing = false; private isDisposed = false; private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting + // Buttons pressed with Shift while the app tracks the mouse: webtty's (selection, menu), + // so nothing about them reaches the app until they are released (webtty, ADR 051) + private mouseButtonsBypassed = 0; private lastKeyDownData: string | null = null; private lastKeyDownTime = 0; private lastCompositionData: string | null = null; @@ -646,10 +650,17 @@ export class InputHandler { */ private handleMouseDown(event: MouseEvent): void { if (this.isDisposed) return; - if (!this.mouseConfig?.hasMouseTracking()) return; + const tracking = this.mouseConfig?.hasMouseTracking() ?? false; + if (!tracking) return; const button = MOUSE_BUTTONS[event.button]; if (!button) return; + // Shift+press is webtty's: the app gets neither it, the drag nor the release + // (webtty, ADR 051) + if (!pressToApp(tracking, event.shiftKey)) { + this.mouseButtonsBypassed |= 1 << event.button; + return; + } // Back and forward would leave the session's page (webtty) if (event.button >= 3) event.preventDefault(); @@ -672,6 +683,10 @@ export class InputHandler { const button = MOUSE_BUTTONS[event.button]; if (!button) return; const bit = 1 << event.button; + if (this.mouseButtonsBypassed & bit) { + this.mouseButtonsBypassed &= ~bit; + return; + } if (!(this.mouseButtonsPressed & bit) && !this.isOverTerminal(event)) return; if (event.button >= 3) event.preventDefault(); @@ -690,7 +705,12 @@ export class InputHandler { if (!this.mouseConfig?.hasMouseTracking()) return; // No button held: a release outside the document never cleared the bitmask - if (event.buttons === 0) this.mouseButtonsPressed = 0; + if (event.buttons === 0) { + this.mouseButtonsPressed = 0; + this.mouseButtonsBypassed = 0; + } + // A Shift drag is webtty's selection (webtty, ADR 051) + if (this.mouseButtonsBypassed !== 0) return; if (this.mouseButtonsPressed === 0 && !this.isOverTerminal(event)) return; this.sendMouseEvent(event, 'motion', motionButton(this.mouseButtonsPressed)); diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 8ebdfdd..8c2d9dc 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -13,6 +13,7 @@ import { EventEmitter } from './event-emitter'; import type { GhosttyTerminal } from './ghostty'; import type { IEvent } from './interfaces'; +import { pressToApp } from './mouse-route'; import type { CanvasRenderer } from './renderer'; import type { Terminal } from './terminal'; @@ -257,6 +258,10 @@ export class SelectionManager { canvas.parentElement.focus(); } + // A press the app takes (it tracks the mouse, Shift is up) starts no selection + // and leaves the current one; Shift selects here instead. (webtty, ADR 051) + if (this.pressIsApps(e)) return; + const cell = this.pixelToCell(e.offsetX, e.offsetY); // Always clear previous selection on new click @@ -399,8 +404,10 @@ export class SelectionManager { // Double click selects a word and triple click a line, by Ghostty's rules (ADR 050). // event.detail is the browser's click count. + // Not while the app takes the press (webtty, ADR 051). canvas.addEventListener('click', (e: MouseEvent) => { if (e.detail < 2) return; + if (this.pressIsApps(e)) return; const cell = this.pixelToCell(e.offsetX, e.offsetY); const at = { col: cell.col, row: this.viewportRowToAbsolute(cell.row) }; const range = e.detail === 2 ? this.wasmTerm.selectWord(at) : this.wasmTerm.selectLine(at); @@ -503,6 +510,11 @@ export class SelectionManager { document.addEventListener('click', this.boundClickHandler); } + /** Whether the app takes this press: it tracks the mouse and Shift is up. (webtty, ADR 051) */ + private pressIsApps(e: MouseEvent): boolean { + return pressToApp(this.wasmTerm.hasMouseTracking(), e.shiftKey); + } + /** * Update auto-scroll based on mouse Y position within canvas */ diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index b7e1805..2066594 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -140,6 +140,32 @@ describe('InputHandler mouse', () => { handler.dispose(); }); + // Shift is the bypass, as in Ghostty and xterm (ADR 051) + test('a Shift drag and its release send nothing, even if Shift is let go', async () => { + const { handler, container, doc, sent, mouse } = await setup(); + container.fire('mousedown', mouse(true, { button: 0, buttons: 1, shiftKey: true }).event); + doc.fire('mousemove', mouse(true, { buttons: 1, clientX: 30, shiftKey: true }).event); + doc.fire('mousemove', mouse(true, { buttons: 1, clientX: 50 }).event); + doc.fire('mouseup', mouse(true, { button: 0, clientX: 50 }).event); + expect(sent).toEqual([]); + // The next press without Shift is the app's again + container.fire('mousedown', mouse(true, { button: 0, buttons: 1 }).event); + doc.fire('mouseup', mouse(true, { button: 0 }).event); + expect(sent).toEqual(['', '']); + handler.dispose(); + }); + + test('a Shift right-click sends nothing; a right-click is the app’s', async () => { + const { handler, container, doc, sent, mouse } = await setup(); + container.fire('mousedown', mouse(true, { button: 2, buttons: 2, shiftKey: true }).event); + doc.fire('mouseup', mouse(true, { button: 2, shiftKey: true }).event); + expect(sent).toEqual([]); + container.fire('mousedown', mouse(true, { button: 2, buttons: 2 }).event); + doc.fire('mouseup', mouse(true, { button: 2 }).event); + expect(sent).toEqual(['', '']); + handler.dispose(); + }); + test('back and forward are buttons 8 and 9, and do not navigate', async () => { const { handler, container, doc, sent, mouse } = await setup(); const down = mouse(true, { button: 3, buttons: 8 }); diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index 44d39be..2a7c10d 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -125,6 +125,43 @@ describe('SelectionManager copy on select', () => { expect(scrolls.every((amount) => amount === 3)).toBe(true); }); + // While the app tracks the mouse, a press is the app's; Shift selects (ADR 051) + test('with mouse tracking on, a drag or a double click selects and copies nothing', () => { + wasmTerm.write('\x1b[?1000h'); + const { m, canvas, changes } = manager(true); + drag(canvas); + canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); + canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); + expect(m.hasSelection()).toBe(false); + expect(m.getSelectionRange()).toBeNull(); + expect(writes).toEqual([]); + expect(changes()).toBe(0); + }); + + test('with mouse tracking on, Shift drag, double and triple click select and copy', () => { + wasmTerm.write('\x1b[?1002h\x1b[?1006h'); + const { m, canvas } = manager(true); + canvas.dispatch('mousedown', { button: 0, offsetX: 1, offsetY: 4, shiftKey: true }); + canvas.dispatch('mousemove', { offsetX: 4 * 8 + 1, offsetY: 4, shiftKey: true }); + documentTarget.dispatch('mouseup', {}); + canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4, shiftKey: true }); + canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20, shiftKey: true }); + expect(writes).toEqual(['hello', 'world', 'second line']); + expect(m.getSelection()).toBe('second line'); + }); + + test('a press the app takes leaves the current selection', () => { + const { m, canvas } = manager(true); + drag(canvas); + wasmTerm.write('\x1b[?1000h'); + canvas.dispatch('mousedown', { button: 0, offsetX: 8 * 7, offsetY: 20 }); + documentTarget.dispatch('mouseup', {}); + expect(m.getSelection()).toBe('hello'); + wasmTerm.write('\x1b[?1000l'); + drag(canvas); + expect(writes).toEqual(['hello', 'hello']); + }); + test('nothing is copied or built with copyOnSelect off, but the change is reported', () => { const { canvas, builds, changes } = manager(false); drag(canvas); From f21ac24b41273a247f9f833d1a95f7dd4f46f25f Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:05:35 -0400 Subject: [PATCH 089/295] feat(client): handle every right-click in one place, following app mouse tracking --- src/client/index.ts | 37 ++--- src/client/vendor/ghostty-web/interfaces.ts | 2 + .../vendor/ghostty-web/selection-manager.ts | 133 +++++++++--------- src/client/vendor/ghostty-web/terminal.ts | 58 ++++++++ src/client/vendor/terminal.test.ts | 102 ++++++++++++++ 5 files changed, 245 insertions(+), 87 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 48db2ce..66053f2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -51,7 +51,7 @@ interface ClientConfig { scrollbackLines: number; theme: Theme; copyOnSelect: boolean; - rightClickBehavior: 'default' | 'copyPaste'; + rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none'; mouseScrollSpeed: number; padding: number; keyboardBindings: KeyboardBinding[]; @@ -83,8 +83,12 @@ const term = new Terminal({ fontFamily: config.fontFamily, scrollbackLines: config.scrollbackLines, mouseScrollSpeed: config.mouseScrollSpeed, - // The terminal copies a mouse selection on mouseup, once, only when this is on (ADR 011) + // The terminal copies a mouse selection on mouseup, once, only when this is on (ADR 011). + // A drag the app takes, while it tracks the mouse and Shift is up, selects nothing. copyOnSelect: config.copyOnSelect, + // What a right-click the app does not take does; the terminal handles every + // contextmenu, and asks for a paste through onPasteRequest (ADR 051) + rightClickBehavior: config.rightClickBehavior, theme: terminalTheme, }); @@ -352,19 +356,6 @@ term.onResize(({ cols, rows }: { cols: number; rows: number }) => { } }); -// Copy selected text to clipboard on right-click when copyPaste mode is active. -if (config.rightClickBehavior === 'copyPaste') { - container.addEventListener('contextmenu', (e: MouseEvent) => { - const selection = term.getSelection() as string; - if (!selection) return; - e.preventDefault(); - navigator.clipboard.writeText(selection).catch(() => { - /* empty */ - }); - term.clearSelection(); - }); -} - // ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike // xterm.js), relying entirely on the browser 'paste' event. On macOS that // event only fires for Cmd+V (the OS paste gesture) - Ctrl+V produces no @@ -384,6 +375,19 @@ function sendPaste(text: string): void { else term.paste(text); } +// Reads the clipboard and pastes it, as Cmd+V does; a failed read sends Ctrl+V. +// Returns false when the browser cannot read the clipboard. +function pasteFromClipboard(): boolean { + if (!navigator.clipboard?.readText) return false; + navigator.clipboard.readText().then(sendPaste, sendCtrlV); + return true; +} + +// rightClickBehavior "paste" (ADR 051) +term.onPasteRequest(() => { + pasteFromClipboard(); +}); + // Ctrl+V never reaches us as a 'paste' event on macOS (see above), so read // the clipboard ourselves on keydown to make it behave like Cmd+V. Both // listeners are on `container` and keydown targets it directly, so they run @@ -396,10 +400,9 @@ container.addEventListener( (e: KeyboardEvent) => { if (!e.ctrlKey || e.metaKey || e.code !== 'KeyV') return; if (findBinding(e)) return; - if (!navigator.clipboard?.readText) return; + if (!pasteFromClipboard()) return; e.preventDefault(); e.stopImmediatePropagation(); - navigator.clipboard.readText().then(sendPaste, sendCtrlV); }, { capture: true }, ); diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 578ec93..d09eb44 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -3,6 +3,7 @@ */ import type { Ghostty } from './ghostty'; +import type { RightClickBehavior } from './mouse-route'; export interface ITerminalOptions { cols?: number; // Default: 80 @@ -24,6 +25,7 @@ export interface ITerminalOptions { // Selection options copyOnSelect?: boolean; // Copy a mouse selection to the clipboard when it is made (default: true) (webtty) + rightClickBehavior?: RightClickBehavior; // What a right-click the app does not take does (default: 'contextMenu') (webtty, ADR 051) // The Ghostty WASM instance, from Ghostty.load() (webtty: upstream's init() and its // module-level instance are removed) diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 8c2d9dc..41d564e 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -53,7 +53,6 @@ export class SelectionManager { // Store bound event handlers for cleanup private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null; - private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null; private boundClickHandler: ((e: MouseEvent) => void) | null = null; private boundDocumentMouseMoveHandler: ((e: MouseEvent) => void) | null = null; @@ -221,13 +220,6 @@ export class SelectionManager { this.boundDocumentMouseMoveHandler = null; } - // Clean up context menu event listener - if (this.boundContextMenuHandler) { - const canvas = this.renderer.getCanvas(); - canvas.removeEventListener('contextmenu', this.boundContextMenuHandler); - this.boundContextMenuHandler = null; - } - // Clean up document click listener if (this.boundClickHandler) { document.removeEventListener('click', this.boundClickHandler); @@ -237,6 +229,69 @@ export class SelectionManager { // Canvas event listeners will be cleaned up when canvas is removed from DOM } + /** + * Shows the browser's menu on the textarea at the pointer, holding the selection, so + * the menu offers Copy and Paste. The terminal calls it when a right-click shows the + * menu (webtty: upstream listened to every contextmenu itself, ADR 051). + */ + prepareContextMenu(e: MouseEvent): void { + // Position textarea at mouse cursor + const canvas = this.renderer.getCanvas(); + const rect = canvas.getBoundingClientRect(); + + this.textarea.style.position = 'fixed'; + this.textarea.style.left = `${e.clientX}px`; + this.textarea.style.top = `${e.clientY}px`; + this.textarea.style.width = '1px'; + this.textarea.style.height = '1px'; + this.textarea.style.zIndex = '1000'; + this.textarea.style.opacity = '0'; + + // Enable pointer events temporarily so context menu targets the textarea + this.textarea.style.pointerEvents = 'auto'; + + // If there's a selection, populate textarea with it and select the text + if (this.hasSelection()) { + const text = this.getSelection(); + this.textarea.value = text; + this.textarea.select(); + this.textarea.setSelectionRange(0, text.length); + } else { + // No selection - clear textarea but still show menu (for paste) + this.textarea.value = ''; + } + + // Focus the textarea so the context menu appears on it + this.textarea.focus(); + + // After a short delay, restore the textarea to its hidden state + // This allows the context menu to appear first + setTimeout(() => { + // Listen for when the context menu closes (user clicks away or selects an option) + const resetTextarea = () => { + this.textarea.style.pointerEvents = 'none'; + this.textarea.style.zIndex = '-10'; + this.textarea.style.width = '0'; + this.textarea.style.height = '0'; + this.textarea.style.left = '0'; + this.textarea.style.top = '0'; + this.textarea.value = ''; + + // Remove the one-time listeners + document.removeEventListener('click', resetTextarea); + document.removeEventListener('contextmenu', resetTextarea); + this.textarea.removeEventListener('blur', resetTextarea); + }; + + // Reset on any of these events (menu closed) + document.addEventListener('click', resetTextarea, { once: true }); + document.addEventListener('contextmenu', resetTextarea, { once: true }); + this.textarea.addEventListener('blur', resetTextarea, { once: true }); + }, 10); + + // Don't prevent default - let browser show the context menu on the textarea + } + // ========================================================================== // Private Methods // ========================================================================== @@ -418,68 +473,6 @@ export class SelectionManager { this.selectionMade(); }); - // Right-click (context menu) - position textarea to show browser's native menu - // This allows Copy/Paste options to appear in the context menu - this.boundContextMenuHandler = (e: MouseEvent) => { - // Position textarea at mouse cursor - const canvas = this.renderer.getCanvas(); - const rect = canvas.getBoundingClientRect(); - - this.textarea.style.position = 'fixed'; - this.textarea.style.left = `${e.clientX}px`; - this.textarea.style.top = `${e.clientY}px`; - this.textarea.style.width = '1px'; - this.textarea.style.height = '1px'; - this.textarea.style.zIndex = '1000'; - this.textarea.style.opacity = '0'; - - // Enable pointer events temporarily so context menu targets the textarea - this.textarea.style.pointerEvents = 'auto'; - - // If there's a selection, populate textarea with it and select the text - if (this.hasSelection()) { - const text = this.getSelection(); - this.textarea.value = text; - this.textarea.select(); - this.textarea.setSelectionRange(0, text.length); - } else { - // No selection - clear textarea but still show menu (for paste) - this.textarea.value = ''; - } - - // Focus the textarea so the context menu appears on it - this.textarea.focus(); - - // After a short delay, restore the textarea to its hidden state - // This allows the context menu to appear first - setTimeout(() => { - // Listen for when the context menu closes (user clicks away or selects an option) - const resetTextarea = () => { - this.textarea.style.pointerEvents = 'none'; - this.textarea.style.zIndex = '-10'; - this.textarea.style.width = '0'; - this.textarea.style.height = '0'; - this.textarea.style.left = '0'; - this.textarea.style.top = '0'; - this.textarea.value = ''; - - // Remove the one-time listeners - document.removeEventListener('click', resetTextarea); - document.removeEventListener('contextmenu', resetTextarea); - this.textarea.removeEventListener('blur', resetTextarea); - }; - - // Reset on any of these events (menu closed) - document.addEventListener('click', resetTextarea, { once: true }); - document.addEventListener('contextmenu', resetTextarea, { once: true }); - this.textarea.addEventListener('blur', resetTextarea, { once: true }); - }, 10); - - // Don't prevent default - let browser show the context menu on the textarea - }; - - canvas.addEventListener('contextmenu', this.boundContextMenuHandler); - // Click outside canvas - clear selection // This allows users to deselect by clicking anywhere outside the terminal this.boundClickHandler = (e: MouseEvent) => { diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index df96150..70d7121 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -39,6 +39,7 @@ import type { ITerminalOptions, } from './interfaces'; import { LinkDetector } from './link-detector'; +import { MenuSource, rightClickAction } from './mouse-route'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; @@ -205,12 +206,18 @@ export class Terminal implements ITerminalCore { private bellEmitter = new EventEmitter(); private titleChangeEmitter = new EventEmitter(); private scrollEmitter = new EventEmitter(); + private pasteRequestEmitter = new EventEmitter(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent = this.dataEmitter.event; public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; public readonly onBell: IEvent = this.bellEmitter.event; public readonly onTitleChange: IEvent = this.titleChangeEmitter.event; public readonly onScroll: IEvent = this.scrollEmitter.event; + /** + * A right-click asks to paste the clipboard (`rightClickBehavior: "paste"`). The + * embedder reads the clipboard, as for Ctrl+V. (webtty, ADR 051) + */ + public readonly onPasteRequest: IEvent = this.pasteRequestEmitter.event; // Lifecycle state private isOpen = false; @@ -229,6 +236,8 @@ export class Terminal implements ITerminalCore { public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) private customWheelEventHandler?: (event: WheelEvent) => boolean; private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; + // Whether a contextmenu came from a button or the keyboard (webtty, ADR 051) + private readonly menuSource = new MenuSource(); private arrowAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; private wheelPace?: (event: WheelEvent) => Pace; private beforeRender?: (now: number) => void; @@ -264,6 +273,7 @@ export class Terminal implements ITerminalCore { disableStdin: options.disableStdin ?? false, mouseScrollSpeed: options.mouseScrollSpeed ?? 1, copyOnSelect: options.copyOnSelect ?? true, + rightClickBehavior: options.rightClickBehavior ?? 'contextMenu', }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) @@ -611,6 +621,11 @@ export class Terminal implements ITerminalCore { parent.addEventListener('mouseleave', this.handleMouseLeave); parent.addEventListener('click', this.handleClick); + // Every right-click and keyboard menu goes through handleContextMenu (webtty, ADR 051) + parent.addEventListener('mousedown', this.handleMenuPress, { capture: true }); + parent.addEventListener('keydown', this.handleMenuKey, { capture: true }); + parent.addEventListener('contextmenu', this.handleContextMenu, { capture: true }); + // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) document.addEventListener('mouseup', this.handleMouseUp); @@ -1007,6 +1022,7 @@ export class Terminal implements ITerminalCore { this.bellEmitter.dispose(); this.titleChangeEmitter.dispose(); this.scrollEmitter.dispose(); + this.pasteRequestEmitter.dispose(); } // ========================================================================== @@ -1176,6 +1192,11 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); this.element.removeEventListener('focusin', this.handleFocusIn); + this.element.removeEventListener('mousedown', this.handleMenuPress, { capture: true }); + this.element.removeEventListener('keydown', this.handleMenuKey, { capture: true }); + this.element.removeEventListener('contextmenu', this.handleContextMenu, { + capture: true, + }); // Remove contenteditable and accessibility attributes added in open() this.element.removeAttribute('contenteditable'); @@ -1531,6 +1552,43 @@ export class Terminal implements ITerminalCore { return mouseReportText(this.wasmTerm.encodeMouse({ ...input, x, y })); } + private handleMenuPress = (e: MouseEvent): void => { + this.menuSource.press(e); + }; + + private handleMenuKey = (): void => { + this.menuSource.key(); + }; + + /** + * The one place a right-click or a keyboard menu is handled. A right button the app + * takes (it tracks the mouse, Shift is up) is the app's: its press and release were + * reported, and the menu is cancelled. Otherwise `rightClickBehavior` decides: the + * browser menu, copy and clear the selection, paste, or nothing. (webtty: upstream + * always showed the menu, from the selection manager, ADR 051) + */ + private handleContextMenu = (e: MouseEvent): void => { + const behavior = this.options.rightClickBehavior; + const action = rightClickAction({ + tracking: this.wasmTerm?.hasMouseTracking() ?? false, + shiftKey: e.shiftKey, + fromKeyboard: this.menuSource.fromKeyboard(e), + behavior, + hasSelection: behavior === 'copyPaste' && this.getSelection() !== '', + }); + if (action === 'menu') { + this.selectionManager?.prepareContextMenu(e); + return; + } + e.preventDefault(); + if (action === 'copy') { + this.copySelection(); + this.clearSelection(); + } else if (action === 'paste') { + this.pasteRequestEmitter.fire(); + } + }; + /** * Handle mouse down for scrollbar interaction */ diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 24f829b..f16de20 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -381,3 +381,105 @@ describe('Terminal wheel events', () => { expect(wheel(true)).toBe(true); }); }); + +describe('Terminal right-click', () => { + // Every contextmenu goes through one handler: the app's right button cancels the menu, + // and otherwise rightClickBehavior decides (ADR 051). + async function setup(rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none') { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(20, 2); + wasmTerm.write('hello'); + const term = new Terminal({ ghostty: {} as never, rightClickBehavior }); + const calls: string[] = []; + let selection = ''; + const internals = term as unknown as { + wasmTerm: unknown; + selectionManager: unknown; + handleMenuPress(e: object): void; + handleMenuKey(): void; + handleContextMenu(e: object): void; + }; + internals.wasmTerm = wasmTerm; + internals.selectionManager = { + getSelection: () => selection, + copySelection: () => { + calls.push(`copy ${selection}`); + return true; + }, + clearSelection: () => { + calls.push('clear'); + selection = ''; + }, + prepareContextMenu: () => calls.push('menu'), + }; + term.onPasteRequest(() => calls.push('paste')); + /** A right-click (press, then menu), or a keyboard menu; whether the menu was cancelled. */ + const menu = (opts: { shiftKey?: boolean; keyboard?: boolean } = {}) => { + let cancelled = false; + if (opts.keyboard) internals.handleMenuKey(); + else internals.handleMenuPress({ button: 2, ctrlKey: false }); + internals.handleContextMenu({ + button: opts.keyboard ? 0 : 2, + shiftKey: opts.shiftKey ?? false, + clientX: 1, + clientY: 1, + preventDefault: () => { + cancelled = true; + }, + }); + return cancelled; + }; + return { + menu, + calls, + select: (text: string) => { + selection = text; + }, + track: (on: boolean) => wasmTerm.write(on ? '\x1b[?1000h' : '\x1b[?1000l'), + free: () => wasmTerm.free(), + }; + } + + test('contextMenu shows the menu; while the app tracks the mouse only Shift or the keyboard do', async () => { + const t = await setup('contextMenu'); + expect(t.menu()).toBe(false); + t.track(true); + expect(t.menu()).toBe(true); + expect(t.menu({ shiftKey: true })).toBe(false); + expect(t.menu({ keyboard: true })).toBe(false); + expect(t.calls).toEqual(['menu', 'menu', 'menu']); + t.free(); + }); + + test('copyPaste copies and clears a selection, or shows the menu without one', async () => { + const t = await setup('copyPaste'); + expect(t.menu()).toBe(false); + t.select('hello'); + expect(t.menu()).toBe(true); + expect(t.calls).toEqual(['menu', 'copy hello', 'clear']); + t.track(true); + t.select('hello'); + expect(t.menu()).toBe(true); + expect(t.calls.length).toBe(3); + t.free(); + }); + + test('paste asks for a paste with no menu; none does nothing', async () => { + const p = await setup('paste'); + expect(p.menu()).toBe(true); + p.track(true); + expect(p.menu()).toBe(true); + expect(p.menu({ shiftKey: true })).toBe(true); + expect(p.calls).toEqual(['paste', 'paste']); + p.free(); + const n = await setup('none'); + expect(n.menu()).toBe(true); + expect(n.menu({ keyboard: true })).toBe(true); + expect(n.calls).toEqual([]); + n.free(); + }); + + test('defaults to contextMenu', () => { + expect(new Terminal({ ghostty: {} as never }).options.rightClickBehavior).toBe('contextMenu'); + }); +}); From 2f9328351f3cc72b4b5dbedc6d2ae19421306804 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:11:41 -0400 Subject: [PATCH 090/295] docs(client): record the app-first mouse and right-click behaviours in the specs and provenance --- ...nt.mouse-tracking-selection-and-right-click.md | 2 +- docs/specs/client.md | 15 +++++++++++++-- docs/specs/config.md | 7 ++++--- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md index bd708ee..44c42a5 100644 --- a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md +++ b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md @@ -63,7 +63,7 @@ A menu is from the keyboard when no right press (or macOS Ctrl+click, which open - Shift+drag, Shift+double and Shift+triple click select in webtty in such an app, and the app gets nothing. Apps that read Shift+click (rare; Shift is the bypass in every native terminal) lose it. - A right-click in such an app shows no browser menu. Shift+right-click, or the keyboard, shows it (or does what `rightClickBehavior` says). - Without tracking (a shell, `less` without `--mouse`), the left button behaves as before, and right-click as before for `"contextMenu"` (the old `"default"`) and `"copyPaste"`. -- The keyboard menu depends on the key reaching the browser: a key the terminal encodes and cancels (F10 is sent to the app) opens no menu, and the Menu key is not on every keyboard. +- The keyboard menu depends on the browser opening one. While the terminal has focus, the Menu key and Shift+F10 are keys like any other: they are encoded and sent to the app (`CSI 29 ~`, `CSI 21 ; 2 ~`), as native terminals do, and the browser opens no menu (checked in headless Chrome). A menu opened from the keyboard while focus is elsewhere in the page (Chrome reports `button` -1), or by assistive technology, follows `rightClickBehavior`. - The decision is two small pure functions with unit tests; the listeners only call them. ## Considered Options diff --git a/docs/specs/client.md b/docs/specs/client.md index b7690eb..a92ed83 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -152,11 +152,21 @@ Controlled by two config keys from `GET /api/config`: | Config | Default | Behavior | |--------|---------|----------| -| `copyOnSelect: true` | on | Auto-copies a mouse selection (drag, double or triple click) to clipboard once, on mouseup, through the Terminal's `copyOnSelect` option; off, nothing is copied. Context menu untouched. | -| `rightClickBehavior: "copyPaste"` | off | Right-click with selection copies + clears selection. `e.preventDefault()` only fires when selection exists. | +| `copyOnSelect` | `true` | Copies a mouse selection (drag, double or triple click) to the clipboard once, on mouseup, through the Terminal's `copyOnSelect` option; off, nothing is copied. | +| `rightClickBehavior` | `"contextMenu"` | What a right-click the app does not take does: `"contextMenu"` the browser menu (over the textarea, so it offers Copy and Paste); `"copyPaste"` copy and clear a selection, else the menu; `"paste"` paste the clipboard (the Ctrl+V path, ADR 037); `"none"` nothing, no menu. `"default"` is read as `"contextMenu"`. | Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these are the only reliable copy paths. +**The app's mouse comes first** ([ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md)). While the app tracks the mouse (any DEC mouse tracking mode), a press is the app's unless Shift is held, as in Ghostty, kitty, WezTerm, xterm, Windows Terminal and VS Code. `mouse-route.ts` holds the rule (`pressToApp`, `rightClickAction`), and the input handler, the selection manager and the terminal all ask it: + +| | Left drag, double/triple click | Right-click | +|---|---|---| +| App does not track the mouse | webtty selects; copied on release with `copyOnSelect` | `rightClickBehavior` | +| App tracks the mouse | only the app gets press, motion and release; no highlight, no copy | only the app gets it; browser menu cancelled | +| App tracks the mouse, Shift held | webtty selects and copies; the app gets nothing for that button until it is released | `rightClickBehavior`; the app gets nothing | + +The terminal has the one `contextmenu` listener (`handleContextMenu`). A menu not preceded by a right press (or macOS Ctrl+click) since the last key is a keyboard menu and follows `rightClickBehavior` even while the app tracks the mouse. While the terminal has focus, the Menu key and Shift+F10 are sent to the app as keys, so the browser opens no keyboard menu there. `"paste"` fires `Terminal.onPasteRequest`, which `index.ts` answers with the same clipboard read as Ctrl+V. + What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). ## Behavior Notes @@ -182,6 +192,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Output acknowledgements | Output is acknowledged as it is parsed, so the server sends it in order at the tab's pace; a tab closed with `4003` for falling behind resets and reconnects to the replay | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | | Multiline text paste | Text is encoded by Ghostty's `ghostty_terminal_paste`: bracketed whenever mode 2004 is enabled (single lines too), control bytes replaced with spaces, and without 2004 sent with newlines as CR (they may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 79b4ee4..65aec5d 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -113,8 +113,8 @@ All keys are optional — omit any key to use the default value. | `rows` | number | `24` | Initial terminal height in rows | | `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. | | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. DEC mode 12 (`CSI ? 12 h/l`) is ignored, as in Ghostty when `cursor-style-blink` is set, so an app that turns blinking off that way (vim's `t_ve`) does not leave the cursor steady. | -| `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | -| `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | +| `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released (kitty / Windows Terminal style). While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | +| `rightClickBehavior` | string | `"contextMenu"` | What a right-click does when the app does not take it: `"contextMenu"` shows the browser menu; `"copyPaste"` copies and clears an existing selection, otherwise shows the menu; `"paste"` pastes the clipboard as Ctrl+V does; `"none"` does nothing and shows no menu. While the app tracks the mouse, a right-click goes only to the app and no menu is shown; Shift+right-click applies this setting instead and sends nothing to the app. `"default"`, the old name of `"contextMenu"`, is still accepted; invalid values fall back to `"contextMenu"`. Windows Terminal users may prefer `"copyPaste"`. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | @@ -176,7 +176,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "cursorStyle": "bar", "cursorStyleBlink": true, "copyOnSelect": true, - "rightClickBehavior": "default", + "rightClickBehavior": "contextMenu", "fontSize": 13, "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", @@ -218,6 +218,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Terminal appearance | `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `theme` — re-read on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Hot config reload | Appearance re-read on tab reload; shell/PTY settings re-read on new PTY spawn; `port`/`host` locked for server lifetime | [ADR 009](../adrs/009.webtty.config-hot-reload.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Mouse follows app tracking | While the app tracks the mouse, drags, double clicks and right-clicks go only to the app (no selection, copy or menu), Shift to bypass; `rightClickBehavior` gains `"contextMenu"` (default, was `"default"`), `"paste"` and `"none"` | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime, and Ps 0 restores it | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 38575cd..38a24dd 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -38,4 +38,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `addons/fit.ts`, `interfaces.ts`, `providers/osc8-link-provider.ts`, `link-detector.ts`, `renderer.ts`, `types.ts`: unreachable code removed. `FitAddon.fit` and `observeResize` with their resize state (webtty resizes from `proposeDimensions`, ADR 033), so `ITerminalCore` keeps only `element`; `OSC8LinkProvider.findLinkRange` (private, never called); `LinkDetector.invalidateRows`; the renderer's `setTheme` and the ANSI `palette` array nothing read; `ILink.dispose`. - `ghostty.ts`, `input-handler.ts`, `terminal.ts`, `wheel.ts`: mouse reports are encoded by Ghostty's mouse encoder (ADR 049). The binding adds `setMouseGeometry` and `encodeMouse` (`ghostty_mouse_encoder_*`, `ghostty_mouse_event_*`, with Ghostty's per-cell motion deduplication, the tracking mode and format set from the terminal when a mouse mode changes). Upstream's `encodeMouseSGR`, `encodeMouseX10` and `pixelToCell` go, with webtty's `mouseMotionButton` and `isNewMouseCell` (Ghostty reports hover as 35 and motion once per cell), and `MouseTrackingConfig` becomes `hasMouseTracking` and `encode`. Upstream sent Meta as Alt and dropped Alt, sent the back and forward buttons as codes 3 and 4, knew only the SGR and X10 formats, and clamped the cell from below only; `mouseMods` and `MOUSE_BUTTONS` follow Ghostty (Shift, Alt, Ctrl; buttons 8 and 9). Move and release are heard on the document, so a drag past the terminal's edge keeps reporting and its release is sent; back and forward are `preventDefault`ed while reported. `terminal.ts` maps client positions to device-pixel cells with the container as the screen (`mouseSurface`), drops reports that are not valid UTF-8 (`mouseReportText`), and encodes each wheel tick as a press of button 4 or 5; `wheelSgr` is removed. - `ghostty.ts`, `terminal.ts`: pastes are encoded by `ghostty_terminal_paste` (ADR 049). The binding's new `paste` passes the text as a `text/plain` clipboard paste with a mime reader callback and `allow_unsafe` set, and the bytes come back through `WRITE_PTY`. `Terminal.paste`, removed above as unused, is back with Ghostty's encoding: bracketed whenever mode 2004 is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004; upstream bracketed the text unchanged and sent it raw otherwise. +- `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. From 49b280548cf6b9e0268171f4144ac288fb0f49de Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:23:06 -0400 Subject: [PATCH 091/295] docs(client): ADR 052, programs write the clipboard with OSC 52 --- docs/adrs/052.client.osc52-clipboard-write.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/adrs/052.client.osc52-clipboard-write.md diff --git a/docs/adrs/052.client.osc52-clipboard-write.md b/docs/adrs/052.client.osc52-clipboard-write.md new file mode 100644 index 0000000..ff0b4f8 --- /dev/null +++ b/docs/adrs/052.client.osc52-clipboard-write.md @@ -0,0 +1,80 @@ +# ADR 052: client — Programs write the clipboard with OSC 52 + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +Programs that cannot reach the clipboard of the machine the user sits at copy with OSC 52: `ESC ] 52 ; c ; BEL` (or ST). Claude Code and pi do it over SSH, Neovim 0.10+ has a built-in OSC 52 provider, and tmux does it with `set-clipboard`. webtty ignored the sequence, so a copy in any of them over SSH inside webtty went nowhere. (Locally they use pbcopy or similar, which works without the terminal.) + +Ghostty's official libghostty-vt, the engine since ADR 042 and ADR 047, parses OSC 52, iTerm2's `OSC 1337 ; Copy=:` and kitty's OSC 5522 clipboard protocol, decodes the base64 and calls one callback, `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE`, with a `GhosttyClipboardWrite`: a location (standard, selection, primary), a list of MIME representations (one `text/plain` for OSC 52), and a `reply` the callback must call before it returns. A NULL callback, the default, ignores OSC 52 and OSC 1337 writes and refuses OSC 5522 writes with ENOSYS. Reads (`OSC 52 ; c ; ?` and OSC 5522 reads) go to a separate callback, `CLIPBOARD_READ`; NULL, its default, ignores them. At the pinned commit (`05dcf887`): + +- **Selection characters** (`stream_terminal.zig`, `clipboardContents`): `s` is the selection clipboard, `p` the primary, and anything else (`c`, an empty field, `0`, ...) the standard clipboard. More than one character (`pc`) is not a valid sequence and is dropped. Ghostty on macOS writes `s` to a private pasteboard of its own (`com.mitchellh.ghostty.selection`), not the system one, and drops `p` ("macOS has no primary selection"). +- **Decoding**: strict base64; a character outside the alphabet drops the whole write (never partially decoded), and missing padding is accepted. An empty payload is a clear (no representations). The bytes are passed as they are, with no UTF-8 check. +- **Size**: an OSC is limited to 8 MiB (`MAX_ALLOCATING_BUF`), sequence body included, so an OSC 52 carries a little under 6 MiB of text. A longer one is dropped whole and the terminal carries on. OSC 5522 writes have their own limit, 64 MiB by default (`CLIPBOARD_WRITE_MAX_BYTES`). +- **Policy**: Ghostty's `clipboard-write` defaults to `allow` (`ask` and `deny` exist), and `clipboard-read` to `ask`. + +The browser side has its own rules. `navigator.clipboard.writeText` exists only in a secure context (https or localhost). Chrome writes only while the document has focus; Firefox only shortly after user input (transient activation); Safari only inside the user input's own event handler, so a write that arrives over the WebSocket after a key press is refused there. None of them lets a page write the clipboard long after the user last touched it. + +And webtty has something a native terminal does not: on every connect, the server replays the session's recent output (ADR 007, ADR 048). An OSC 52 in that history would write the clipboard again on every reload or reconnect, replacing whatever the user copied since with something copied long ago. + +The user asked for OSC 52, writes only, with a switch. + +## Decision + +### Writes only + +Only writes are honoured. `CLIPBOARD_READ` is never set, so an `OSC 52 ; c ; ?` query gets no answer, as with libghostty-vt's default (Ghostty itself asks; webtty has no prompt, and a page reading the clipboard needs its own permission). Nothing is ever sent back for OSC 52. + +### The binding takes the write and answers it + +`ghostty.ts` installs a `CLIPBOARD_WRITE` callback when the terminal is created with `clipboardWrite` on (the new `GhosttyTerminalConfig.clipboardWrite`, default true), and leaves it NULL otherwise. The callback: + +- writes nothing for the selection or primary location, and answers `UNSUPPORTED`: the browser has one clipboard, and Ghostty on macOS keeps both away from the system clipboard too; +- for the standard location, takes the first plain-text representation (Ghostty's `isTextMime` names: `text/plain`, `text/plain;charset=utf-8`, `UTF8_STRING`, `TEXT`, `STRING`), decodes it as UTF-8 with invalid bytes replaced by U+FFFD (`writeText` takes a string; Ghostty on macOS stores the bytes as they are), and answers `SUCCESS`; with no text representation, `UNSUPPORTED`; +- for a clear (no representations), keeps `''`: an empty clipboard, as Ghostty on macOS gets when it writes an empty string. + +The text is kept until `readEffects`, which gains `clipboard` (the last write since the previous call, `''` for a clear, or null), like the bell and title. The reply only matters for OSC 5522, which has an acknowledgement; it is sent before the browser write, which happens later and asynchronously. + +OSC 1337 Copy and OSC 5522 writes come through the same callback and are handled the same way. Ghostty's size limits and base64 handling are kept as they are; webtty adds none. + +### The terminal reports it; `index.ts` writes it + +`Terminal` fires the new `onClipboardWrite(text)` after a write whose effects carry a clipboard write, and passes its new `clipboardWrite` option to the engine. `index.ts` hands the text to `ClipboardWriter` (`src/client/clipboard-write.ts`), which calls `navigator.clipboard.writeText`: + +- **Held back after each connect until the user acts in the tab.** `connected()` disarms it at every (re)connect, and `userInput()`, on any `keydown`, `mousedown`, `paste` or `drop` in the window, arms it. So the replay never writes the clipboard, and a write from the live session always follows the user doing something in the tab, which is also when browsers allow it. +- **Failures are logged once, with no error UI.** No clipboard API (an http page that is not localhost), a refused write (no focus, no activation, Safari), and a write held back for the replay are each logged to the console once per page. The program is never told: OSC 52 has no reply. + +### Config + +`clipboardWrite` (boolean, default `true`), served by `/api/config` and read on every tab reload. `false` leaves the callback unset, so OSC 52, OSC 1337 Copy and OSC 5522 writes are ignored by Ghostty. It is a boolean rather than Ghostty's `allow`/`ask`/`deny`: webtty has no prompt to ask with. + +## Consequences + +- `"+y` in Neovim, a copy in Claude Code or pi over SSH, and tmux with `set-clipboard on` reach the browser clipboard in Chrome and Firefox (in Firefox, if the copy follows the key or click within its activation window). UTF-8 text arrives intact, newlines included. +- In Safari, most OSC 52 writes are refused by the browser; the console says so once. +- A reload, a reconnect, or the replay after a tab fell behind (ADR 048) does not write the clipboard. A write that arrives after reconnecting but before the user presses a key or clicks in the tab is ignored, and so is a replay still arriving after the user started typing. +- `"*y` in Neovim (`p`) and writes to the selection clipboard (`s`) do nothing, as in Ghostty on macOS. +- A program can replace the clipboard without asking, as in Ghostty's default (`clipboard-write = allow`); `clipboardWrite: false` turns that off. +- Programs cannot read the clipboard. + +## Considered Options + +- **Write only, held back until input (chosen).** +- **Answer reads too.** Needs a prompt webtty does not have, a separate browser permission, and lets any program in the session read what the user copied elsewhere. +- **Write the system clipboard for `s` and `p` too** (as some terminals do). Ghostty keeps them apart on macOS; Neovim sends `*` as `p`, and following Ghostty keeps `"+y` and `"*y` meaning what they mean there. +- **Write every OSC 52, the replay included.** Every reload would overwrite the clipboard with an old copy. +- **Ignore OSC 52 in the replay by marking where it ends.** Needs a protocol change (the replay is not delimited), and a write while the user is away from the tab would still be refused by the browser. +- **`allow` / `ask` / `deny` like Ghostty.** `ask` needs a prompt; a boolean covers the rest. +- **Fall back to `document.execCommand('copy')`.** Needs user activation in the same way, so it fails where `writeText` fails. + +## Related Decisions + +- [ADR 007 — Session Client](007.webtty.session-client.md): the replay on connect +- [ADR 011 — Config, help, and UX polish](011.cli.config-and-help.md): `copyOnSelect` +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md): effects read after each write +- [ADR 048 — PTY output flow control](048.webtty.output-flow-control.md): the replay after a tab falls behind +- [ADR 051 — Drag, double click and right-click follow app mouse tracking](051.client.mouse-tracking-selection-and-right-click.md) From 7e80023cd46077abb19d32979a5c6e1e390836ee Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:23:06 -0400 Subject: [PATCH 092/295] feat(config): add clipboardWrite, served by /api/config --- src/config.test.ts | 17 +++++++++++++++++ src/config.ts | 4 ++++ src/server/routes.test.ts | 1 + src/server/routes.ts | 1 + 4 files changed, 23 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index 039ef07..4e0dde4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -240,6 +240,23 @@ describe('loadConfig — reads and merges', () => { expect(loadConfig().copyOnSelect).toBe(false); }); + test('defaults clipboardWrite to true', () => { + writeConfig(JSON.stringify({})); + expect(loadConfig().clipboardWrite).toBe(true); + }); + + test('overrides clipboardWrite when set to false', () => { + writeConfig(JSON.stringify({ clipboardWrite: false })); + expect(loadConfig().clipboardWrite).toBe(false); + }); + + test('ignores a clipboardWrite that is not a boolean', () => { + for (const value of ['false', 0, null]) { + writeConfig(JSON.stringify({ clipboardWrite: value })); + expect(loadConfig().clipboardWrite).toBe(true); + } + }); + test('overrides rightClickBehavior when set to copyPaste', () => { writeConfig(JSON.stringify({ rightClickBehavior: 'copyPaste' })); expect(loadConfig().rightClickBehavior).toBe('copyPaste'); diff --git a/src/config.ts b/src/config.ts index 1e92f39..e081bd7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -107,6 +107,8 @@ export interface Config { copyOnSelect: boolean; /** What a right-click does when the app does not take it; see {@link RightClickBehavior}. */ rightClickBehavior: RightClickBehavior; + /** Let programs write the clipboard with OSC 52 (write only: reads are never answered). `false` ignores the writes. */ + clipboardWrite: boolean; /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ mouseScrollSpeed: number; /** Padding in px around the terminal canvas, on all four sides. `0` = none. Coloured by `theme.padding`, or `theme.background` if that is unset. */ @@ -185,6 +187,7 @@ export const DEFAULT_CONFIG: Config = { cursorStyleBlink: true, copyOnSelect: true, rightClickBehavior: 'contextMenu' as RightClickBehavior, + clipboardWrite: true, mouseScrollSpeed: 1, padding: 0, logs: false, @@ -301,6 +304,7 @@ export function loadConfig(): Config { ...(typeof p.rightClickBehavior === 'string' && { rightClickBehavior: parseRightClickBehavior(p.rightClickBehavior), }), + ...(typeof p.clipboardWrite === 'boolean' && { clipboardWrite: p.clipboardWrite }), ...(typeof p.mouseScrollSpeed === 'number' && p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), ...(typeof p.padding === 'number' && diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 52d7e13..c4f3d26 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -117,6 +117,7 @@ describe('server — routes', () => { expect(typeof body.fontSize).toBe('number'); expect(typeof body.copyOnSelect).toBe('boolean'); expect(typeof body.rightClickBehavior).toBe('string'); + expect(body.clipboardWrite).toBe(true); expect(typeof body.mouseScrollSpeed).toBe('number'); expect(body.scrollbackLines).toBe(10000); expect(typeof body.padding).toBe('number'); diff --git a/src/server/routes.ts b/src/server/routes.ts index a6683ac..9e23db6 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -122,6 +122,7 @@ export async function handleRequest( theme: config.theme, copyOnSelect: config.copyOnSelect, rightClickBehavior: config.rightClickBehavior, + clipboardWrite: config.clipboardWrite, mouseScrollSpeed: config.mouseScrollSpeed, padding: config.padding, keyboardBindings: config.keyboardBindings, From 30ae05e0d6672705f855323456760aa4ebf8454c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:23:06 -0400 Subject: [PATCH 093/295] feat(client): take OSC 52 clipboard writes from libghostty-vt's CLIPBOARD_WRITE callback --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 119 +++++++++++++++++++- src/client/vendor/ghostty-web/interfaces.ts | 3 + src/client/vendor/ghostty-web/terminal.ts | 13 ++- src/client/vendor/ghostty-web/types.ts | 6 + src/client/vendor/official-terminal.test.ts | 87 +++++++++++++- src/client/vendor/terminal.test.ts | 39 ++++++- 7 files changed, 256 insertions(+), 12 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 38a24dd..bbd125e 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -39,4 +39,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `input-handler.ts`, `terminal.ts`, `wheel.ts`: mouse reports are encoded by Ghostty's mouse encoder (ADR 049). The binding adds `setMouseGeometry` and `encodeMouse` (`ghostty_mouse_encoder_*`, `ghostty_mouse_event_*`, with Ghostty's per-cell motion deduplication, the tracking mode and format set from the terminal when a mouse mode changes). Upstream's `encodeMouseSGR`, `encodeMouseX10` and `pixelToCell` go, with webtty's `mouseMotionButton` and `isNewMouseCell` (Ghostty reports hover as 35 and motion once per cell), and `MouseTrackingConfig` becomes `hasMouseTracking` and `encode`. Upstream sent Meta as Alt and dropped Alt, sent the back and forward buttons as codes 3 and 4, knew only the SGR and X10 formats, and clamped the cell from below only; `mouseMods` and `MOUSE_BUTTONS` follow Ghostty (Shift, Alt, Ctrl; buttons 8 and 9). Move and release are heard on the document, so a drag past the terminal's edge keeps reporting and its release is sent; back and forward are `preventDefault`ed while reported. `terminal.ts` maps client positions to device-pixel cells with the container as the screen (`mouseSurface`), drops reports that are not valid UTF-8 (`mouseReportText`), and encodes each wheel tick as a press of button 4 or 5; `wheelSgr` is removed. - `ghostty.ts`, `terminal.ts`: pastes are encoded by `ghostty_terminal_paste` (ADR 049). The binding's new `paste` passes the text as a `text/plain` clipboard paste with a mime reader callback and `allow_unsafe` set, and the bytes come back through `WRITE_PTY`. `Terminal.paste`, removed above as unused, is back with Ghostty's encoding: bracketed whenever mode 2004 is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004; upstream bracketed the text unchanged and sent it raw otherwise. - `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. +- `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one. Upstream ignored OSC 52. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 982cc48..598817a 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -81,10 +81,16 @@ export interface ScreenRange { end: ScreenCell; } -/** Bell and title changes since the last `readEffects` (ADR 042). */ +/** Bell, title and clipboard writes since the last `readEffects` (ADR 042, ADR 052). */ export interface TerminalEffects { bell: boolean; title: string | null; + /** + * The text of the last clipboard write the program made (OSC 52, OSC 1337 Copy or + * OSC 5522) to the standard clipboard, `''` for a clear, or null for none. Only set + * while the terminal was created with `clipboardWrite` on. (webtty, ADR 052) + */ + clipboard: string | null; } /** @@ -172,7 +178,7 @@ export interface GhosttyTerminal { // Replies and effects hasResponse(): boolean; readResponse(): string | null; - /** Bell and title changes since the last call. */ + /** Bell, title and clipboard writes since the last call. */ readEffects(): TerminalEffects; } @@ -494,6 +500,7 @@ function resolveOfficialLayout(layout: VtLayout) { MODE: opt('MODE'), DEFAULT_CURSOR_STYLE: opt('DEFAULT_CURSOR_STYLE'), DEFAULT_CURSOR_BLINK: opt('DEFAULT_CURSOR_BLINK'), + CLIPBOARD_WRITE: opt('CLIPBOARD_WRITE'), }, defaultCursorStyle: { bar: layout.value('GhosttyTerminalCursorStyle', 'BAR'), @@ -728,6 +735,21 @@ function resolveOfficialLayout(layout: VtLayout) { STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), CLIPBOARD: layout.value('GhosttyPasteSource', 'CLIPBOARD'), }, + clipboardWrite: { + location: layout.offset('GhosttyClipboardWrite', 'location'), + contents: layout.offset('GhosttyClipboardWrite', 'contents'), + contentsLen: layout.offset('GhosttyClipboardWrite', 'contents_len'), + reply: layout.offset('GhosttyClipboardWrite', 'reply'), + contentSize: layout.size('GhosttyClipboardContent'), + mime: layout.offset('GhosttyClipboardContent', 'mime'), + data: layout.offset('GhosttyClipboardContent', 'data'), + replySize: layout.size('GhosttyClipboardWriteReply'), + replySizeField: layout.offset('GhosttyClipboardWriteReply', 'size'), + replyResult: layout.offset('GhosttyClipboardWriteReply', 'result'), + STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), + SUCCESS: layout.value('GhosttyClipboardWriteResult', 'SUCCESS'), + UNSUPPORTED: layout.value('GhosttyClipboardWriteResult', 'UNSUPPORTED'), + }, result: { SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), @@ -1024,6 +1046,23 @@ function setRgb(cell: GhosttyCell, fg: number, bg: number): void { cell.bg_b = bg & 0xff; } +const TEXT_MIMES = new Set([ + 'text/plain', + 'text/plain;charset=utf-8', + 'UTF8_STRING', + 'TEXT', + 'STRING', +]); + +/** + * Whether a clipboard MIME type names plain text: Ghostty's `isTextMime` list + * (`src/terminal/clipboard.zig`), the names text goes by on every platform. (webtty, + * ADR 052) + */ +export function isTextMime(mime: string): boolean { + return TEXT_MIMES.has(mime); +} + /** * Whether one `WRITE_PTY` reply is the kitty keyboard query reply `ESC [ ? u` * (`queryKittyKeyboard` in Ghostty's `stream_terminal.zig`). No other reply has @@ -1099,7 +1138,8 @@ export class OfficialTerminal implements GhosttyTerminal { | 'palette' | 'mouseSize' | 'mousePos' - | 'mouseOut', + | 'mouseOut' + | 'clipReply', number >; private readonly callbacks: InstalledFn[] = []; @@ -1168,6 +1208,7 @@ export class OfficialTerminal implements GhosttyTerminal { private bell = false; private titleChanged = false; private pendingTitle: string | null = null; + private pendingClipboard: string | null = null; constructor(vt: Ghostty, cols: number, rows: number, config?: GhosttyTerminalConfig) { this.vt = vt; @@ -1228,6 +1269,7 @@ export class OfficialTerminal implements GhosttyTerminal { mouseSize: [L.mouse.size, 8], mousePos: [L.mouse.positionSize, 8], mouseOut: [MOUSE_REPORT_MAX, 8], + clipReply: [L.clipboardWrite.replySize, 8], } as const); this.scratch = scratch; this.at = at; @@ -1248,7 +1290,7 @@ export class OfficialTerminal implements GhosttyTerminal { ]); this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); - this.installCallbacks(); + this.installCallbacks(config?.clipboardWrite ?? true); this.applyConfig(config); this.allocView(); } @@ -1753,9 +1795,10 @@ export class OfficialTerminal implements GhosttyTerminal { } readEffects(): TerminalEffects { - const effects = { bell: this.bell, title: this.pendingTitle }; + const effects = { bell: this.bell, title: this.pendingTitle, clipboard: this.pendingClipboard }; this.bell = false; this.pendingTitle = null; + this.pendingClipboard = null; return effects; } @@ -1830,7 +1873,7 @@ export class OfficialTerminal implements GhosttyTerminal { // Private: setup // ========================================================================== - private installCallbacks(): void { + private installCallbacks(clipboardWrite: boolean): void { const L = this.L; const table = this.vt.fnTable; const install = ( @@ -1923,6 +1966,70 @@ export class OfficialTerminal implements GhosttyTerminal { 3, true ); + // CLIPBOARD_WRITE(terminal, userdata, write): left NULL with clipboardWrite off, so + // Ghostty ignores OSC 52 and OSC 1337 Copy and refuses OSC 5522 writes (ADR 052). + if (clipboardWrite) { + install( + L.opt.CLIPBOARD_WRITE, + 'CLIPBOARD_WRITE', + (_t, _u, write) => { + this.clipboardWrite(write); + return undefined; + }, + 3, + false + ); + } + } + + /** + * Takes one clipboard write (`GhosttyClipboardWrite`, already base64-decoded by + * Ghostty) and answers it before returning, as the API requires. Only the standard + * clipboard is written: the browser has no other, and Ghostty on macOS keeps `s` in + * a private pasteboard and drops `p`. The text is the first plain-text + * representation, decoded as UTF-8 with invalid bytes replaced (U+FFFD); no + * representations is a clear, kept as `''`. The browser write happens later, after + * `readEffects`, so success here means the write was accepted. (webtty, ADR 052) + */ + private clipboardWrite(write: number): void { + const C = this.L.clipboardWrite; + const dv = this.dv(); + let result = C.UNSUPPORTED; + if (dv.getInt32(write + C.location, true) === C.STANDARD) { + const contents = dv.getUint32(write + C.contents, true); + const count = dv.getUint32(write + C.contentsLen, true); + if (count === 0) { + this.pendingClipboard = ''; + result = C.SUCCESS; + } + for (let i = 0; i < count; i++) { + const content = contents + i * C.contentSize; + const mime = this.stringAt(content + C.mime); + if (!isTextMime(new TextDecoder().decode(mime))) continue; + this.pendingClipboard = new TextDecoder().decode(this.stringAt(content + C.data)); + result = C.SUCCESS; + break; + } + } + const reply = this.at.clipReply; + const out = this.dv(); + new Uint8Array(this.ex.memory.buffer, reply, C.replySize).fill(0); + out.setUint32(reply + C.replySizeField, C.replySize, true); + out.setInt32(reply + C.replyResult, result, true); + const fn = this.ex.__indirect_function_table.get(out.getUint32(write + C.reply, true)) as ( + write: number, + reply: number + ) => void; + fn(write, reply); + } + + /** A copy of the bytes a `GhosttyString` at `ptr` points to. */ + private stringAt(ptr: number): Uint8Array { + const S = this.L.string; + const dv = this.dv(); + const data = dv.getUint32(ptr + S.ptr, true); + const len = dv.getUint32(ptr + S.len, true); + return len === 0 ? new Uint8Array(0) : this.u8().slice(data, data + len); } private applyConfig(config: GhosttyTerminalConfig | undefined): void { diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index d09eb44..409f88f 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -27,6 +27,9 @@ export interface ITerminalOptions { copyOnSelect?: boolean; // Copy a mouse selection to the clipboard when it is made (default: true) (webtty) rightClickBehavior?: RightClickBehavior; // What a right-click the app does not take does (default: 'contextMenu') (webtty, ADR 051) + // Clipboard options + clipboardWrite?: boolean; // Report program clipboard writes (OSC 52) through onClipboardWrite; read when the terminal is created (default: true) (webtty, ADR 052) + // The Ghostty WASM instance, from Ghostty.load() (webtty: upstream's init() and its // module-level instance are removed) ghostty: Ghostty; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 70d7121..9841791 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -207,6 +207,7 @@ export class Terminal implements ITerminalCore { private titleChangeEmitter = new EventEmitter(); private scrollEmitter = new EventEmitter(); private pasteRequestEmitter = new EventEmitter(); + private clipboardWriteEmitter = new EventEmitter(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent = this.dataEmitter.event; public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; @@ -218,6 +219,12 @@ export class Terminal implements ITerminalCore { * embedder reads the clipboard, as for Ctrl+V. (webtty, ADR 051) */ public readonly onPasteRequest: IEvent = this.pasteRequestEmitter.event; + /** + * The program wrote the clipboard (OSC 52, OSC 1337 Copy, OSC 5522), with the text to + * write, `''` to clear it. Fires only while the `clipboardWrite` option is on; the + * embedder writes the browser clipboard. (webtty, ADR 052) + */ + public readonly onClipboardWrite: IEvent = this.clipboardWriteEmitter.event; // Lifecycle state private isOpen = false; @@ -274,6 +281,7 @@ export class Terminal implements ITerminalCore { mouseScrollSpeed: options.mouseScrollSpeed ?? 1, copyOnSelect: options.copyOnSelect ?? true, rightClickBehavior: options.rightClickBehavior ?? 'contextMenu', + clipboardWrite: options.clipboardWrite ?? true, }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) @@ -445,6 +453,7 @@ export class Terminal implements ITerminalCore { palette: paletteColors, cursorStyle: this.options.cursorStyle, cursorBlink: this.options.cursorBlink, + clipboardWrite: this.options.clipboardWrite, }; } @@ -691,10 +700,12 @@ export class Terminal implements ITerminalCore { // These need to be sent back to the PTY via onData this.processTerminalResponses(); - // Bell and title (OSC 0/2) as Ghostty reported them (webtty, ADR 042) + // Bell, title (OSC 0/2) and clipboard writes (OSC 52) as Ghostty reported them + // (webtty, ADR 042, ADR 052) const effects = this.wasmTerm!.readEffects(); if (effects.bell) this.bellEmitter.fire(); if (effects.title !== null) this.setTitle(effects.title); + if (effects.clipboard !== null) this.clipboardWriteEmitter.fire(effects.clipboard); // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index 93eaf07..a3979b8 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -298,6 +298,12 @@ export interface GhosttyTerminalConfig { */ cursorStyle?: 'block' | 'underline' | 'bar'; cursorBlink?: boolean; + /** + * Whether programs may write the clipboard (OSC 52, OSC 1337 Copy, OSC 5522): the + * writes are reported by `readEffects`. Off, Ghostty's clipboard write callback is + * left unset and the writes are ignored. Default true. (webtty, ADR 052) + */ + clipboardWrite?: boolean; } /** diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index d94b4b1..d71447f 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -630,10 +630,10 @@ describe('official engine: effects and replies', () => { test('reports the title and the bell once', () => { const t = term(); t.write('\x1b]2;first\x07'); - expect(t.readEffects()).toEqual({ bell: false, title: 'first' }); + expect(t.readEffects()).toEqual({ bell: false, title: 'first', clipboard: null }); t.write('\x07\x1b]0;second\x1b\\'); - expect(t.readEffects()).toEqual({ bell: true, title: 'second' }); - expect(t.readEffects()).toEqual({ bell: false, title: null }); + expect(t.readEffects()).toEqual({ bell: true, title: 'second', clipboard: null }); + expect(t.readEffects()).toEqual({ bell: false, title: null, clipboard: null }); t.free(); }); @@ -719,6 +719,87 @@ describe('official engine: effects and replies', () => { }); }); +describe('official engine: clipboard writes (ADR 052)', () => { + const b64 = (bytes: string | Uint8Array) => Buffer.from(bytes).toString('base64'); + /** The clipboard text one write reports, or null for none. */ + function copied(seq: string, config: object = THEME): string | null { + const t = term(80, 24, config); + t.write(seq); + const { clipboard } = t.readEffects(); + expect(t.readEffects().clipboard).toBeNull(); + // OSC 52 has no reply: nothing is ever sent back, query or write. + expect(t.readResponse()).toBeNull(); + t.free(); + return clipboard; + } + + test('OSC 52 to the clipboard, ended by BEL or ST, decoded as UTF-8', () => { + expect(copied(`\x1b]52;c;${b64('hello')}\x07`)).toBe('hello'); + expect(copied(`\x1b]52;c;${b64('héllo 中文 🎉\nline 2')}\x1b\\`)).toBe('héllo 中文 🎉\nline 2'); + }); + + test('an empty or unknown selection is the clipboard, as in Ghostty', () => { + expect(copied(`\x1b]52;;${b64('x')}\x07`)).toBe('x'); + expect(copied(`\x1b]52;0;${b64('zero')}\x07`)).toBe('zero'); + }); + + test('the selection and primary clipboards are not written', () => { + expect(copied(`\x1b]52;s;${b64('sel')}\x07`)).toBeNull(); + expect(copied(`\x1b]52;p;${b64('pri')}\x07`)).toBeNull(); + // Ghostty takes one selection character only. + expect(copied(`\x1b]52;pc;${b64('both')}\x07`)).toBeNull(); + }); + + test('an empty payload clears the clipboard', () => { + expect(copied('\x1b]52;c;\x07')).toBe(''); + }); + + test('a query is never answered', () => { + expect(copied('\x1b]52;c;?\x07')).toBeNull(); + }); + + test('invalid base64 is ignored whole; missing padding is accepted', () => { + expect(copied('\x1b]52;c;!!!!\x07')).toBeNull(); + expect(copied(`\x1b]52;c;${b64('ok').slice(0, 3)}?\x07`)).toBeNull(); + expect(copied('\x1b]52;c;aGk\x07')).toBe('hi'); + }); + + test('bytes that are not UTF-8 become U+FFFD', () => { + expect(copied(`\x1b]52;c;${b64(new Uint8Array([0x61, 0xff, 0x62]))}\x07`)).toBe('a\ufffdb'); + }); + + test('iTerm2 OSC 1337 Copy reaches the clipboard too', () => { + expect(copied(`\x1b]1337;Copy=:${b64('iterm')}\x07`)).toBe('iterm'); + }); + + test('a sequence split across writes is reported when it ends; the last write wins', () => { + const t = term(); + t.write(`\x1b]52;c;${b64('split')}`); + expect(t.readEffects().clipboard).toBeNull(); + t.write(`\x07\x1b]52;c;${b64('first')}\x07\x1b]52;c;${b64('last')}\x07`); + expect(t.readEffects().clipboard).toBe('last'); + t.free(); + }); + + test('a sequence over 8 MiB is dropped, and the terminal carries on', () => { + const t = term(); + const fits = 'a'.repeat(5 * 1024 * 1024); + t.write(`\x1b]52;c;${b64(fits)}\x07`); + expect(t.readEffects().clipboard?.length).toBe(fits.length); + t.write(`\x1b]52;c;${b64('a'.repeat(6 * 1024 * 1024))}\x07after`); + expect(t.readEffects().clipboard).toBeNull(); + expect(rowText(t, 0)).toBe('after'); + t.free(); + }); + + test('with clipboardWrite off, nothing is reported', () => { + const off = { ...THEME, clipboardWrite: false }; + expect(copied(`\x1b]52;c;${b64('hello')}\x07`, off)).toBeNull(); + expect(copied('\x1b]52;c;\x07', off)).toBeNull(); + expect(copied(`\x1b]1337;Copy=:${b64('iterm')}\x07`, off)).toBeNull(); + }); +}); + describe('official engine: memory growth', () => { test('reads correct cells and replies after the wasm memory has grown', () => { const t = term(120, 40, { scrollbackLines: 0 }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index f16de20..8a80c44 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -285,14 +285,18 @@ describe('Terminal bell and title', () => { // (ADR 047). test('a bell and a title reported by readEffects reach onBell and onTitleChange', () => { const term = new Terminal({ ghostty: {} as never }); - let effects: { bell: boolean; title: string | null } = { bell: true, title: 'vim' }; + let effects: { bell: boolean; title: string | null; clipboard: string | null } = { + bell: true, + title: 'vim', + clipboard: null, + }; (term as unknown as { wasmTerm: object }).wasmTerm = { write: () => {}, hasResponse: () => false, readResponse: () => null, readEffects: () => { const e = effects; - effects = { bell: false, title: null }; + effects = { bell: false, title: null, clipboard: null }; return e; }, }; @@ -313,6 +317,37 @@ describe('Terminal bell and title', () => { }); }); +describe('Terminal clipboard writes', () => { + // Program clipboard writes come from the engine's readEffects and reach the embedder + // through onClipboardWrite (ADR 052). + test('OSC 52 output fires onClipboardWrite with the decoded text, once', async () => { + const ghostty = await Ghostty.load(WASM); + const term = new Terminal({ ghostty: {} as never }); + (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); + const write = (data: string) => + (term as unknown as { writeInternal(data: string): void }).writeInternal(data); + const copied: string[] = []; + term.onClipboardWrite((text) => copied.push(text)); + + write(`\x1b]52;c;${Buffer.from('héllo 中文').toString('base64')}\x07`); + expect(copied).toEqual(['héllo 中文']); + write('plain output'); + write('\x1b]52;c;\x07'); + expect(copied).toEqual(['héllo 中文', '']); + }); + + test('passes clipboardWrite to the engine, on by default', () => { + const wasmConfig = (options: object) => + ( + new Terminal({ ghostty: {} as never, ...options }) as unknown as { + buildWasmConfig(): GhosttyTerminalConfig; + } + ).buildWasmConfig(); + expect(wasmConfig({}).clipboardWrite).toBe(true); + expect(wasmConfig({ clipboardWrite: false }).clipboardWrite).toBe(false); + }); +}); + describe('Terminal link hover', () => { // The engine's hyperlink_id is a 1-bit "has a link" flag, so every OSC 8 cell carries // the same value: the hover is the hovered link's range, never an id. From a09785bbeb04c923c3e082bb45dcacdbcf58c056 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:23:06 -0400 Subject: [PATCH 094/295] feat(client): write the browser clipboard for OSC 52, held back after a connect until input --- src/client/clipboard-write.test.ts | 62 +++++++++++++++++++++++++++++ src/client/clipboard-write.ts | 64 ++++++++++++++++++++++++++++++ src/client/index.ts | 16 ++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/client/clipboard-write.test.ts create mode 100644 src/client/clipboard-write.ts diff --git a/src/client/clipboard-write.test.ts b/src/client/clipboard-write.test.ts new file mode 100644 index 0000000..e6edb35 --- /dev/null +++ b/src/client/clipboard-write.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { ClipboardWriter } from './clipboard-write'; + +function setup(clipboard: 'ok' | 'refuse' | 'none' = 'ok') { + const written: string[] = []; + const logs: string[] = []; + const api = + clipboard === 'none' + ? undefined + : { + writeText: async (text: string) => { + if (clipboard === 'refuse') throw new Error('Document is not focused.'); + written.push(text); + }, + }; + const writer = new ClipboardWriter(api, (m) => logs.push(m)); + return { writer, written, logs }; +} + +describe('ClipboardWriter', () => { + test('writes the text, UTF-8 and empty included, after user input', async () => { + const { writer, written, logs } = setup(); + writer.connected(); + writer.userInput(); + await writer.write('héllo 中文 🎉'); + await writer.write(''); + expect(written).toEqual(['héllo 中文 🎉', '']); + expect(logs).toEqual([]); + }); + + test('ignores writes from the replay, before any input since connecting', async () => { + const { writer, written, logs } = setup(); + await writer.write('from the replay'); + writer.userInput(); + await writer.write('typed'); + // A reconnect replays again. + writer.connected(); + await writer.write('replayed again'); + expect(written).toEqual(['typed']); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('before any input'); + }); + + test('logs a refused write once, with no error thrown', async () => { + const { writer, logs } = setup('refuse'); + writer.userInput(); + await writer.write('a'); + await writer.write('b'); + expect(logs).toEqual([ + 'webtty: the browser refused an OSC 52 clipboard write: Document is not focused.', + ]); + }); + + test('logs once when the page has no clipboard API', async () => { + const { writer, logs } = setup('none'); + writer.userInput(); + await writer.write('a'); + await writer.write('b'); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('no clipboard API'); + }); +}); diff --git a/src/client/clipboard-write.ts b/src/client/clipboard-write.ts new file mode 100644 index 0000000..692d989 --- /dev/null +++ b/src/client/clipboard-write.ts @@ -0,0 +1,64 @@ +/** The part of `navigator.clipboard` a program's clipboard write uses. */ +export interface TextClipboard { + writeText(text: string): Promise; +} + +/** + * Writes the browser clipboard for a program's OSC 52 (ADR 052). The terminal decodes + * the sequence; this decides whether the write happens and reports why not, once per + * reason, on the console. There is no error UI: the program cannot be told either way. + * + * Writes are held back after each (re)connect until the user presses a key, clicks or + * pastes in the tab. The server replays the session's recent output on connect, and an + * OSC 52 in it would otherwise overwrite the clipboard on every reload with something + * copied long ago. + * + * The browser may still refuse: Chrome writes only while the document has focus, + * Firefox only shortly after user input, Safari only inside the input event itself. + */ +export class ClipboardWriter { + private armed = false; + private readonly logged = new Set(); + + constructor( + private readonly clipboard: TextClipboard | undefined, + private readonly log: (message: string) => void = (message) => console.warn(message), + ) {} + + /** A connection opened: the output that follows starts with the replay. */ + connected(): void { + this.armed = false; + } + + /** The user pressed a key, clicked or pasted in this tab. */ + userInput(): void { + this.armed = true; + } + + /** Writes `text` (`''` clears) to the clipboard if allowed; settles when done. */ + async write(text: string): Promise { + if (!this.armed) { + this.once('replay', 'webtty: ignored an OSC 52 clipboard write before any input in this tab'); + return; + } + if (!this.clipboard) { + this.once( + 'api', + 'webtty: OSC 52 clipboard write ignored: no clipboard API (needs https or localhost)', + ); + return; + } + try { + await this.clipboard.writeText(text); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + this.once('refused', `webtty: the browser refused an OSC 52 clipboard write: ${reason}`); + } + } + + private once(key: string, message: string): void { + if (this.logged.has(key)) return; + this.logged.add(key); + this.log(message); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 66053f2..cd63af6 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,5 @@ import { OutputAck } from './ack'; +import { ClipboardWriter } from './clipboard-write'; import { Composer } from './compose'; import { EdgeExtender } from './edge'; import { computeInsets } from './fit'; @@ -52,6 +53,7 @@ interface ClientConfig { theme: Theme; copyOnSelect: boolean; rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none'; + clipboardWrite: boolean; mouseScrollSpeed: number; padding: number; keyboardBindings: KeyboardBinding[]; @@ -89,6 +91,8 @@ const term = new Terminal({ // What a right-click the app does not take does; the terminal handles every // contextmenu, and asks for a paste through onPasteRequest (ADR 051) rightClickBehavior: config.rightClickBehavior, + // Programs may write the clipboard with OSC 52; off, Ghostty ignores it (ADR 052) + clipboardWrite: config.clipboardWrite, theme: terminalTheme, }); @@ -205,12 +209,24 @@ const graphics = new KittyGraphics(term, container, (data: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }); +// A program's OSC 52 clipboard write, decoded by Ghostty, written to the browser +// clipboard once the user has used this tab since connecting (ADR 052). +const clipboardWriter = new ClipboardWriter(navigator.clipboard); +term.onClipboardWrite((text) => { + void clipboardWriter.write(text); +}); +for (const type of ['keydown', 'mousedown', 'paste', 'drop'] as const) { + window.addEventListener(type, () => clipboardWriter.userInput(), { capture: true }); +} + function connect(): void { // flow=1: this client acknowledges output as it parses it, so the server paces it // (ADR 048). const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}/pty?cols=${term.cols}&rows=${term.rows}&flow=1`; const socket = new WebSocket(wsUrl); ws = socket; + // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) + clipboardWriter.connected(); const ack = new OutputAck((message) => { if (socket.readyState === WebSocket.OPEN) socket.send(message); }); From 912f19b2c649f3b00e78b9553347af331ed7b4ad Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:23:06 -0400 Subject: [PATCH 095/295] docs(client): record OSC 52 clipboard writes and clipboardWrite in the specs --- docs/specs/client.md | 16 ++++++++++++++++ docs/specs/config.md | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index a92ed83..a5a789c 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -72,6 +72,7 @@ The vendored TS is excluded from Biome so it stays byte-identical to upstream un scrollback, // the server's replay buffer; the client does not read it (ADR 047) scrollbackLines, // history in lines, 0 = no line limit, 32 MiB ceiling (ADR 046) theme, copyOnSelect, rightClickBehavior, + clipboardWrite, // Terminal option: report OSC 52 clipboard writes (ADR 052) mouseScrollSpeed, // Terminal option: SGR wheel ticks per line/page event when the app tracks the mouse padding, // px around the canvas, used by fit(); coloured by theme.padding or theme.background keyboardBindings // used by the keydown capture handler, not passed to Terminal constructor @@ -169,6 +170,20 @@ The terminal has the one `contextmenu` listener (`handleContextMenu`). A menu no What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). +## Program Clipboard Writes + +Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; BEL` or ST), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH ([ADR 052](../adrs/052.client.osc52-clipboard-write.md)). Ghostty parses and decodes it (also iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522) and calls the binding's `CLIPBOARD_WRITE` callback, installed only while `clipboardWrite` is on: + +| Sequence | Result | +|---|---| +| `52;c;`, `52;;`, any selection other than `s`/`p` | the clipboard gets the text, decoded as UTF-8 (invalid bytes become U+FFFD) | +| `52;s;…`, `52;p;…`, `52;pc;…` | nothing, as in Ghostty on macOS | +| `52;c;` (empty) | the clipboard is cleared (set to `''`) | +| `52;c;?` (query) | no answer, ever | +| invalid base64, or a sequence over 8 MiB (about 6 MiB of text) | ignored whole; missing padding is accepted | + +`readEffects` reports the last write since the previous call, `Terminal.onClipboardWrite` fires with it, and `index.ts` passes it to `ClipboardWriter` (`clipboard-write.ts`), which calls `navigator.clipboard.writeText`. After each (re)connect it ignores writes until a `keydown`, `mousedown`, `paste` or `drop` in the tab, so the replayed history does not overwrite the clipboard on reload. A missing clipboard API (http other than localhost), a refused write (no focus in Chrome, no recent input in Firefox, most writes in Safari) and a write held back for the replay are each logged to the console once; there is no error UI. + ## Behavior Notes ### Tab close on session end @@ -192,6 +207,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Output acknowledgements | Output is acknowledged as it is parsed, so the server sends it in order at the tab's pace; a tab closed with `4003` for falling behind resets and reconnects to the replay | [ADR 048](../adrs/048.webtty.output-flow-control.md) | ✅ | | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 65aec5d..113f91d 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -91,7 +91,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `clipboardWrite`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -115,6 +115,7 @@ All keys are optional — omit any key to use the default value. | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. DEC mode 12 (`CSI ? 12 h/l`) is ignored, as in Ghostty when `cursor-style-blink` is set, so an app that turns blinking off that way (vim's `t_ve`) does not leave the cursor steady. | | `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released (kitty / Windows Terminal style). While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `rightClickBehavior` | string | `"contextMenu"` | What a right-click does when the app does not take it: `"contextMenu"` shows the browser menu; `"copyPaste"` copies and clears an existing selection, otherwise shows the menu; `"paste"` pastes the clipboard as Ctrl+V does; `"none"` does nothing and shows no menu. While the app tracks the mouse, a right-click goes only to the app and no menu is shown; Shift+right-click applies this setting instead and sends nothing to the app. `"default"`, the old name of `"contextMenu"`, is still accepted; invalid values fall back to `"contextMenu"`. Windows Terminal users may prefer `"copyPaste"`. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | +| `clipboardWrite` | boolean | `true` | Let programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; BEL`), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH; iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522 writes too. Writes only: a query (`52;c;?`) is never answered. Only the clipboard (`c`, or an empty or unknown selection) is written; `s` and `p` are ignored, as in Ghostty on macOS. A write is held back after each connect until you press a key or click in the tab, so the replayed history never overwrites the clipboard, and the browser may still refuse it (Chrome needs focus, Firefox recent input, Safari refuses most): the console says so once. `false` ignores all program clipboard writes. See [ADR 052](../adrs/052.client.osc52-clipboard-write.md). | | `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | @@ -177,6 +178,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "cursorStyleBlink": true, "copyOnSelect": true, "rightClickBehavior": "contextMenu", + "clipboardWrite": true, "fontSize": 13, "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", @@ -219,6 +221,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Hot config reload | Appearance re-read on tab reload; shell/PTY settings re-read on new PTY spawn; `port`/`host` locked for server lifetime | [ADR 009](../adrs/009.webtty.config-hot-reload.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double clicks and right-clicks go only to the app (no selection, copy or menu), Shift to bypass; `rightClickBehavior` gains `"contextMenu"` (default, was `"default"`), `"paste"` and `"none"` | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | +| Program clipboard writes | `clipboardWrite` (default `true`) lets programs write the clipboard with OSC 52 (writes only, standard clipboard only, held back until input after each connect); `false` ignores them | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime, and Ps 0 restores it | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | From 512fbd42586cfb5d93ecb9063e9c92749728a57d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:37:10 -0400 Subject: [PATCH 096/295] fix(client): keep the selection on mouse reports, so a Shift selection survives mode 1003 hover The input handler marks mouse reports, and Terminal.handleInput clears the selection only for keys, text and paste. Before, every report cleared it, so in an app with any-motion tracking a Shift selection vanished at the next hover and Shift+right-click (copyPaste) or the menu's Copy found nothing. Terminal.dispose also drops onClipboardWrite listeners. --- src/client/vendor/ghostty-web/PROVENANCE.md | 4 +- .../vendor/ghostty-web/input-handler.ts | 10 ++- src/client/vendor/ghostty-web/terminal.ts | 24 +++--- src/client/vendor/input-handler.test.ts | 17 ++++- src/client/vendor/terminal.test.ts | 73 +++++++++++++++++++ 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index bbd125e..d859aa6 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -38,6 +38,6 @@ webtty edits to these files are tracked in git from the commit that added them; - `addons/fit.ts`, `interfaces.ts`, `providers/osc8-link-provider.ts`, `link-detector.ts`, `renderer.ts`, `types.ts`: unreachable code removed. `FitAddon.fit` and `observeResize` with their resize state (webtty resizes from `proposeDimensions`, ADR 033), so `ITerminalCore` keeps only `element`; `OSC8LinkProvider.findLinkRange` (private, never called); `LinkDetector.invalidateRows`; the renderer's `setTheme` and the ANSI `palette` array nothing read; `ILink.dispose`. - `ghostty.ts`, `input-handler.ts`, `terminal.ts`, `wheel.ts`: mouse reports are encoded by Ghostty's mouse encoder (ADR 049). The binding adds `setMouseGeometry` and `encodeMouse` (`ghostty_mouse_encoder_*`, `ghostty_mouse_event_*`, with Ghostty's per-cell motion deduplication, the tracking mode and format set from the terminal when a mouse mode changes). Upstream's `encodeMouseSGR`, `encodeMouseX10` and `pixelToCell` go, with webtty's `mouseMotionButton` and `isNewMouseCell` (Ghostty reports hover as 35 and motion once per cell), and `MouseTrackingConfig` becomes `hasMouseTracking` and `encode`. Upstream sent Meta as Alt and dropped Alt, sent the back and forward buttons as codes 3 and 4, knew only the SGR and X10 formats, and clamped the cell from below only; `mouseMods` and `MOUSE_BUTTONS` follow Ghostty (Shift, Alt, Ctrl; buttons 8 and 9). Move and release are heard on the document, so a drag past the terminal's edge keeps reporting and its release is sent; back and forward are `preventDefault`ed while reported. `terminal.ts` maps client positions to device-pixel cells with the container as the screen (`mouseSurface`), drops reports that are not valid UTF-8 (`mouseReportText`), and encodes each wheel tick as a press of button 4 or 5; `wheelSgr` is removed. - `ghostty.ts`, `terminal.ts`: pastes are encoded by `ghostty_terminal_paste` (ADR 049). The binding's new `paste` passes the text as a `text/plain` clipboard paste with a mime reader callback and `allow_unsafe` set, and the bytes come back through `WRITE_PTY`. `Terminal.paste`, removed above as unused, is back with Ghostty's encoding: bracketed whenever mode 2004 is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004; upstream bracketed the text unchanged and sent it raw otherwise. -- `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. -- `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one. Upstream ignored OSC 52. +- `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The input handler passes `mouseReport` to its data callback for mouse reports, and the terminal's `handleInput` clears the selection only for other input; upstream cleared it for every report, so a Shift selection vanished at the next hover in mode 1003. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. +- `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 9487652..ae954d9 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -230,7 +230,8 @@ export class InputHandler { private encoder: KeyEncoder; private container: HTMLElement; private inputElement?: HTMLElement; - private onDataCallback: (data: string) => void; + // mouseReport is true for a mouse report, which leaves the selection alone (webtty, ADR 051) + private onDataCallback: (data: string, mouseReport?: boolean) => void; private getModeCallback?: (mode: number) => boolean; private onCopyCallback?: () => boolean; private mouseConfig?: MouseTrackingConfig; @@ -264,7 +265,8 @@ export class InputHandler { * Create a new InputHandler * @param ghostty - Ghostty instance (for creating KeyEncoder) * @param container - DOM element to attach listeners to - * @param onData - Callback for terminal data (escape sequences to send to PTY) + * @param onData - Callback for terminal data (escape sequences to send to PTY); mouseReport + * is true for a mouse report (webtty, ADR 051) * @param getMode - Optional callback to query terminal mode state (for application cursor mode) * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) * @param inputElement - Optional input element for beforeinput events @@ -273,7 +275,7 @@ export class InputHandler { constructor( ghostty: Ghostty, container: HTMLElement, - onData: (data: string) => void, + onData: (data: string, mouseReport?: boolean) => void, getMode?: (mode: number) => boolean, onCopy?: () => boolean, inputElement?: HTMLElement, @@ -642,7 +644,7 @@ export class InputHandler { clientY: event.clientY, anyButtonPressed: this.mouseButtonsPressed !== 0, }); - if (data) this.onDataCallback(data); + if (data) this.onDataCallback(data, true); } /** diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 9841791..d34ea2d 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -580,17 +580,7 @@ export class Terminal implements ITerminalCore { this.inputHandler = new InputHandler( this.ghostty!, parent, - (data: string) => { - // Check if stdin is disabled - if (this.options.disableStdin) { - return; - } - // Clear selection when user types - this.selectionManager?.clearSelection(); - this.awaitingEcho = true; - // Input handler fires data events - this.dataEmitter.fire(data); - }, + this.handleInput, (mode: number) => { // Query terminal mode state (e.g., mode 1 for application cursor mode) return this.wasmTerm?.getMode(mode, false) ?? false; @@ -1034,6 +1024,7 @@ export class Terminal implements ITerminalCore { this.titleChangeEmitter.dispose(); this.scrollEmitter.dispose(); this.pasteRequestEmitter.dispose(); + this.clipboardWriteEmitter.dispose(); } // ========================================================================== @@ -1563,6 +1554,17 @@ export class Terminal implements ITerminalCore { return mouseReportText(this.wasmTerm.encodeMouse({ ...input, x, y })); } + /** + * Input from the InputHandler. Keys and text clear the selection; a mouse report leaves + * it alone, so a Shift selection outlives the app's hover and clicks. (webtty, ADR 051) + */ + private handleInput = (data: string, mouseReport = false): void => { + if (this.options.disableStdin) return; + if (!mouseReport) this.selectionManager?.clearSelection(); + this.awaitingEcho = true; + this.dataEmitter.fire(data); + }; + private handleMenuPress = (e: MouseEvent): void => { this.menuSource.press(e); }; diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/input-handler.test.ts index 2066594..62e3ac6 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/input-handler.test.ts @@ -71,10 +71,14 @@ describe('InputHandler mouse', () => { }; const encoded: MouseEventReport[] = []; const sent: string[] = []; + const reports: boolean[] = []; const handler = new InputHandler( ghostty, container as unknown as HTMLElement, - (data) => sent.push(data), + (data, mouseReport) => { + sent.push(data); + reports.push(mouseReport ?? false); + }, undefined, undefined, undefined, @@ -107,7 +111,7 @@ describe('InputHandler mouse', () => { }, }; }; - return { handler, container, doc, encoded, sent, mouse }; + return { handler, container, doc, encoded, sent, reports, mouse }; } test('reports a press, then a drag and its release past the terminal edge', async () => { @@ -130,6 +134,15 @@ describe('InputHandler mouse', () => { expect(doc.listeners.get('mousemove')).toEqual([]); }); + test('mouse reports are marked as such, so they leave the selection alone', async () => { + const { handler, container, doc, reports, mouse } = await setup(); + doc.fire('mousemove', mouse(true, {}).event); + container.fire('mousedown', mouse(true, { button: 0, buttons: 1 }).event); + doc.fire('mouseup', mouse(true, { button: 0 }).event); + expect(reports).toEqual([true, true, true]); + handler.dispose(); + }); + test('hover is reported over the terminal only, with no button', async () => { const { handler, doc, sent, mouse } = await setup(); doc.fire('mousemove', mouse(true, {}).event); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 8a80c44..1ca758a 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Ghostty } from './ghostty-web/ghostty'; +import { InputHandler } from './ghostty-web/input-handler'; import { LinkDetector } from './ghostty-web/link-detector'; import { OSC8LinkProvider } from './ghostty-web/providers/osc8-link-provider'; import { @@ -336,6 +337,17 @@ describe('Terminal clipboard writes', () => { expect(copied).toEqual(['héllo 中文', '']); }); + test('dispose drops onClipboardWrite listeners', () => { + const term = new Terminal({ ghostty: {} as never }); + const copied: string[] = []; + term.onClipboardWrite((text) => copied.push(text)); + term.dispose(); + ( + term as unknown as { clipboardWriteEmitter: { fire(t: string): void } } + ).clipboardWriteEmitter.fire('x'); + expect(copied).toEqual([]); + }); + test('passes clipboardWrite to the engine, on by default', () => { const wasmConfig = (options: object) => ( @@ -348,6 +360,67 @@ describe('Terminal clipboard writes', () => { }); }); +describe('Terminal input and the selection', () => { + // Keys and text clear the selection; mouse reports leave it, so a Shift selection in an + // app with any-motion mode 1003 survives the next hover or click (ADR 051). + test('a mouse report keeps the selection, a key clears it', async () => { + const ghostty = await Ghostty.load(WASM); + const term = new Terminal({ ghostty: {} as never }); + const internals = term as unknown as { selectionManager: unknown }; + let selection = 'hello'; + internals.selectionManager = { + clearSelection: () => { + selection = ''; + }, + }; + const sent: string[] = []; + term.onData((data) => sent.push(data)); + type Listener = (e: object) => void; + const listeners = new Map(); + const container = { + ownerDocument: undefined, + contains: () => true, + addEventListener: (type: string, l: Listener) => + listeners.set(type, [...(listeners.get(type) ?? []), l]), + removeEventListener: () => {}, + }; + const fire = (type: string, e: object) => { + for (const l of listeners.get(type) ?? []) l(e); + }; + const handler = new InputHandler( + ghostty, + container as unknown as HTMLElement, + (term as unknown as { handleInput(d: string, m?: boolean): void }).handleInput, + undefined, + undefined, + undefined, + { hasMouseTracking: () => true, encode: () => '\x1b[<35;4;4M' }, + ); + const mods = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; + fire('mousemove', { ...mods, target: container, buttons: 0, clientX: 1, clientY: 1 }); + fire('mousedown', { + ...mods, + target: container, + button: 0, + buttons: 1, + clientX: 1, + clientY: 1, + }); + expect(sent.length).toBe(2); + expect(selection).toBe('hello'); + fire('keydown', { + ...mods, + key: 'a', + code: 'KeyA', + isComposing: false, + preventDefault: () => {}, + }); + expect(sent[2]).toBe('a'); + expect(selection).toBe(''); + handler.dispose(); + }); +}); + describe('Terminal link hover', () => { // The engine's hyperlink_id is a 1-bit "has a link" flag, so every OSC 8 cell carries // the same value: the hover is the hovered link's range, never an id. From 8fa67181969e663965d08aa24fb0120a96afb690 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 05:37:10 -0400 Subject: [PATCH 097/295] docs(client): ADR 051 and client spec on mouse reports keeping the selection Also correct the keyboard menu note: a contextmenu aimed outside the terminal never reaches its listener and shows the browser menu. --- .../051.client.mouse-tracking-selection-and-right-click.md | 4 ++-- docs/specs/client.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md index 44c42a5..0298981 100644 --- a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md +++ b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md @@ -31,7 +31,7 @@ The user agreed the design below. A new module, `mouse-route.ts` in the vendored terminal, holds the rule, and both listeners call it: - `pressToApp(tracking, shiftKey)`: a press goes to the app when the app tracks the mouse and Shift is not held. Otherwise it is webtty's. -- When the press is the app's, `selection-manager.ts` starts no selection: no highlight, no copy on release, and a double or triple click selects no word or line. An existing selection is left alone. `input-handler.ts` reports the press, the drag and the release, as today. +- When the press is the app's, `selection-manager.ts` starts no selection: no highlight, no copy on release, and a double or triple click selects no word or line. An existing selection is left alone. `input-handler.ts` reports the press, the drag and the release, as today, and marks them as mouse reports: `Terminal` clears the selection for keys, text and paste, not for a mouse report, so a Shift selection outlives the app's hover (any-motion mode 1003) and clicks, and Shift+right-click or the menu's Copy still finds it. - When the press is webtty's because Shift is held, `input-handler.ts` reports nothing for that button: not the press, not the motion while it is held, not its release, even if Shift is let go mid-drag. `selection-manager.ts` selects and copies as it does without tracking (Ghostty and xterm do the same). - Without tracking, nothing changes: drag, double and triple click select, and the release copies when `copyOnSelect` is on. @@ -63,7 +63,7 @@ A menu is from the keyboard when no right press (or macOS Ctrl+click, which open - Shift+drag, Shift+double and Shift+triple click select in webtty in such an app, and the app gets nothing. Apps that read Shift+click (rare; Shift is the bypass in every native terminal) lose it. - A right-click in such an app shows no browser menu. Shift+right-click, or the keyboard, shows it (or does what `rightClickBehavior` says). - Without tracking (a shell, `less` without `--mouse`), the left button behaves as before, and right-click as before for `"contextMenu"` (the old `"default"`) and `"copyPaste"`. -- The keyboard menu depends on the browser opening one. While the terminal has focus, the Menu key and Shift+F10 are keys like any other: they are encoded and sent to the app (`CSI 29 ~`, `CSI 21 ; 2 ~`), as native terminals do, and the browser opens no menu (checked in headless Chrome). A menu opened from the keyboard while focus is elsewhere in the page (Chrome reports `button` -1), or by assistive technology, follows `rightClickBehavior`. +- The keyboard menu depends on the browser opening one. While the terminal has focus, the Menu key and Shift+F10 are keys like any other: they are encoded and sent to the app (`CSI 29 ~`, `CSI 21 ; 2 ~`), as native terminals do, and the browser opens no menu (checked in headless Chrome). A `contextmenu` aimed at the terminal with no right press before it, such as one opened by assistive technology, follows `rightClickBehavior`. One aimed elsewhere in the page never reaches the terminal's listener and shows the browser menu. - The decision is two small pure functions with unit tests; the listeners only call them. ## Considered Options diff --git a/docs/specs/client.md b/docs/specs/client.md index a5a789c..7e1f80d 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -166,7 +166,7 @@ Both can be active simultaneously. The canvas-based terminal has no DOM text sel | App tracks the mouse | only the app gets press, motion and release; no highlight, no copy | only the app gets it; browser menu cancelled | | App tracks the mouse, Shift held | webtty selects and copies; the app gets nothing for that button until it is released | `rightClickBehavior`; the app gets nothing | -The terminal has the one `contextmenu` listener (`handleContextMenu`). A menu not preceded by a right press (or macOS Ctrl+click) since the last key is a keyboard menu and follows `rightClickBehavior` even while the app tracks the mouse. While the terminal has focus, the Menu key and Shift+F10 are sent to the app as keys, so the browser opens no keyboard menu there. `"paste"` fires `Terminal.onPasteRequest`, which `index.ts` answers with the same clipboard read as Ctrl+V. +The terminal has the one `contextmenu` listener (`handleContextMenu`). Keys, text and paste clear the selection; mouse reports do not, so a Shift selection survives the app's hover and clicks until Shift+right-click or the menu's Copy uses it. A menu not preceded by a right press (or macOS Ctrl+click) since the last key is a keyboard menu and follows `rightClickBehavior` even while the app tracks the mouse. While the terminal has focus, the Menu key and Shift+F10 are sent to the app as keys, so the browser opens no keyboard menu there. `"paste"` fires `Terminal.onPasteRequest`, which `index.ts` answers with the same clipboard read as Ctrl+V. What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). From 747de10ba1ecc824a9020b998127f541bf2756e8 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Wed, 14 Oct 2026 10:46:31 -0400 Subject: [PATCH 098/295] docs(client): ADR 053, focus reports, the app's title and the bell --- ...053.client.focus-reports-title-and-bell.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/adrs/053.client.focus-reports-title-and-bell.md diff --git a/docs/adrs/053.client.focus-reports-title-and-bell.md b/docs/adrs/053.client.focus-reports-title-and-bell.md new file mode 100644 index 0000000..c68a37c --- /dev/null +++ b/docs/adrs/053.client.focus-reports-title-and-bell.md @@ -0,0 +1,85 @@ +# ADR 053: client — Focus reports, the app's title and the bell + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +Three things a program asks of its terminal reached webtty's engine and stopped there: + +- **Focus reports (DEC mode 1004).** Neovim and tmux (`focus-events on`) turn the mode on to hear `CSI I` when the terminal gains focus and `CSI O` when it loses it: Neovim runs `FocusGained` (`autoread` checks files then), tmux passes it to the active pane. webtty never sent either, so `:checktime` on focus never ran and tmux's own focus events were dead. +- **The title (OSC 0 / OSC 2).** `Terminal.onTitleChange` fires with the title Ghostty parsed (ADR 042, ADR 047), but `index.ts` set `document.title` to ` | webtty` once and never listened. Every webtty tab read the same, whatever ran in it. +- **The bell (BEL).** `Terminal.onBell` fires, and nothing listened. A job that rings when it is done (`make; tput bel`, an agent's terminal-bell notification) was silent. + +Ghostty's official libghostty-vt, at the pinned commit (`05dcf887`): + +- exports `ghostty_focus_encode(GhosttyFocusEvent, buf, len, out_written)`, which writes `CSI I` (`GAINED`) or `CSI O` (`LOST`); `GhosttyFocusEvent` is in the wasm's type manifest. The terminal itself does nothing for mode 1004 (`stream_terminal.zig`: `.focus_event => {}`); sending the reports is the embedder's job. +- reports the title through `TITLE_CHANGED` and the bell through `BELL`, read after each write (`readEffects`). An empty `OSC 2 ;` sets the title to `''`; RIS does not clear it. + +Ghostty the app, the embedder webtty follows: + +- **Focus.** `Surface.focusCallback` runs when the surface gains or loses keyboard focus, which includes the window becoming or ceasing to be the key window (another app, another window). `Termio.focusGained` sends the report only while mode 1004 is on. And setting the mode sends the current state at once (`termio/stream_handler.zig`: `.focus_event => if (enabled) messageWriter(.{ .focused = ... })`), so a program that turns it on learns where focus is without waiting for a change. +- **Title.** The window title is the program's title as it is (`computeTitle` in the macOS app), with a 🔔 in front while the bell is active. +- **Bell.** `bell-features` defaults to `attention` and `title` (`system`, `audio` and `border` are off). `title`: "Prepend a bell emoji (🔔) to the title of the alerted surface until the terminal is re-focused or interacted with (such as on keyboard input)". The macOS app sets the surface's bell on every BEL, focused or not, and clears it when the surface gains focus or gets a key down. `attention` bounces the Dock icon while the app is inactive. + +webtty has one thing a native terminal does not: the server replays the session's recent output on every connect (ADR 007, ADR 048). A BEL in that history would ring again on every reload and reconnect. The replay is not delimited (ADR 052), but it is sent as one burst: the server queues all of it in its `open` handler, at most 64 Ki characters a message and, at the default `scrollback` (256 KiB, under the 2 MiB high-water mark), without waiting for an acknowledgement (ADR 048). + +## Decision + +### Terminal focus is keyboard focus in a focused window + +The terminal has focus when the browser window has focus and keyboard focus is inside the terminal's container (its textarea, or the container on the way to it). That is Ghostty's surface focus: switching tabs, switching to another app or window, or clicking the address bar or devtools takes it away; coming back gives it back. A tab merely visible behind another window does not have it, as a Ghostty window that is not the key window does not. + +`Terminal` follows it with `focusin` and `focusout` on the container. A `focusout` whose `relatedTarget` is inside the container (the padding click that `handleFocusIn` sends on to the textarea) is not a change; a window blur is a `focusout` with no `relatedTarget`, and a window focus a `focusin`. Repeated events with no change send nothing. + +### Focus reports are Ghostty's encoder, sent as Ghostty sends them + +The binding gains `encodeFocus(focused)` (`ghostty_focus_encode`). `Terminal`: + +- on a focus change, sends the report through `onData` if mode 1004 is on; +- after each write, if mode 1004 went from off to on, sends the current state, as Ghostty does when the mode is set. Only the state after the write is compared, so a set that does not change the mode (it was already on), or an off-and-on inside one write, sends nothing; programs set it once when they start. + +The replay can turn the mode on, and the report then tells the program this tab's focus. That is the point of the report on set, and it is right for the tab that just connected. With two tabs on one session, each sends its own changes and the last one wins. + +### The tab title is the program's title + +`TabTitle` (`src/client/tab-title.ts`) owns `document.title`: ` | webtty` while the program has set a non-empty title, `<session> | webtty` otherwise (the old title). `index.ts` feeds it `onTitleChange`. A title in the replay is applied: it is state, the title the program last set, not an event. The ` | webtty` suffix stays, so webtty tabs still read as webtty in the tab strip and history. + +### The bell marks the title, Ghostty's `title` feature + +On a bell, `TabTitle` puts `🔔 ` in front of the title, focused or not, until the terminal gains focus or a key is pressed in the tab, as in Ghostty. A focused tab that rings (a failed Tab completion) shows the 🔔 until the next key; a tab in the background keeps it until the user comes back. Nothing else happens: no sound (Ghostty's `audio` and `system` are off by default, and a page may play sound only after user input), no flash (Ghostty's `border` is off by default), no notification (it needs a permission prompt, as `attention` needs none in Ghostty). + +**Bells in the replay are ignored.** After each connect the client treats output as replay until the first gap of 1 s with no output: `TabTitle.connected()` at socket open, `TabTitle.output(now)` before each message is parsed. The first message starts the replay; a message that arrives 1 s or more after the previous one ends it, and its bells and every later one count. The replay arrives as a burst, so a gap means it is over; a program that wrote without a pause since the connect has its bells ignored until it pauses. + +### Config + +`bellTitle` (boolean, default `true`), served by `/api/config` and read on every tab reload: `false` leaves the title alone on a bell, as Ghostty's `bell-features = no-title`. A boolean rather than Ghostty's feature list: `title` is the only feature webtty has. + +## Consequences + +- Neovim's `FocusGained` / `FocusLost` and `autoread` on focus, tmux `focus-events`, and focus-aware TUIs work. A program that enables the mode gets `CSI I` or `CSI O` at once. +- A tab that loses focus to the address bar, devtools or a browser dialog sends `CSI O`, as Ghostty does when its window loses key status to a panel. +- Two tabs on one session send interleaved reports; the program sees the latest. +- Tabs read as what runs in them: a shell that sets the title (`user@host: ~/dir`), vim's file name, Claude Code's title. A program that never sets one keeps `<session> | webtty`. A title from an earlier program stays until another is set, as in Ghostty. +- A bell shows as `🔔 ` in the tab title until the user focuses the terminal or presses a key. Reloads and reconnects do not mark the tab for bells already rung. +- A bell less than 1 s after the previous output, while the output has not paused since the connect, is not shown. A replay delayed by a gap of 1 s or more (a link slower than about 0.5 Mbit/s for a 64 Ki message, a stalled connection) can mark the tab for a replayed bell; the mark goes on the next focus or key. + +## Considered Options + +- **Focus from `document.visibilityState`, or from window focus alone.** Visibility misses a window behind another app, which Ghostty counts as unfocused; window focus alone misses focus moving to the address bar or devtools, and the textarea is the only input in the page anyway. +- **Report only changes, nothing on set.** Ghostty sends the state on set; programs rely on it to start in the right state. +- **Hold bells after each connect until user input, as OSC 52 writes are (ADR 052).** The bell's use is a tab the user is away from; after a reconnect in the background (laptop wake, server restart) every bell would be lost until the user came back. +- **A replay-end marker from the server.** Exact, but a protocol change for every client, rejected for the same reason in ADR 052. +- **Mark only while the terminal is unfocused.** Simpler on the eye, but Ghostty marks a focused surface too, and the next key clears it. +- **Flash the screen, play a sound, or post a notification.** Ghostty's defaults do none of the first two; a notification needs a permission prompt. Left for later behind more `bell*` keys. +- **`<title> | <session> | webtty`.** Longer than a tab shows; shells that set a title already name the host and directory. + +## Related Decisions + +- [ADR 007 — Session Client](007.webtty.session-client.md): the replay on connect +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md): bell and title through `readEffects` +- [ADR 048 — PTY output flow control](048.webtty.output-flow-control.md): how the replay is sent +- [ADR 052 — Programs write the clipboard with OSC 52](052.client.osc52-clipboard-write.md): the replay and side effects From c9395943df5dca6727720f3e9eee7543537fbf2a Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 10:48:18 -0400 Subject: [PATCH 099/295] feat(client): send mode 1004 focus reports, encoded by libghostty-vt --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 27 +++++++ src/client/vendor/ghostty-web/terminal.ts | 50 ++++++++++++ src/client/vendor/official-terminal.test.ts | 14 ++++ src/client/vendor/terminal.test.ts | 87 +++++++++++++++++++++ 5 files changed, 179 insertions(+) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index d859aa6..028e0d6 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -40,4 +40,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `terminal.ts`: pastes are encoded by `ghostty_terminal_paste` (ADR 049). The binding's new `paste` passes the text as a `text/plain` clipboard paste with a mime reader callback and `allow_unsafe` set, and the bytes come back through `WRITE_PTY`. `Terminal.paste`, removed above as unused, is back with Ghostty's encoding: bracketed whenever mode 2004 is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004; upstream bracketed the text unchanged and sent it raw otherwise. - `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The input handler passes `mouseReport` to its data callback for mouse reports, and the terminal's `handleInput` clears the selection only for other input; upstream cleared it for every report, so a Shift selection vanished at the next hover in mode 1003. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. +- `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. Upstream sent no focus reports. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 598817a..ba82dee 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -167,6 +167,10 @@ export interface GhosttyTerminal { */ encodeMouse(input: MouseInput): Uint8Array; + // Focus reports (ADR 053) + /** The mode 1004 report for a focus change, `CSI I` or `CSI O`, as Ghostty encodes it. */ + encodeFocus(focused: boolean): Uint8Array; + // Paste (ADR 049) /** * Pastes text as Ghostty does (`ghostty_terminal_paste`): bracketed while mode 2004 @@ -318,6 +322,7 @@ interface OfficialWasmExports { ghostty_mouse_event_set_mods(event: number, mods: number): void; /** The GhosttyMousePosition struct is passed by value, i.e. by pointer on wasm32. */ ghostty_mouse_event_set_position(event: number, position: number): void; + ghostty_focus_encode(event: number, buf: number, len: number, outWritten: number): number; } interface ManifestBits { @@ -750,6 +755,10 @@ function resolveOfficialLayout(layout: VtLayout) { SUCCESS: layout.value('GhosttyClipboardWriteResult', 'SUCCESS'), UNSUPPORTED: layout.value('GhosttyClipboardWriteResult', 'UNSUPPORTED'), }, + focus: { + GAINED: layout.value('GhosttyFocusEvent', 'GAINED'), + LOST: layout.value('GhosttyFocusEvent', 'LOST'), + }, result: { SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), @@ -981,6 +990,8 @@ const FALLBACK_BACKGROUND = 0x000000; const GRAPHEME_SCRATCH = 32; /** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ const MOUSE_REPORT_MAX = 64; +/** Room for one focus report (`CSI I` / `CSI O`, 3 bytes). */ +const FOCUS_REPORT_MAX = 8; /** * The modes that set the mouse encoder's tracking mode and format: X10, normal, button, * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. @@ -1139,6 +1150,7 @@ export class OfficialTerminal implements GhosttyTerminal { | 'mouseSize' | 'mousePos' | 'mouseOut' + | 'focusOut' | 'clipReply', number >; @@ -1269,6 +1281,7 @@ export class OfficialTerminal implements GhosttyTerminal { mouseSize: [L.mouse.size, 8], mousePos: [L.mouse.positionSize, 8], mouseOut: [MOUSE_REPORT_MAX, 8], + focusOut: [FOCUS_REPORT_MAX, 8], clipReply: [L.clipboardWrite.replySize, 8], } as const); this.scratch = scratch; @@ -1700,6 +1713,20 @@ export class OfficialTerminal implements GhosttyTerminal { return this.mouseEncoder; } + // ========================================================================== + // Focus reports (ADR 053) + // ========================================================================== + + encodeFocus(focused: boolean): Uint8Array { + const at = this.at; + const F = this.L.focus; + const event = focused ? F.GAINED : F.LOST; + const rc = this.ex.ghostty_focus_encode(event, at.focusOut, FOCUS_REPORT_MAX, at.u32); + if (rc !== 0) return new Uint8Array(0); + const n = this.dv().getUint32(at.u32, true); + return this.u8().slice(at.focusOut, at.focusOut + n); + } + // ========================================================================== // Paste (ADR 049) // ========================================================================== diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index d34ea2d..e1f25d9 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -116,6 +116,9 @@ export function mouseSurface( const MOUSE_REPORT_DECODER = new TextDecoder('utf-8', { fatal: true }); +/** DEC private mode 1004: the app hears `CSI I` / `CSI O` on focus changes. (webtty, ADR 053) */ +const FOCUS_EVENT_MODE = 1004; + /** * A mouse report as text for the PTY, or '' when it is not valid UTF-8. Input travels as * text frames that the server writes as UTF-8, so a report with raw bytes above 0x7f @@ -239,6 +242,11 @@ export class Terminal implements ITerminalCore { // Phase 1: Title tracking private currentTitle: string = ''; + // Focus reports (webtty, ADR 053): keyboard focus inside the container in a focused + // window, and whether mode 1004 was on after the last write. + private focused = false; + private focusReporting = false; + // Phase 2: Viewport and scrolling state public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) private customWheelEventHandler?: (event: WheelEvent) => boolean; @@ -501,6 +509,10 @@ export class Terminal implements ITerminalCore { // focus() on a background window moves activeElement without a focusin; catch // the container still holding focus when the window comes back. (webtty) window.addEventListener('focus', this.handleWindowFocus); + // Terminal focus for mode 1004: a window blur or focus reaches the focused element + // as focusout / focusin too. (webtty, ADR 053) + parent.addEventListener('focusin', this.handleFocusGained); + parent.addEventListener('focusout', this.handleFocusLost); // Add accessibility attributes for screen readers and extensions parent.setAttribute('role', 'textbox'); @@ -690,6 +702,11 @@ export class Terminal implements ITerminalCore { // These need to be sent back to the PTY via onData this.processTerminalResponses(); + // Ghostty reports the focus when the app turns mode 1004 on (webtty, ADR 053) + const focusReporting = this.wasmTerm!.getMode(FOCUS_EVENT_MODE); + if (focusReporting && !this.focusReporting) this.reportFocus(); + this.focusReporting = focusReporting; + // Bell, title (OSC 0/2) and clipboard writes (OSC 52) as Ghostty reported them // (webtty, ADR 042, ADR 052) const effects = this.wasmTerm!.readEffects(); @@ -818,6 +835,8 @@ export class Terminal implements ITerminalCore { // Reset title this.currentTitle = ''; + // The new engine starts with mode 1004 off (webtty, ADR 053) + this.focusReporting = false; } /** @@ -1042,6 +1061,35 @@ export class Terminal implements ITerminalCore { if (this.element && document.activeElement === this.element) this.textarea?.focus(); }; + /** Focus came into the container, or its window got focus back. (webtty, ADR 053) */ + private handleFocusGained = (): void => { + this.setFocused(true); + }; + + /** + * Focus left the container, or its window lost focus (no `relatedTarget`). A move + * inside it, the container passing focus on to the textarea, is not a change. + * (webtty, ADR 053) + */ + private handleFocusLost = (e: FocusEvent): void => { + const next = e.relatedTarget as Node | null; + if (next && this.element?.contains(next)) return; + this.setFocused(false); + }; + + private setFocused(focused: boolean): void { + if (focused === this.focused) return; + this.focused = focused; + if (this.wasmTerm?.getMode(FOCUS_EVENT_MODE)) this.reportFocus(); + } + + /** Sends `CSI I` or `CSI O` for the current focus, encoded by Ghostty. (webtty, ADR 053) */ + private reportFocus(): void { + if (this.options.disableStdin || !this.wasmTerm) return; + const report = new TextDecoder().decode(this.wasmTerm.encodeFocus(this.focused)); + if (report) this.dataEmitter.fire(report); + } + /** * The cursor cell's top-left corner in CSS pixels relative to the container's * padding box, or undefined before open(). (webtty) @@ -1194,6 +1242,8 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); this.element.removeEventListener('focusin', this.handleFocusIn); + this.element.removeEventListener('focusin', this.handleFocusGained); + this.element.removeEventListener('focusout', this.handleFocusLost); this.element.removeEventListener('mousedown', this.handleMenuPress, { capture: true }); this.element.removeEventListener('keydown', this.handleMenuKey, { capture: true }); this.element.removeEventListener('contextmenu', this.handleContextMenu, { diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index d71447f..7b7025b 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -800,6 +800,20 @@ describe('official engine: clipboard writes (ADR 052)', () => { }); }); +describe('official engine: focus reports (ADR 053)', () => { + test('encodes focus gained and lost as CSI I and CSI O', () => { + const t = term(); + const text = (focused: boolean) => new TextDecoder().decode(t.encodeFocus(focused)); + expect(text(true)).toBe('\x1b[I'); + expect(text(false)).toBe('\x1b[O'); + // The engine sends nothing itself when the mode is set: the embedder does. + t.write('\x1b[?1004h'); + expect(t.getMode(1004)).toBe(true); + expect(t.readResponse()).toBeNull(); + t.free(); + }); +}); + describe('official engine: memory growth', () => { test('reads correct cells and replies after the wasm memory has grown', () => { const t = term(120, 40, { scrollbackLines: 0 }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 1ca758a..16de5a2 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -295,6 +295,7 @@ describe('Terminal bell and title', () => { write: () => {}, hasResponse: () => false, readResponse: () => null, + getMode: () => false, readEffects: () => { const e = effects; effects = { bell: false, title: null, clipboard: null }; @@ -318,6 +319,92 @@ describe('Terminal bell and title', () => { }); }); +describe('Terminal focus reports', () => { + // Mode 1004 reports are encoded by Ghostty and sent as Ghostty's Surface sends them: + // on a focus change while the mode is on, and the current state when it is set + // (ADR 053). + async function focusTerm(options: object = {}) { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(20, 2); + const term = new Terminal({ ghostty: {} as never, ...options }); + const element = { contains: (node: unknown) => node === inside }; + const inside = {}; + const internals = term as unknown as { + isOpen: boolean; + wasmTerm: unknown; + element: unknown; + handleFocusGained(): void; + handleFocusLost(e: { relatedTarget: unknown }): void; + writeInternal(data: string): void; + }; + internals.wasmTerm = wasmTerm; + internals.isOpen = true; + internals.element = element; + const sent: string[] = []; + term.onData((data) => sent.push(data)); + return { + wasmTerm, + sent, + write: (data: string) => internals.writeInternal(data), + focusIn: () => internals.handleFocusGained(), + focusOut: (relatedTarget: unknown = null) => internals.handleFocusLost({ relatedTarget }), + inside, + }; + } + + test('sends nothing on focus changes while mode 1004 is off', async () => { + const { wasmTerm, sent, focusIn, focusOut } = await focusTerm(); + focusIn(); + focusOut(); + expect(sent).toEqual([]); + wasmTerm.free(); + }); + + test('sends the current focus when the app sets the mode, then each change', async () => { + const { wasmTerm, sent, write, focusIn, focusOut } = await focusTerm(); + focusIn(); + write('\x1b[?1004h'); + expect(sent).toEqual(['\x1b[I']); + focusOut(); + focusIn(); + expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I']); + // Setting it again while it is on, or other output, sends nothing. + write('\x1b[?1004hplain'); + expect(sent).toHaveLength(3); + write('\x1b[?1004l'); + focusOut(); + expect(sent).toHaveLength(3); + // Set again while unfocused: the report says so. + write('\x1b[?1004h'); + expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I', '\x1b[O']); + wasmTerm.free(); + }); + + test('a move inside the terminal, or a repeated event, is not a change', async () => { + const { wasmTerm, sent, write, focusIn, focusOut, inside } = await focusTerm(); + focusIn(); + write('\x1b[?1004h'); + // The container passing focus on to the textarea: out with an inside target, in. + focusOut(inside); + focusIn(); + focusIn(); + expect(sent).toEqual(['\x1b[I']); + focusOut(); + focusOut(); + expect(sent).toEqual(['\x1b[I', '\x1b[O']); + wasmTerm.free(); + }); + + test('sends nothing with stdin disabled', async () => { + const { wasmTerm, sent, write, focusIn, focusOut } = await focusTerm({ disableStdin: true }); + write('\x1b[?1004h'); + focusIn(); + focusOut(); + expect(sent).toEqual([]); + wasmTerm.free(); + }); +}); + describe('Terminal clipboard writes', () => { // Program clipboard writes come from the engine's readEffects and reach the embedder // through onClipboardWrite (ADR 052). From 58d61f409f459edfbf26543a39f88dc48e75078f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 10:48:38 -0400 Subject: [PATCH 100/295] feat(config): add bellTitle, served by /api/config --- src/config.test.ts | 17 +++++++++++++++++ src/config.ts | 4 ++++ src/server/routes.test.ts | 1 + src/server/routes.ts | 1 + 4 files changed, 23 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index 4e0dde4..fc387aa 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -257,6 +257,23 @@ describe('loadConfig — reads and merges', () => { } }); + test('defaults bellTitle to true', () => { + writeConfig(JSON.stringify({})); + expect(loadConfig().bellTitle).toBe(true); + }); + + test('overrides bellTitle when set to false', () => { + writeConfig(JSON.stringify({ bellTitle: false })); + expect(loadConfig().bellTitle).toBe(false); + }); + + test('ignores a bellTitle that is not a boolean', () => { + for (const value of ['false', 0, null]) { + writeConfig(JSON.stringify({ bellTitle: value })); + expect(loadConfig().bellTitle).toBe(true); + } + }); + test('overrides rightClickBehavior when set to copyPaste', () => { writeConfig(JSON.stringify({ rightClickBehavior: 'copyPaste' })); expect(loadConfig().rightClickBehavior).toBe('copyPaste'); diff --git a/src/config.ts b/src/config.ts index e081bd7..d064a14 100644 --- a/src/config.ts +++ b/src/config.ts @@ -109,6 +109,8 @@ export interface Config { rightClickBehavior: RightClickBehavior; /** Let programs write the clipboard with OSC 52 (write only: reads are never answered). `false` ignores the writes. */ clipboardWrite: boolean; + /** Put a 🔔 in front of the tab title when a program rings the bell (BEL), until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title`. Bells replayed on reconnect are ignored. */ + bellTitle: boolean; /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ mouseScrollSpeed: number; /** Padding in px around the terminal canvas, on all four sides. `0` = none. Coloured by `theme.padding`, or `theme.background` if that is unset. */ @@ -188,6 +190,7 @@ export const DEFAULT_CONFIG: Config = { copyOnSelect: true, rightClickBehavior: 'contextMenu' as RightClickBehavior, clipboardWrite: true, + bellTitle: true, mouseScrollSpeed: 1, padding: 0, logs: false, @@ -305,6 +308,7 @@ export function loadConfig(): Config { rightClickBehavior: parseRightClickBehavior(p.rightClickBehavior), }), ...(typeof p.clipboardWrite === 'boolean' && { clipboardWrite: p.clipboardWrite }), + ...(typeof p.bellTitle === 'boolean' && { bellTitle: p.bellTitle }), ...(typeof p.mouseScrollSpeed === 'number' && p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), ...(typeof p.padding === 'number' && diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index c4f3d26..e8be4d3 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -118,6 +118,7 @@ describe('server — routes', () => { expect(typeof body.copyOnSelect).toBe('boolean'); expect(typeof body.rightClickBehavior).toBe('string'); expect(body.clipboardWrite).toBe(true); + expect(body.bellTitle).toBe(true); expect(typeof body.mouseScrollSpeed).toBe('number'); expect(body.scrollbackLines).toBe(10000); expect(typeof body.padding).toBe('number'); diff --git a/src/server/routes.ts b/src/server/routes.ts index 9e23db6..641918d 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -123,6 +123,7 @@ export async function handleRequest( copyOnSelect: config.copyOnSelect, rightClickBehavior: config.rightClickBehavior, clipboardWrite: config.clipboardWrite, + bellTitle: config.bellTitle, mouseScrollSpeed: config.mouseScrollSpeed, padding: config.padding, keyboardBindings: config.keyboardBindings, From 8878892915d361b9ee97266e63bee5f451d30892 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 10:51:32 -0400 Subject: [PATCH 101/295] feat(client): show the app's title in the tab, with a bell mark Ghostty-style, replayed bells ignored --- src/client/index.ts | 18 +++++++- src/client/tab-title.test.ts | 88 ++++++++++++++++++++++++++++++++++++ src/client/tab-title.ts | 71 +++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/client/tab-title.test.ts create mode 100644 src/client/tab-title.ts diff --git a/src/client/index.ts b/src/client/index.ts index cd63af6..9d36ab2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,6 +6,7 @@ import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { nonTextPaste } from './paste'; import { SmoothScroll } from './smooth-scroll'; +import { TabTitle } from './tab-title'; import { toTerminalTheme } from './theme'; import { FitAddon, Ghostty, Terminal } from './vendor/ghostty-web'; import { watchDevicePixelRatio } from './vendor/ghostty-web/renderer'; @@ -54,6 +55,7 @@ interface ClientConfig { copyOnSelect: boolean; rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none'; clipboardWrite: boolean; + bellTitle: boolean; mouseScrollSpeed: number; padding: number; keyboardBindings: KeyboardBinding[]; @@ -63,7 +65,11 @@ const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); -document.title = `${sessionId} | webtty`; +// `<title> | webtty` from the program's OSC 0/2 title, or the session id, with a 🔔 +// after a bell until the terminal is attended; replayed bells are ignored (ADR 053). +const tabTitle = new TabTitle(sessionId, config.bellTitle, (title) => { + document.title = title; +}); // The vendored ghostty-web is bundled into /dist/client-browser.js, so its default // wasm lookup (relative to its own module URL) would depend on where the bundle sits. @@ -219,6 +225,13 @@ for (const type of ['keydown', 'mousedown', 'paste', 'drop'] as const) { window.addEventListener(type, () => clipboardWriter.userInput(), { capture: true }); } +// The title and the bell mark (ADR 053). Ghostty clears the mark when the terminal +// gains focus (a window focus reaches the container as a focusin too) or gets a key. +term.onTitleChange((title) => tabTitle.title(title)); +term.onBell(() => tabTitle.bell()); +container.addEventListener('focusin', () => tabTitle.attended()); +window.addEventListener('keydown', () => tabTitle.attended(), { capture: true }); + function connect(): void { // flow=1: this client acknowledges output as it parses it, so the server paces it // (ADR 048). @@ -227,6 +240,8 @@ function connect(): void { ws = socket; // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) clipboardWriter.connected(); + // ... and ignore its bells until the output pauses (ADR 053) + tabTitle.connected(); const ack = new OutputAck((message) => { if (socket.readyState === WebSocket.OPEN) socket.send(message); }); @@ -246,6 +261,7 @@ function connect(): void { ws.onmessage = (event: MessageEvent<string>) => { // term.write() parses synchronously, so the output has been parsed once feed() // returns: acknowledging it now lets a slow tab slow the server down. + tabTitle.output(performance.now()); graphics.feed(event.data); ack.parsed(event.data.length); smoothScroll.onOutput(); diff --git a/src/client/tab-title.test.ts b/src/client/tab-title.test.ts new file mode 100644 index 0000000..3c1873e --- /dev/null +++ b/src/client/tab-title.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test'; +import { REPLAY_QUIET_MS, TabTitle } from './tab-title'; + +function setup(bellTitle = true) { + const titles: string[] = []; + const tab = new TabTitle('main', bellTitle, (t) => titles.push(t)); + const last = () => titles[titles.length - 1]; + return { tab, titles, last }; +} + +/** Connects and parses one replay message at time 0, then the replay is over at `gap`. */ +function afterReplay(tab: TabTitle, gap = REPLAY_QUIET_MS): void { + tab.connected(); + tab.output(0); + tab.output(gap); +} + +describe('TabTitle', () => { + test("shows the program's title, or the session id while it has none", () => { + const { tab, last } = setup(); + expect(last()).toBe('main | webtty'); + tab.title('vim README.md'); + expect(last()).toBe('vim README.md | webtty'); + tab.title(''); + expect(last()).toBe('main | webtty'); + }); + + test('a bell marks the title until the terminal is attended', () => { + const { tab, titles, last } = setup(); + afterReplay(tab); + tab.title('make'); + tab.bell(); + expect(last()).toBe('🔔 make | webtty'); + // A title change keeps the mark; a second bell changes nothing. + tab.title('done'); + const count = titles.length; + tab.bell(); + expect(titles).toHaveLength(count); + expect(last()).toBe('🔔 done | webtty'); + tab.attended(); + expect(last()).toBe('done | webtty'); + // Attending again changes nothing. + tab.attended(); + expect(titles).toHaveLength(count + 1); + }); + + test('ignores the bells of the replay, until the output pauses', () => { + const { tab, last } = setup(); + tab.connected(); + tab.output(0); + tab.bell(); + // The replay's messages arrive back to back. + tab.output(REPLAY_QUIET_MS - 1); + tab.bell(); + tab.output(REPLAY_QUIET_MS * 2 - 2); + tab.bell(); + expect(last()).toBe('main | webtty'); + // A message after a pause is live output. + tab.output(REPLAY_QUIET_MS * 3); + tab.bell(); + expect(last()).toBe('🔔 main | webtty'); + }); + + test('a reconnect replays again, and its bells are ignored', () => { + const { tab, last } = setup(); + afterReplay(tab); + tab.connected(); + // The first message is the replay, however long after the connect it arrives. + tab.output(REPLAY_QUIET_MS * 10); + tab.bell(); + expect(last()).toBe('main | webtty'); + }); + + test('applies titles from the replay', () => { + const { tab, last } = setup(); + tab.connected(); + tab.output(0); + tab.title('htop'); + expect(last()).toBe('htop | webtty'); + }); + + test('with bellTitle off, a bell changes nothing', () => { + const { tab, titles } = setup(false); + afterReplay(tab); + tab.bell(); + expect(titles).toEqual(['main | webtty']); + }); +}); diff --git a/src/client/tab-title.ts b/src/client/tab-title.ts new file mode 100644 index 0000000..88352c7 --- /dev/null +++ b/src/client/tab-title.ts @@ -0,0 +1,71 @@ +/** + * Output quiet for this long after a connect ends the replay; bells before it are the + * replay's (ADR 053). The server sends the whole replay as one burst at connect. + */ +export const REPLAY_QUIET_MS = 1000; + +const BELL_MARK = '🔔 '; + +/** + * The tab title (ADR 053): the program's OSC 0/2 title, or the session id while it has + * set none, as `<title> | webtty`, with a 🔔 in front after a bell until the terminal + * gains focus or a key is pressed, as Ghostty's `bell-features = title` does. + * + * The server replays the session's recent output on every connect. A title in it is + * applied (it is the title the program last set), but a bell in it is not a new bell: + * bells are ignored from each connect until the output first pauses for + * {@link REPLAY_QUIET_MS}. + */ +export class TabTitle { + private appTitle = ''; + private ringing = false; + private replaying = false; + private lastOutput: number | null = null; + + constructor( + private readonly sessionId: string, + private readonly bellTitle: boolean, + private readonly set: (title: string) => void, + ) { + this.update(); + } + + /** The program set its title (`''` for none). */ + title(title: string): void { + this.appTitle = title; + this.update(); + } + + /** The program rang the bell. */ + bell(): void { + if (!this.bellTitle || this.replaying || this.ringing) return; + this.ringing = true; + this.update(); + } + + /** The terminal gained focus or got a key: the bell has been seen. */ + attended(): void { + if (!this.ringing) return; + this.ringing = false; + this.update(); + } + + /** A connection opened: the output that follows starts with the replay. */ + connected(): void { + this.replaying = true; + this.lastOutput = null; + } + + /** Output arrived at `now` (ms) and is about to be parsed. */ + output(now: number): void { + if (this.replaying && this.lastOutput !== null && now - this.lastOutput >= REPLAY_QUIET_MS) { + this.replaying = false; + } + this.lastOutput = now; + } + + private update(): void { + const name = this.appTitle || this.sessionId; + this.set(`${this.ringing ? BELL_MARK : ''}${name} | webtty`); + } +} From 8ee21fda38a591d72b59ba72ff8e6a3fe86d85d7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 10:52:21 -0400 Subject: [PATCH 102/295] docs(client): record focus reports, the tab title, the bell and bellTitle in the specs --- docs/adrs/053.client.focus-reports-title-and-bell.md | 2 +- docs/specs/client.md | 12 +++++++++++- docs/specs/config.md | 5 ++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/adrs/053.client.focus-reports-title-and-bell.md b/docs/adrs/053.client.focus-reports-title-and-bell.md index c68a37c..45150d3 100644 --- a/docs/adrs/053.client.focus-reports-title-and-bell.md +++ b/docs/adrs/053.client.focus-reports-title-and-bell.md @@ -52,7 +52,7 @@ The replay can turn the mode on, and the report then tells the program this tab' On a bell, `TabTitle` puts `🔔 ` in front of the title, focused or not, until the terminal gains focus or a key is pressed in the tab, as in Ghostty. A focused tab that rings (a failed Tab completion) shows the 🔔 until the next key; a tab in the background keeps it until the user comes back. Nothing else happens: no sound (Ghostty's `audio` and `system` are off by default, and a page may play sound only after user input), no flash (Ghostty's `border` is off by default), no notification (it needs a permission prompt, as `attention` needs none in Ghostty). -**Bells in the replay are ignored.** After each connect the client treats output as replay until the first gap of 1 s with no output: `TabTitle.connected()` at socket open, `TabTitle.output(now)` before each message is parsed. The first message starts the replay; a message that arrives 1 s or more after the previous one ends it, and its bells and every later one count. The replay arrives as a burst, so a gap means it is over; a program that wrote without a pause since the connect has its bells ignored until it pauses. +**Bells in the replay are ignored.** After each connect the client treats output as replay until the first gap of 1 s with no output: `TabTitle.connected()` on each connect, `TabTitle.output(now)` before each message is parsed. The first message starts the replay; a message that arrives 1 s or more after the previous one ends it, and its bells and every later one count. The replay arrives as a burst, so a gap means it is over; a program that wrote without a pause since the connect has its bells ignored until it pauses. ### Config diff --git a/docs/specs/client.md b/docs/specs/client.md index 7e1f80d..43d4969 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -55,7 +55,7 @@ The vendored TS is excluded from Biome so it stays byte-identical to upstream un 1. Reads `sessionId` from `window.location.pathname` (`/s/main` → `main`) 2. Fetches `GET /api/config` to get terminal config -3. Sets `document.title = sessionId + ' | webtty'` +3. Sets `document.title` to `sessionId + ' | webtty'` through `TabTitle`, which later shows the program's title and the bell mark ([ADR 053](../adrs/053.client.focus-reports-title-and-bell.md)) 4. Loads the official libghostty-vt wasm and initialises the vendored ghostty-web `Terminal` with config values (cols, rows, fontSize, fontFamily, cursorStyle, cursorStyleBlink, scrollbackLines, mouseScrollSpeed, theme, copyOnSelect, rightClickBehavior) 5. Connects to `ws://<host>/ws/:id/pty?cols=<cols>&rows=<rows>&flow=1` over WebSocket, and acknowledges output as it parses it: `{ type: 'ack', chars }`, the characters parsed since connecting, every 64 Ki characters and 100 ms after output stops (ADR 048) 6. Fits the terminal to the viewport from the full container size (no scrollbar reserve, see ADR 033) and observes resize events @@ -73,6 +73,7 @@ The vendored TS is excluded from Biome so it stays byte-identical to upstream un scrollbackLines, // history in lines, 0 = no line limit, 32 MiB ceiling (ADR 046) theme, copyOnSelect, rightClickBehavior, clipboardWrite, // Terminal option: report OSC 52 clipboard writes (ADR 052) + bellTitle, // TabTitle: 🔔 in the tab title after a bell (ADR 053) mouseScrollSpeed, // Terminal option: SGR wheel ticks per line/page event when the app tracks the mouse padding, // px around the canvas, used by fit(); coloured by theme.padding or theme.background keyboardBindings // used by the keydown capture handler, not passed to Terminal constructor @@ -184,6 +185,14 @@ Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), `readEffects` reports the last write since the previous call, `Terminal.onClipboardWrite` fires with it, and `index.ts` passes it to `ClipboardWriter` (`clipboard-write.ts`), which calls `navigator.clipboard.writeText`. After each (re)connect it ignores writes until a `keydown`, `mousedown`, `paste` or `drop` in the tab, so the replayed history does not overwrite the clipboard on reload. A missing clipboard API (http other than localhost), a refused write (no focus in Chrome, no recent input in Firefox, most writes in Safari) and a write held back for the replay are each logged to the console once; there is no error UI. +## Focus Reports, Title and Bell + +Programs hear focus changes, name the tab and ring the bell as in Ghostty ([ADR 053](../adrs/053.client.focus-reports-title-and-bell.md)): + +- **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Nothing is sent with `disableStdin`. +- **Title.** `TabTitle` (`tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. +- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay until it first pauses for 1 s (`REPLAY_QUIET_MS`), measured between WebSocket messages, the first message always being replay. + ## Behavior Notes ### Tab close on session end @@ -208,6 +217,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | +| Focus reports, title and bell | Mode 1004 focus reports encoded by Ghostty, sent on each change and when the mode is set; the tab title is the program's OSC 0/2 title (`<title> \| webtty`, the session id while it has none); a bell puts 🔔 in front of it until the terminal is focused or a key is pressed, and bells replayed on connect are ignored; `bellTitle: false` turns the mark off | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 113f91d..d8ac925 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -91,7 +91,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `clipboardWrite`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `clipboardWrite`, `bellTitle`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -116,6 +116,7 @@ All keys are optional — omit any key to use the default value. | `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released (kitty / Windows Terminal style). While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `rightClickBehavior` | string | `"contextMenu"` | What a right-click does when the app does not take it: `"contextMenu"` shows the browser menu; `"copyPaste"` copies and clears an existing selection, otherwise shows the menu; `"paste"` pastes the clipboard as Ctrl+V does; `"none"` does nothing and shows no menu. While the app tracks the mouse, a right-click goes only to the app and no menu is shown; Shift+right-click applies this setting instead and sends nothing to the app. `"default"`, the old name of `"contextMenu"`, is still accepted; invalid values fall back to `"contextMenu"`. Windows Terminal users may prefer `"copyPaste"`. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `clipboardWrite` | boolean | `true` | Let programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL`), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH; iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522 writes too. Writes only: a query (`52;c;?`) is never answered. Only the clipboard (`c`, or an empty or unknown selection) is written; `s` and `p` are ignored, as in Ghostty on macOS. A write is held back after each connect until you press a key or click in the tab, so the replayed history never overwrites the clipboard, and the browser may still refuse it (Chrome needs focus, Firefox recent input, Safari refuses most): the console says so once. `false` ignores all program clipboard writes. See [ADR 052](../adrs/052.client.osc52-clipboard-write.md). | +| `bellTitle` | boolean | `true` | When a program rings the bell (BEL), put a 🔔 in front of the tab title until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title` does; it shows in a background tab until you come back. Bells in the output replayed on reload or reconnect are ignored. The tab title itself is the program's title (OSC 0/2) as `<title> \| webtty`, or `<session> \| webtty` while it has none. `false` leaves the title alone on a bell. See [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md). | | `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | @@ -179,6 +180,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "copyOnSelect": true, "rightClickBehavior": "contextMenu", "clipboardWrite": true, + "bellTitle": true, "fontSize": 13, "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", @@ -222,6 +224,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Copy behavior | `copyOnSelect` + `rightClickBehavior` — configurable clipboard copy matching VS Code / kitty conventions | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double clicks and right-clicks go only to the app (no selection, copy or menu), Shift to bypass; `rightClickBehavior` gains `"contextMenu"` (default, was `"default"`), `"paste"` and `"none"` | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Program clipboard writes | `clipboardWrite` (default `true`) lets programs write the clipboard with OSC 52 (writes only, standard clipboard only, held back until input after each connect); `false` ignores them | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | +| Bell in the tab title | `bellTitle` (default `true`) puts 🔔 in front of the tab title after a bell until the terminal is focused or a key is pressed; replayed bells are ignored | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime, and Ps 0 restores it | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | From 4c3c037845e6111f6b99f69a0904c2f341a42be7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 10:59:23 -0400 Subject: [PATCH 103/295] fix(client): resend the focus state when a socket opens with mode 1004 on A reconnect that keeps the terminal leaves mode 1004 on, so the replay sends no report on set, and focus changes while the socket was down were dropped. Terminal.resyncFocus() sends the current state when each socket opens (ADR 053). --- .../053.client.focus-reports-title-and-bell.md | 4 +++- docs/specs/client.md | 2 +- src/client/index.ts | 3 +++ src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/terminal.ts | 8 ++++++++ src/client/vendor/terminal.test.ts | 18 ++++++++++++++++++ 6 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/adrs/053.client.focus-reports-title-and-bell.md b/docs/adrs/053.client.focus-reports-title-and-bell.md index 45150d3..32907ac 100644 --- a/docs/adrs/053.client.focus-reports-title-and-bell.md +++ b/docs/adrs/053.client.focus-reports-title-and-bell.md @@ -42,7 +42,9 @@ The binding gains `encodeFocus(focused)` (`ghostty_focus_encode`). `Terminal`: - on a focus change, sends the report through `onData` if mode 1004 is on; - after each write, if mode 1004 went from off to on, sends the current state, as Ghostty does when the mode is set. Only the state after the write is compared, so a set that does not change the mode (it was already on), or an off-and-on inside one write, sends nothing; programs set it once when they start. -The replay can turn the mode on, and the report then tells the program this tab's focus. That is the point of the report on set, and it is right for the tab that just connected. With two tabs on one session, each sends its own changes and the last one wins. +The replay can turn the mode on, and the report then tells the program this tab's focus. That is the point of the report on set, and it is right for the tab that just connected. A reconnect that keeps the terminal (a dropped connection, not a reload or a `4003`, which reset it) leaves the mode on, so the replay does not turn it on, and reports for focus changes while the socket was down were dropped: `index.ts` calls `Terminal.resyncFocus()` when each socket opens, which sends the current state if the mode is on. After a reset the mode is off at open, and the replay's set sends it instead, so a connection sends it once. + +With two tabs on one session, each sends its own changes and the last one wins. ### The tab title is the program's title diff --git a/docs/specs/client.md b/docs/specs/client.md index 43d4969..4cadaf6 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -189,7 +189,7 @@ Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), Programs hear focus changes, name the tab and ring the bell as in Ghostty ([ADR 053](../adrs/053.client.focus-reports-title-and-bell.md)): -- **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Nothing is sent with `disableStdin`. +- **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Each socket that opens with the mode still on (a reconnect without a reset) sends the current state too (`resyncFocus`), since reports while it was down were dropped. Nothing is sent with `disableStdin`. - **Title.** `TabTitle` (`tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. - **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay until it first pauses for 1 s (`REPLAY_QUIET_MS`), measured between WebSocket messages, the first message always being replay. diff --git a/src/client/index.ts b/src/client/index.ts index 9d36ab2..4daf5b2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -256,6 +256,9 @@ function connect(): void { ws.onopen = () => { graphics.reset(); ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + // Focus changes while the socket was down were dropped: send the state the app's + // mode 1004 now misses (ADR 053). After a reset the mode is off until the replay. + term.resyncFocus(); }; ws.onmessage = (event: MessageEvent<string>) => { diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 028e0d6..2d11bc5 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -40,5 +40,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `terminal.ts`: pastes are encoded by `ghostty_terminal_paste` (ADR 049). The binding's new `paste` passes the text as a `text/plain` clipboard paste with a mime reader callback and `allow_unsafe` set, and the bytes come back through `WRITE_PTY`. `Terminal.paste`, removed above as unused, is back with Ghostty's encoding: bracketed whenever mode 2004 is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004; upstream bracketed the text unchanged and sent it raw otherwise. - `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The input handler passes `mouseReport` to its data callback for mouse reports, and the terminal's `handleInput` clears the selection only for other input; upstream cleared it for every report, so a Shift selection vanished at the next hover in mode 1003. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. -- `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. Upstream sent no focus reports. +- `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index e1f25d9..9d43baa 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -1083,6 +1083,14 @@ export class Terminal implements ITerminalCore { if (this.wasmTerm?.getMode(FOCUS_EVENT_MODE)) this.reportFocus(); } + /** + * Sends the current focus if mode 1004 is on, for a new connection to the app: reports + * sent while there was none were dropped. (webtty, ADR 053) + */ + resyncFocus(): void { + if (this.wasmTerm?.getMode(FOCUS_EVENT_MODE)) this.reportFocus(); + } + /** Sends `CSI I` or `CSI O` for the current focus, encoded by Ghostty. (webtty, ADR 053) */ private reportFocus(): void { if (this.options.disableStdin || !this.wasmTerm) return; diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 16de5a2..55db486 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -343,6 +343,7 @@ describe('Terminal focus reports', () => { const sent: string[] = []; term.onData((data) => sent.push(data)); return { + term, wasmTerm, sent, write: (data: string) => internals.writeInternal(data), @@ -395,6 +396,23 @@ describe('Terminal focus reports', () => { wasmTerm.free(); }); + test('resyncFocus sends the current focus while the mode is on, for a new connection', async () => { + const { term, wasmTerm, sent, write, focusIn, focusOut } = await focusTerm(); + focusIn(); + term.resyncFocus(); + expect(sent).toEqual([]); + write('\x1b[?1004h'); + // The socket goes down: this report is dropped on the way to the app. + focusOut(); + sent.length = 0; + // The replay of a reconnect leaves the mode on, which sends nothing: the resync does. + write('\x1b[?1004h'); + expect(sent).toEqual([]); + term.resyncFocus(); + expect(sent).toEqual(['\x1b[O']); + wasmTerm.free(); + }); + test('sends nothing with stdin disabled', async () => { const { wasmTerm, sent, write, focusIn, focusOut } = await focusTerm({ disableStdin: true }); write('\x1b[?1004h'); From 928bf29aa3ab7f13700b5e7c9baa7b2ca9497463 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:01:16 -0400 Subject: [PATCH 104/295] fix(client): end the replay at its last piece, not at the first 1 s pause A program that kept writing after a reconnect, such as an agent redrawing its spinner, had every bell ignored until it paused for 1 s. The server sends the replay in full 64 Ki-character pieces, so a shorter message is its last; a 1 s quiet gap, now measured from the end of the previous parse, still ends a replay whose last piece is full (ADR 053). --- ...053.client.focus-reports-title-and-bell.md | 5 +- docs/specs/client.md | 2 +- src/client/index.ts | 5 +- src/client/tab-title.test.ts | 59 ++++++++++++++----- src/client/tab-title.ts | 37 +++++++++--- 5 files changed, 79 insertions(+), 29 deletions(-) diff --git a/docs/adrs/053.client.focus-reports-title-and-bell.md b/docs/adrs/053.client.focus-reports-title-and-bell.md index 32907ac..6661644 100644 --- a/docs/adrs/053.client.focus-reports-title-and-bell.md +++ b/docs/adrs/053.client.focus-reports-title-and-bell.md @@ -54,7 +54,7 @@ With two tabs on one session, each sends its own changes and the last one wins. On a bell, `TabTitle` puts `🔔 ` in front of the title, focused or not, until the terminal gains focus or a key is pressed in the tab, as in Ghostty. A focused tab that rings (a failed Tab completion) shows the 🔔 until the next key; a tab in the background keeps it until the user comes back. Nothing else happens: no sound (Ghostty's `audio` and `system` are off by default, and a page may play sound only after user input), no flash (Ghostty's `border` is off by default), no notification (it needs a permission prompt, as `attention` needs none in Ghostty). -**Bells in the replay are ignored.** After each connect the client treats output as replay until the first gap of 1 s with no output: `TabTitle.connected()` on each connect, `TabTitle.output(now)` before each message is parsed. The first message starts the replay; a message that arrives 1 s or more after the previous one ends it, and its bells and every later one count. The replay arrives as a burst, so a gap means it is over; a program that wrote without a pause since the connect has its bells ignored until it pauses. +**Bells in the replay are ignored.** The server sends the replay to an acknowledging client in pieces of `FlowLimits.chunk` (64 Ki characters, one fewer where a piece would split a surrogate pair): every piece but the last is full length. After each connect the client treats output as replay through the first message shorter than that (`REPLAY_PIECE`, a test keeps it equal to the server's), or until a message arrives 1 s (`REPLAY_QUIET_MS`) or more after the previous one was parsed, for a replay whose last piece is full length: `TabTitle.connected()` on each connect, `TabTitle.output(chars, now)` before each message is parsed, `TabTitle.parsed(now)` after. The first message is always replay. A program that writes without a pause from the connect on (an agent's spinner) has its bells counted from the message after the replay's last piece, not after its first pause. ### Config @@ -67,7 +67,7 @@ On a bell, `TabTitle` puts `🔔 ` in front of the title, focused or not, until - Two tabs on one session send interleaved reports; the program sees the latest. - Tabs read as what runs in them: a shell that sets the title (`user@host: ~/dir`), vim's file name, Claude Code's title. A program that never sets one keeps `<session> | webtty`. A title from an earlier program stays until another is set, as in Ghostty. - A bell shows as `🔔 ` in the tab title until the user focuses the terminal or presses a key. Reloads and reconnects do not mark the tab for bells already rung. -- A bell less than 1 s after the previous output, while the output has not paused since the connect, is not shown. A replay delayed by a gap of 1 s or more (a link slower than about 0.5 Mbit/s for a 64 Ki message, a stalled connection) can mark the tab for a replayed bell; the mark goes on the next focus or key. +- After a replay whose length is a whole number of pieces, which includes a full default `scrollback` (262,144 characters, four pieces), the first live message is taken as the replay's end: its bells are not shown unless it comes 1 s after the replay. Later messages count, so an agent that keeps redrawing loses at most a bell in its first frame after the connect. A replay piece delayed by 1 s or more after the previous one was parsed (a link slower than about 0.5 Mbit/s, a stalled connection, an acknowledgement wait for a `scrollback` over the 2 MiB high-water mark) can mark the tab for a replayed bell; the mark goes on the next focus or key. The client repeats the server's piece size; a test fails if the two differ. ## Considered Options @@ -75,6 +75,7 @@ On a bell, `TabTitle` puts `🔔 ` in front of the title, focused or not, until - **Report only changes, nothing on set.** Ghostty sends the state on set; programs rely on it to start in the right state. - **Hold bells after each connect until user input, as OSC 52 writes are (ADR 052).** The bell's use is a tab the user is away from; after a reconnect in the background (laptop wake, server restart) every bell would be lost until the user came back. - **A replay-end marker from the server.** Exact, but a protocol change for every client, rejected for the same reason in ADR 052. +- **Only a quiet gap after the connect.** The first version: output was replay until it paused for 1 s. A program that keeps writing (Claude Code redraws its spinner several times a second and rings right after its last frame) lost every bell after a reconnect. A shorter gap does not fix it, since a spinner's frames are closer than any gap a slow link's replay can be trusted to stay under. - **Mark only while the terminal is unfocused.** Simpler on the eye, but Ghostty marks a focused surface too, and the next key clears it. - **Flash the screen, play a sound, or post a notification.** Ghostty's defaults do none of the first two; a notification needs a permission prompt. Left for later behind more `bell*` keys. - **`<title> | <session> | webtty`.** Longer than a tab shows; shells that set a title already name the host and directory. diff --git a/docs/specs/client.md b/docs/specs/client.md index 4cadaf6..69d9bfb 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -191,7 +191,7 @@ Programs hear focus changes, name the tab and ring the bell as in Ghostty ([ADR - **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Each socket that opens with the mode still on (a reconnect without a reset) sends the current state too (`resyncFocus`), since reports while it was down were dropped. Nothing is sent with `disableStdin`. - **Title.** `TabTitle` (`tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. -- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay until it first pauses for 1 s (`REPLAY_QUIET_MS`), measured between WebSocket messages, the first message always being replay. +- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. ## Behavior Notes diff --git a/src/client/index.ts b/src/client/index.ts index 4daf5b2..54b8e6d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -240,7 +240,7 @@ function connect(): void { ws = socket; // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) clipboardWriter.connected(); - // ... and ignore its bells until the output pauses (ADR 053) + // ... and ignore its bells until the replay's last piece (ADR 053) tabTitle.connected(); const ack = new OutputAck((message) => { if (socket.readyState === WebSocket.OPEN) socket.send(message); @@ -264,8 +264,9 @@ function connect(): void { ws.onmessage = (event: MessageEvent<string>) => { // term.write() parses synchronously, so the output has been parsed once feed() // returns: acknowledging it now lets a slow tab slow the server down. - tabTitle.output(performance.now()); + tabTitle.output(event.data.length, performance.now()); graphics.feed(event.data); + tabTitle.parsed(performance.now()); ack.parsed(event.data.length); smoothScroll.onOutput(); }; diff --git a/src/client/tab-title.test.ts b/src/client/tab-title.test.ts index 3c1873e..0fb879a 100644 --- a/src/client/tab-title.test.ts +++ b/src/client/tab-title.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { REPLAY_QUIET_MS, TabTitle } from './tab-title'; +import { DEFAULT_FLOW_LIMITS } from '../server/flow'; +import { REPLAY_PIECE, REPLAY_QUIET_MS, TabTitle } from './tab-title'; function setup(bellTitle = true) { const titles: string[] = []; @@ -8,11 +9,12 @@ function setup(bellTitle = true) { return { tab, titles, last }; } -/** Connects and parses one replay message at time 0, then the replay is over at `gap`. */ -function afterReplay(tab: TabTitle, gap = REPLAY_QUIET_MS): void { +/** Connects and parses a one-message replay, then a live message arrives. */ +function afterReplay(tab: TabTitle): void { tab.connected(); - tab.output(0); - tab.output(gap); + tab.output(100, 0); + tab.parsed(0); + tab.output(10, 0); } describe('TabTitle', () => { @@ -44,21 +46,44 @@ describe('TabTitle', () => { expect(titles).toHaveLength(count + 1); }); - test('ignores the bells of the replay, until the output pauses', () => { + test('ignores the bells of the replay, through its last piece', () => { const { tab, last } = setup(); tab.connected(); - tab.output(0); - tab.bell(); - // The replay's messages arrive back to back. - tab.output(REPLAY_QUIET_MS - 1); + // Full pieces, the second one short of a surrogate pair, then the shorter last one. + for (const chars of [REPLAY_PIECE, REPLAY_PIECE - 1, 10]) { + tab.output(chars, 0); + tab.bell(); + tab.parsed(0); + } + expect(last()).toBe('main | webtty'); + // The next message is live output, however soon it comes. + tab.output(REPLAY_PIECE, 0); tab.bell(); - tab.output(REPLAY_QUIET_MS * 2 - 2); + expect(last()).toBe('🔔 main | webtty'); + }); + + test('a replay ending on a full piece is over when the output pauses after it', () => { + const { tab, last } = setup(); + tab.connected(); + tab.output(REPLAY_PIECE, 0); + // Slow to parse: the pause counts from the end of the parse. + tab.parsed(REPLAY_QUIET_MS); + tab.output(10, REPLAY_QUIET_MS * 2 - 1); tab.bell(); + tab.parsed(REPLAY_QUIET_MS * 2 - 1); expect(last()).toBe('main | webtty'); - // A message after a pause is live output. - tab.output(REPLAY_QUIET_MS * 3); + // The short message ended it too: the next is live, as is one after a pause. + tab.output(10, REPLAY_QUIET_MS * 2); tab.bell(); expect(last()).toBe('🔔 main | webtty'); + + const other = setup(); + other.tab.connected(); + other.tab.output(REPLAY_PIECE, 0); + other.tab.parsed(0); + other.tab.output(10, REPLAY_QUIET_MS); + other.tab.bell(); + expect(other.last()).toBe('🔔 main | webtty'); }); test('a reconnect replays again, and its bells are ignored', () => { @@ -66,15 +91,19 @@ describe('TabTitle', () => { afterReplay(tab); tab.connected(); // The first message is the replay, however long after the connect it arrives. - tab.output(REPLAY_QUIET_MS * 10); + tab.output(10, REPLAY_QUIET_MS * 10); tab.bell(); expect(last()).toBe('main | webtty'); }); + test("the replay's piece size is the server's", () => { + expect(REPLAY_PIECE).toBe(DEFAULT_FLOW_LIMITS.chunk); + }); + test('applies titles from the replay', () => { const { tab, last } = setup(); tab.connected(); - tab.output(0); + tab.output(10, 0); tab.title('htop'); expect(last()).toBe('htop | webtty'); }); diff --git a/src/client/tab-title.ts b/src/client/tab-title.ts index 88352c7..eef8b74 100644 --- a/src/client/tab-title.ts +++ b/src/client/tab-title.ts @@ -1,6 +1,13 @@ /** - * Output quiet for this long after a connect ends the replay; bells before it are the - * replay's (ADR 053). The server sends the whole replay as one burst at connect. + * The size, in characters, of the pieces the server sends the replay in (its + * `FlowLimits.chunk`, ADR 048): every piece but the last is this long, or one shorter + * where it would split a surrogate pair. A shorter message ends the replay (ADR 053). + */ +export const REPLAY_PIECE = 64 * 1024; + +/** + * Output quiet for this long, after the previous message was parsed, also ends the + * replay: for a replay whose last piece is full length (ADR 053). */ export const REPLAY_QUIET_MS = 1000; @@ -13,14 +20,16 @@ const BELL_MARK = '🔔 '; * * The server replays the session's recent output on every connect. A title in it is * applied (it is the title the program last set), but a bell in it is not a new bell: - * bells are ignored from each connect until the output first pauses for + * bells are ignored from each connect through the first message shorter than + * {@link REPLAY_PIECE}, the replay's last piece, or until the output pauses for * {@link REPLAY_QUIET_MS}. */ export class TabTitle { private appTitle = ''; private ringing = false; private replaying = false; - private lastOutput: number | null = null; + private lastParsed: number | null = null; + private lastWasTail = false; constructor( private readonly sessionId: string, @@ -53,15 +62,25 @@ export class TabTitle { /** A connection opened: the output that follows starts with the replay. */ connected(): void { this.replaying = true; - this.lastOutput = null; + this.lastParsed = null; + this.lastWasTail = false; } - /** Output arrived at `now` (ms) and is about to be parsed. */ - output(now: number): void { - if (this.replaying && this.lastOutput !== null && now - this.lastOutput >= REPLAY_QUIET_MS) { + /** A message of `chars` characters arrived at `now` (ms) and is about to be parsed. */ + output(chars: number, now: number): void { + if ( + this.replaying && + this.lastParsed !== null && + (this.lastWasTail || now - this.lastParsed >= REPLAY_QUIET_MS) + ) { this.replaying = false; } - this.lastOutput = now; + this.lastWasTail = chars < REPLAY_PIECE - 1; + } + + /** The message passed to {@link output} was parsed at `now` (ms). */ + parsed(now: number): void { + this.lastParsed = now; } private update(): void { From f410e3e760d6d1b2b3a59144da902b0c4a81755a Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:08:23 -0400 Subject: [PATCH 105/295] docs(client): ADR 054, copy, paste and clear-screen keys as Ghostty binds them --- docs/adrs/054.client.copy-paste-clear-keys.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/adrs/054.client.copy-paste-clear-keys.md diff --git a/docs/adrs/054.client.copy-paste-clear-keys.md b/docs/adrs/054.client.copy-paste-clear-keys.md new file mode 100644 index 0000000..cb85396 --- /dev/null +++ b/docs/adrs/054.client.copy-paste-clear-keys.md @@ -0,0 +1,89 @@ +# ADR 054: client — Copy, paste and clear-screen keys as Ghostty binds them + +**SPEC:** [client](../specs/client.md), [key-bindings](../specs/key-bindings.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +webtty has three keyboard paths to the clipboard: Cmd+C copies the selection (the vendored input handler, any platform), Cmd+V fires the browser's `paste` event, and Ctrl+V reads the clipboard itself (ADR 014, ADR 037), with Shift or Alt held too, so Ctrl+Shift+V already pastes. Two keys a native terminal has are missing: + +- **Copy on Windows and Linux.** Ctrl+C must stay the interrupt, and Cmd+C needs a key those keyboards do not use for it. Ctrl+Shift+C is sent to the app as `\x03`, an interrupt, with or without a selection. +- **Clear on macOS.** Cmd+K is encoded like any key (the encoder sends `k`), so it types a `k`. + +Ghostty's default bindings (`src/config/Config.zig`, `Keybinds.init`, on `main` at the time of writing): + +- **Copy and paste.** `super+c` / `super+v` on macOS and `ctrl+shift+c` / `ctrl+shift+v` elsewhere, bound `performable:` to `copy_to_clipboard` and `paste_from_clipboard`. `performable:` means "only consume the input if the action is able to be performed": with no selection, `copy_to_clipboard` is not performed and the key is encoded for the app as if unbound (`ctrl+shift+c` is `\x03` in the legacy encoding). Outside macOS, `ctrl+insert` is also `copy_to_clipboard` and `shift+insert` `paste_from_clipboard`, both without `performable:`, so they are consumed even when nothing happens; the Linux block later rebinds `shift+insert` to `paste_from_selection` (the X11/Wayland primary selection). Plain `ctrl+v` is not bound anywhere. +- **Clear.** `super+k` is `clear_screen`, `performable:`, on macOS only. `Surface.performBindingAction` returns "not performed" on the alternate screen, so the key goes to the app there (an emulator-side clear would desynchronise a full-screen program's idea of the screen). Otherwise `Termio.clearScreen(history: true)`: clears the selection and erases the scrollback, then + - **at a shell prompt** (`Terminal.cursorIsAtPrompt`, which needs OSC 133 semantic prompt marks, as fish 4 and Ghostty's shell integration emit), erases the whole screen and sends FF (`0x0C`) to the shell so it redraws its prompt at the top; + - **otherwise** deletes the rows above the cursor (`eraseActive(cursor.y - 1)`, which shifts the rest up), so the cursor's row becomes the top row and output in progress carries on below it, and deletes all kitty graphics. + + At a prompt Ghostty erases the scrollback first and the screen second, and libghostty-vt's ED 2 moves a screen whose last non-empty row is a prompt into the scrollback (`eraseDisplay(.complete)`, Ghostty #905), so one screen survives in the scrollback; `Termio.clearScreen` marks it `TODO`. + +What libghostty-vt gives an embedder, at the pinned commit (`05dcf887`): no clear-screen call, but `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT` (`cursorIsAtPrompt`, false without semantic prompt marks and on the alternate screen), the cursor position and the active screen, and the VT parser, which does the erasing: `CSI 3 J` erases the scrollback, `CSI n S` scrolls the region up by n rows (on the primary screen the rows go into the scrollback), `CSI n A` moves the cursor up. + +Two things are webtty's own. User `keyboardBindings` (ADR 018) are matched by a capture listener on the container first, and the Ctrl+V listener already steps aside for a combo the user has bound. And the server replays the session's recent output on every connect (ADR 007): a clear done only in the client comes back on the next reload. + +## Decision + +### The bindings + +The platform is the browser's (`navigator.userAgentData.platform`, else `navigator.platform`): macOS (and iOS) or anything else, as Ghostty's defaults depend on it. + +| Platform | Key | Action | When it cannot be done | +|---|---|---|---| +| macOS | Cmd+C | copy (unchanged, input handler) | browser default | +| macOS | Cmd+V | paste (unchanged, browser `paste` event) | | +| macOS | Cmd+K | clear screen | sent to the app (alternate screen) | +| others | Ctrl+Shift+C | copy | sent to the app (`\x03`) | +| others | Ctrl+Insert | copy | consumed, nothing sent | +| others | Shift+Insert | paste | consumed; the browser's own paste may run | +| all | Ctrl+V, Ctrl+Shift+V (and with Alt) | paste (unchanged, ADR 014) | the browser's own paste may run | + +Modifiers match exactly, as Ghostty's triggers do: Ctrl+Alt+Shift+C is not copy. Cmd+C keeps working on every platform, as before. + +`shortcuts.ts` maps a `keydown` to one of these (`shortcutFor(event, mac)`); one capture listener on the container in `index.ts`, registered after the `keyboardBindings` listener, performs it. It replaces the Ctrl+V listener, which becomes the "all platforms" row. + +**User bindings win.** The listener returns at once for a combo `keyboardBindings` has (`findBinding`), as the Ctrl+V listener did: a binding with `"chars": ""` turns a default off, as `keybind = ctrl+shift+c=unbind` would in Ghostty. + +**Performable.** A copy with no selection, and a clear on the alternate screen, are not performed: the listener leaves the event alone and the input handler encodes the key for the app, as Ghostty does. Ctrl+Insert, as in Ghostty, is consumed either way. A paste is performed when the browser has `navigator.clipboard.readText` (the read itself is asynchronous, and a failed read sends Ctrl+V, ADR 014); without it (http other than localhost) the key is kept from the app but not `preventDefault`ed, so the browser's own paste (Ctrl+V, Ctrl+Shift+V and Shift+Insert outside macOS) can still fire the `paste` event webtty handles. + +**Shift+Insert pastes the clipboard**, not the primary selection: a page cannot read the primary selection. This is Ghostty's binding outside macOS before its Linux block replaces it, and what Shift+Insert does in a browser on Windows. + +### Clear screen + +`Terminal.clearScreen()` (vendored `terminal.ts`) does Ghostty's `clear_screen` with what libghostty-vt has, and returns false, doing nothing, on the alternate screen: + +- clears the selection; +- **at a prompt** (the binding's new `cursorAtPrompt()`, `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`): writes `CSI 2 J` then `CSI 3 J` to the terminal and sends FF to the app through `onData`. The order is the other way round from Ghostty's, so the screen ED 2 moves into the scrollback is erased too: the scrollback ends up empty, which is what `clear_screen` is for; +- **otherwise**: with the cursor on row y > 0, writes `CSI y S` and `CSI y A` (the rows above the cursor scroll off and the cursor follows its row to the top, as `eraseActive` leaves it), then `CSI 3 J`. + +The sequences go to the terminal only (`term.write`), never to the PTY; only FF is sent. `index.ts` then clears the kitty graphics placements (`KittyGraphics.clear`, as an ED from the app does; image data is kept for re-placement) and lets the smooth scroll settle at the bottom. + +## Consequences + +- On Windows and Linux, Ctrl+Shift+C copies a selection, Ctrl+Insert copies, and Shift+Insert pastes; Ctrl+Shift+C with no selection is still an interrupt. Ctrl+C is unchanged. +- On macOS, Cmd+K clears the screen and the scrollback on the normal screen. At a fish 4 prompt (or any shell sending OSC 133) the shell redraws its prompt at the top; at a shell without semantic prompts, the prompt row moves to the top. In vim, less or any full-screen program, Cmd+K is the app's key, as in Ghostty. +- The clear is this tab's own. A reload, a reconnect or another tab on the session replays the session's output from the server, scrollback included, until the program clears it. At a prompt the shell's redraw after FF is replayed as well. +- With a scroll region set on the normal screen (rare at a shell), `CSI y S` scrolls only the region, and the result differs from Ghostty's, which deletes rows regardless. +- `keyboardBindings` can still claim any of these keys. +- The browser's own Ctrl+Shift+C (inspect element in Chrome) is no more reachable than before: the key was already `preventDefault`ed when it was sent as `\x03`. + +## Considered Options + +- **Bind copy and paste on every platform** (Cmd+C and Ctrl+Shift+C everywhere). Simpler, but Ghostty's defaults differ by platform, and on macOS Ctrl+Shift+C is the app's. +- **Copy on Ctrl+C when there is a selection** (Windows Terminal). Not Ghostty's, and a selection left on screen would turn the next interrupt into a copy. +- **Ghostty's order at a prompt (scrollback first, then the screen).** Leaves the old screen in the scrollback, the `TODO` Ghostty records. +- **Always clear the whole screen and send FF.** Without semantic prompts webtty cannot tell a prompt from a program writing output, and FF to a running program is a stray byte; Ghostty sends it only at a prompt. +- **Clear on the server too, so a reload shows the cleared screen.** The replay is a byte buffer the server does not parse; cutting it at a client's request is a protocol change, and other tabs on the session would lose their history. +- **`RIS` or `CSI 2 J` everywhere.** RIS resets modes the program set; ED 2 alone keeps the scrollback. + +## Related Decisions + +- [ADR 007 — Session Client](007.webtty.session-client.md): the replay on connect +- [ADR 014 — Non-text paste via Ctrl+V](014.client.image-paste.md), [ADR 037 — Multiline text paste](037.client.multiline-text-paste.md): the paste path +- [ADR 018 — Key bindings config support](018.key-bindings.config-support.md): user bindings run first +- [ADR 032 — Kitty graphics overlay](032.client.kitty-graphics-overlay.md): placements cleared with the screen +- [ADR 050 — Selection from libghostty-vt](050.client.selection-from-libghostty-vt.md): what copy copies From 629ad23a49bf08fd88152a05edf0fd4a8132c5bc Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:10:07 -0400 Subject: [PATCH 106/295] feat(client): clear the screen and scrollback as Ghostty's clear_screen does Terminal.clearScreen writes the erasing to the terminal: nothing on the alternate screen; at an OSC 133 prompt the screen and scrollback, with FF sent for the shell to redraw; elsewhere the scrollback and the rows above the cursor. The binding reads CURSOR_AT_PROMPT for the prompt test. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 12 +++ src/client/vendor/ghostty-web/terminal.ts | 23 +++++ src/client/vendor/official-terminal.test.ts | 18 ++++ src/client/vendor/terminal.test.ts | 98 +++++++++++++++++++++ 5 files changed, 152 insertions(+) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 2d11bc5..fa81e98 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -41,4 +41,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The input handler passes `mouseReport` to its data callback for mouse reports, and the terminal's `handleInput` clears the selection only for other input; upstream cleared it for every report, so a Shift selection vanished at the next hover in mode 1003. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. - `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. +- `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). Upstream had no clear-screen action (its `clear`, removed above, was unused). - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index ba82dee..a890030 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -136,6 +136,11 @@ export interface GhosttyTerminal { isAlternateScreen(): boolean; hasMouseTracking(): boolean; getMode(mode: number, isAnsi?: boolean): boolean; + /** + * Whether the cursor is at a shell prompt or its input, from OSC 133 marks; false + * without them and on the alternate screen. (ADR 054) + */ + cursorAtPrompt(): boolean; // Scrollback, graphemes, hyperlinks getScrollbackLength(): number; @@ -517,6 +522,7 @@ function resolveOfficialLayout(layout: VtLayout) { CURSOR_Y: tdata('CURSOR_Y'), ACTIVE_SCREEN: tdata('ACTIVE_SCREEN'), MOUSE_TRACKING: tdata('MOUSE_TRACKING'), + CURSOR_AT_PROMPT: tdata('CURSOR_AT_PROMPT'), TITLE: tdata('TITLE'), SCROLLBACK_ROWS: tdata('SCROLLBACK_ROWS'), COLOR_PALETTE_DEFAULT: tdata('COLOR_PALETTE_DEFAULT'), @@ -1510,6 +1516,12 @@ export class OfficialTerminal implements GhosttyTerminal { return rc === 0 && this.u8()[at.u8] !== 0; } + cursorAtPrompt(): boolean { + const at = this.at; + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.CURSOR_AT_PROMPT, at.u8); + return rc === 0 && this.u8()[at.u8] !== 0; + } + getMode(mode: number, isAnsi: boolean = false): boolean { const at = this.at; const M = this.L.mode; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 9d43baa..55af022 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -673,6 +673,29 @@ export class Terminal implements ITerminalCore { this.processTerminalResponses(); } + /** + * Ghostty's `clear_screen` (Cmd+K): clears the selection and the scrollback, then at a + * shell prompt the screen, sending FF so the shell redraws its prompt, and elsewhere the + * rows above the cursor, whose row becomes the top one. Returns false, doing nothing, on + * the alternate screen, where the key is the app's. The erasing is written to the + * terminal, not the PTY. (webtty, ADR 054) + */ + clearScreen(): boolean { + this.assertOpen(); + const wasmTerm = this.wasmTerm!; + if (wasmTerm.isAlternateScreen()) return false; + this.clearSelection(); + if (wasmTerm.cursorAtPrompt()) { + // ED 2 at a prompt moves the screen into the scrollback, so ED 3 comes after it + this.write('\x1b[2J\x1b[3J'); + if (!this.options.disableStdin) this.dataEmitter.fire('\x0c'); + } else { + const y = wasmTerm.getCursor().y; + this.write(`${y > 0 ? `\x1b[${y}S\x1b[${y}A` : ''}\x1b[3J`); + } + return true; + } + /** * Write data to terminal */ diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 7b7025b..98f4e4b 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -814,6 +814,24 @@ describe('official engine: focus reports (ADR 053)', () => { }); }); +describe('official engine: cursor at a prompt (ADR 054)', () => { + test('follows OSC 133 marks, and is false without them and on the alternate screen', () => { + const t = term(); + t.write('$ '); + expect(t.cursorAtPrompt()).toBe(false); + t.write('\r\n\x1b]133;A\x07~ > \x1b]133;B\x07ls'); + expect(t.cursorAtPrompt()).toBe(true); + t.write('\x1b[?1049h'); + expect(t.cursorAtPrompt()).toBe(false); + t.write('\x1b[?1049l'); + expect(t.cursorAtPrompt()).toBe(true); + // The command runs: its output is not a prompt + t.write('\r\n\x1b]133;C\x07output\r\nmore'); + expect(t.cursorAtPrompt()).toBe(false); + t.free(); + }); +}); + describe('official engine: memory growth', () => { test('reads correct cells and replies after the wasm memory has grown', () => { const t = term(120, 40, { scrollbackLines: 0 }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 55db486..4026f3b 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -281,6 +281,104 @@ describe('Terminal paste', () => { }); }); +describe('Terminal clearScreen', () => { + // Ghostty's clear_screen with what libghostty-vt has: the erasing is written to the + // terminal, and only FF, at a prompt, goes to the app (ADR 054). + async function clearTerm(options: object = {}) { + const ghostty = await Ghostty.load(WASM); + const wasmTerm = ghostty.createTerminal(20, 5); + const term = new Terminal({ ghostty: {} as never, ...options }); + const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; + internals.wasmTerm = wasmTerm; + internals.isOpen = true; + const sent: string[] = []; + term.onData((data) => sent.push(data)); + const text = (cells: { codepoint: number }[] | null) => + (cells ?? []) + .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) + .join('') + .trimEnd(); + const screen = () => Array.from({ length: 5 }, (_, y) => text(wasmTerm.getLine(y))); + const scrollback = () => + Array.from({ length: wasmTerm.getScrollbackLength() }, (_, i) => + text(wasmTerm.getScrollbackLine(i)), + ); + const fill = () => { + for (let i = 0; i < 8; i++) term.write(`line${i}\r\n`); + }; + return { term, wasmTerm, sent, screen, scrollback, fill }; + } + + test('away from a prompt, drops the scrollback and the rows above the cursor', async () => { + const { term, wasmTerm, sent, screen, scrollback, fill } = await clearTerm(); + fill(); + term.write('$ make'); + expect(term.clearScreen()).toBe(true); + expect(screen()).toEqual(['$ make', '', '', '', '']); + expect(scrollback()).toEqual([]); + expect(wasmTerm.getCursor()).toMatchObject({ x: 6, y: 0 }); + // Output in progress carries on below the cursor's row + term.write('\r\nok'); + expect(screen()).toEqual(['$ make', 'ok', '', '', '']); + expect(sent).toEqual([]); + wasmTerm.free(); + }); + + test('with the cursor on the top row, drops only the scrollback', async () => { + const { term, wasmTerm, screen, scrollback, fill } = await clearTerm(); + fill(); + term.write('\x1b[H'); + expect(term.clearScreen()).toBe(true); + expect(screen()).toEqual(['line4', 'line5', 'line6', 'line7', '']); + expect(scrollback()).toEqual([]); + wasmTerm.free(); + }); + + test('at an OSC 133 prompt, erases everything and sends FF for the shell to redraw', async () => { + const { term, wasmTerm, sent, screen, scrollback, fill } = await clearTerm(); + fill(); + term.write('\x1b]133;A\x07~ > \x1b]133;B\x07ls'); + expect(wasmTerm.cursorAtPrompt()).toBe(true); + expect(term.clearScreen()).toBe(true); + expect(screen()).toEqual(['', '', '', '', '']); + expect(scrollback()).toEqual([]); + expect(sent).toEqual(['\x0c']); + wasmTerm.free(); + }); + + test('sends no FF with stdin disabled', async () => { + const { term, wasmTerm, sent } = await clearTerm({ disableStdin: true }); + term.write('\x1b]133;A\x07$ \x1b]133;B\x07'); + expect(term.clearScreen()).toBe(true); + expect(sent).toEqual([]); + wasmTerm.free(); + }); + + test('does nothing on the alternate screen, where the key goes to the app', async () => { + const { term, wasmTerm, sent, screen, fill } = await clearTerm(); + fill(); + term.write('\x1b[?1049h\x1b[Hvim'); + expect(term.clearScreen()).toBe(false); + expect(screen()[0]).toBe('vim'); + term.write('\x1b[?1049l'); + expect(wasmTerm.getScrollbackLength()).toBe(4); + expect(sent).toEqual([]); + wasmTerm.free(); + }); + + test('clears the selection', async () => { + const { term, wasmTerm, fill } = await clearTerm(); + fill(); + let cleared = 0; + (term as unknown as { selectionManager: object }).selectionManager = { + clearSelection: () => cleared++, + }; + term.clearScreen(); + expect(cleared).toBe(1); + wasmTerm.free(); + }); +}); + describe('Terminal bell and title', () => { // Bell and title come from the engine's readEffects, not from scanning the output // (ADR 047). From 810bbb20aeed458544b69625ff18e4f2820b19a7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:14:56 -0400 Subject: [PATCH 107/295] feat(client): Ctrl+Shift+C, Ctrl+Insert and Shift+Insert outside macOS, Cmd+K on macOS, as Ghostty binds them Copy with no selection and Cmd+K on the alternate screen go to the app, as Ghostty's performable bindings do. keyboardBindings still win; the Ctrl+V listener becomes the shortcut listener's paste row. --- src/client/graphics.ts | 7 ++- src/client/index.ts | 42 +++++++++++++----- src/client/shortcuts.test.ts | 86 ++++++++++++++++++++++++++++++++++++ src/client/shortcuts.ts | 42 ++++++++++++++++++ 4 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 src/client/shortcuts.test.ts create mode 100644 src/client/shortcuts.ts diff --git a/src/client/graphics.ts b/src/client/graphics.ts index aa68f99..e2db933 100644 --- a/src/client/graphics.ts +++ b/src/client/graphics.ts @@ -508,8 +508,11 @@ export class KittyGraphics { for (const id of [...this.images.keys()]) this.dropImage(id); } - /** Placements no longer match what is on screen. Image data is kept: clients may re-place it. */ - private clear(): void { + /** + * Placements no longer match what is on screen. Image data is kept: clients may re-place + * it. Also called when the user clears the screen (ADR 054). + */ + clear(): void { this.placements.clear(); // Even when the map is already empty the overlay may still hold pixels // from placements removed without a repaint. diff --git a/src/client/index.ts b/src/client/index.ts index 54b8e6d..5c405ac 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,6 +5,7 @@ import { EdgeExtender } from './edge'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { nonTextPaste } from './paste'; +import { isMacPlatform, shortcutFor } from './shortcuts'; import { SmoothScroll } from './smooth-scroll'; import { TabTitle } from './tab-title'; import { toTerminalTheme } from './theme'; @@ -424,25 +425,46 @@ term.onPasteRequest(() => { pasteFromClipboard(); }); -// Ctrl+V never reaches us as a 'paste' event on macOS (see above), so read -// the clipboard ourselves on keydown to make it behave like Cmd+V. Both -// listeners are on `container` and keydown targets it directly, so they run -// in registration order regardless of the capture flag - this one runs -// after the configured-binding listener above, so skip a combo the user has -// already bound (including an intentionally empty binding meant to consume -// the key and suppress default paste). +// Copy, paste and clear-screen keys as Ghostty binds them for this platform (ADR 054). +// Ctrl+V never reaches us as a 'paste' event on macOS (see above), so the clipboard is +// read on keydown to make it behave like Cmd+V. Both keydown listeners are capture +// listeners on `container`, so they run in registration order - this one runs after the +// configured-binding listener above, so skip a combo the user has already bound +// (including an intentionally empty binding meant to consume the key and suppress the +// default). Both run before the input handler, which listens on the container too. +const mac = isMacPlatform( + (navigator as Navigator & { userAgentData?: { platform: string } }).userAgentData?.platform || + navigator.platform, +); container.addEventListener( 'keydown', (e: KeyboardEvent) => { - if (!e.ctrlKey || e.metaKey || e.code !== 'KeyV') return; - if (findBinding(e)) return; - if (!pasteFromClipboard()) return; + const shortcut = shortcutFor(e, mac); + if (!shortcut || findBinding(e)) return; + if (shortcut.action === 'paste') { + // Without the clipboard API the browser's own paste may still fire 'paste' + if (pasteFromClipboard()) e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + const done = shortcut.action === 'copy' ? term.copySelection() : clearScreen(); + // Not done and performable: the input handler sends the key to the app, as Ghostty does + if (!done && shortcut.performable) return; e.preventDefault(); e.stopImmediatePropagation(); }, { capture: true }, ); +// Cmd+K: nothing on the alternate screen; the clear is this tab's own, and a reload +// replays the session's output again (ADR 054) +function clearScreen(): boolean { + if (!term.clearScreen()) return false; + graphics.clear(); + smoothScroll.onOutput(); + return true; +} + container.addEventListener( 'paste', (e: ClipboardEvent) => { diff --git a/src/client/shortcuts.test.ts b/src/client/shortcuts.test.ts new file mode 100644 index 0000000..8ab4f0a --- /dev/null +++ b/src/client/shortcuts.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test'; +import { isMacPlatform, shortcutFor } from './shortcuts'; + +type Mod = 'ctrl' | 'shift' | 'alt' | 'meta'; +const key = (code: string, mods: Mod[] = []) => ({ + code, + ctrlKey: mods.includes('ctrl'), + shiftKey: mods.includes('shift'), + altKey: mods.includes('alt'), + metaKey: mods.includes('meta'), +}); + +describe('isMacPlatform', () => { + test('macOS and iOS take the macOS defaults', () => { + expect(isMacPlatform('macOS')).toBe(true); + expect(isMacPlatform('MacIntel')).toBe(true); + expect(isMacPlatform('iPhone')).toBe(true); + expect(isMacPlatform('iPad')).toBe(true); + }); + + test('Windows, Linux and ChromeOS do not', () => { + expect(isMacPlatform('Windows')).toBe(false); + expect(isMacPlatform('Win32')).toBe(false); + expect(isMacPlatform('Linux x86_64')).toBe(false); + expect(isMacPlatform('Chrome OS')).toBe(false); + expect(isMacPlatform('')).toBe(false); + }); +}); + +describe('shortcutFor outside macOS', () => { + const at = (code: string, mods: Mod[] = []) => shortcutFor(key(code, mods), false); + + test('Ctrl+Shift+C copies, and goes to the app with no selection', () => { + expect(at('KeyC', ['ctrl', 'shift'])).toEqual({ action: 'copy', performable: true }); + }); + + test('Ctrl+C stays the interrupt', () => { + expect(at('KeyC', ['ctrl'])).toBeNull(); + }); + + test('Ctrl+Insert copies and Shift+Insert pastes, consumed either way', () => { + expect(at('Insert', ['ctrl'])).toEqual({ action: 'copy', performable: false }); + expect(at('Insert', ['shift'])).toEqual({ action: 'paste', performable: false }); + expect(at('Insert')).toBeNull(); + }); + + test('Ctrl+V and Ctrl+Shift+V paste', () => { + expect(at('KeyV', ['ctrl'])?.action).toBe('paste'); + expect(at('KeyV', ['ctrl', 'shift'])?.action).toBe('paste'); + }); + + test('modifiers match exactly', () => { + expect(at('KeyC', ['ctrl', 'shift', 'alt'])).toBeNull(); + expect(at('KeyC', ['ctrl', 'shift', 'meta'])).toBeNull(); + expect(at('Insert', ['ctrl', 'shift'])).toBeNull(); + }); + + test('Cmd+K is not clear', () => { + expect(at('KeyK', ['meta'])).toBeNull(); + }); +}); + +describe('shortcutFor on macOS', () => { + const at = (code: string, mods: Mod[] = []) => shortcutFor(key(code, mods), true); + + test('Cmd+K clears, and goes to the app on the alternate screen', () => { + expect(at('KeyK', ['meta'])).toEqual({ action: 'clear', performable: true }); + expect(at('KeyK', ['meta', 'shift'])).toBeNull(); + expect(at('KeyK', ['ctrl'])).toBeNull(); + }); + + test('Ctrl+Shift+C and the Insert keys go to the app', () => { + expect(at('KeyC', ['ctrl', 'shift'])).toBeNull(); + expect(at('Insert', ['ctrl'])).toBeNull(); + expect(at('Insert', ['shift'])).toBeNull(); + }); + + test('Ctrl+V pastes, with Shift or Alt too; Cmd+V and Cmd+C are left to the browser path', () => { + expect(at('KeyV', ['ctrl'])?.action).toBe('paste'); + expect(at('KeyV', ['ctrl', 'shift'])?.action).toBe('paste'); + expect(at('KeyV', ['ctrl', 'alt'])?.action).toBe('paste'); + expect(at('KeyV', ['meta'])).toBeNull(); + expect(at('KeyV', ['ctrl', 'meta'])).toBeNull(); + expect(at('KeyC', ['meta'])).toBeNull(); + }); +}); diff --git a/src/client/shortcuts.ts b/src/client/shortcuts.ts new file mode 100644 index 0000000..0f29ae3 --- /dev/null +++ b/src/client/shortcuts.ts @@ -0,0 +1,42 @@ +/** + * A copy, paste or clear-screen key, as Ghostty binds them by default (ADR 054). + * `performable`: when the action cannot be done (no selection to copy, the alternate + * screen for a clear) the key goes on to the app, as Ghostty's `performable:` bindings + * do; otherwise it is consumed either way. A paste is never the app's: without a + * clipboard API the browser's own paste is left to run instead. + */ +export interface Shortcut { + action: 'copy' | 'paste' | 'clear'; + performable: boolean; +} + +type Keys = Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>; + +/** Whether the browser's platform takes Ghostty's macOS defaults (iPadOS and iOS too). */ +export function isMacPlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +/** + * The shortcut a `keydown` is, if any. Modifiers match exactly, as in Ghostty, except for + * Ctrl+V, which pastes with Shift or Alt held too, on every platform (ADR 014). Cmd+C and + * Cmd+V are left to the input handler and the browser's `paste` event. + */ +export function shortcutFor(e: Keys, mac: boolean): Shortcut | null { + if (e.ctrlKey && !e.metaKey && e.code === 'KeyV') return { action: 'paste', performable: false }; + const mods = `${e.ctrlKey ? 'ctrl+' : ''}${e.altKey ? 'alt+' : ''}${e.shiftKey ? 'shift+' : ''}${e.metaKey ? 'super+' : ''}`; + const trigger = `${mods}${e.code}`; + if (mac) { + if (trigger === 'super+KeyK') return { action: 'clear', performable: true }; + return null; + } + switch (trigger) { + case 'ctrl+shift+KeyC': + return { action: 'copy', performable: true }; + case 'ctrl+Insert': + return { action: 'copy', performable: false }; + case 'shift+Insert': + return { action: 'paste', performable: false }; + } + return null; +} From 4384abf8a2b3a6e2fd270bd260222b6d6498d4c3 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:15:55 -0400 Subject: [PATCH 108/295] docs(client): record the copy, paste and clear-screen keys in the specs --- docs/specs/client.md | 27 ++++++++++++++++++++++++++- docs/specs/key-bindings.md | 4 +++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 69d9bfb..906f840 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -157,7 +157,7 @@ Controlled by two config keys from `GET /api/config`: | `copyOnSelect` | `true` | Copies a mouse selection (drag, double or triple click) to the clipboard once, on mouseup, through the Terminal's `copyOnSelect` option; off, nothing is copied. | | `rightClickBehavior` | `"contextMenu"` | What a right-click the app does not take does: `"contextMenu"` the browser menu (over the textarea, so it offers Copy and Paste); `"copyPaste"` copy and clear a selection, else the menu; `"paste"` paste the clipboard (the Ctrl+V path, ADR 037); `"none"` nothing, no menu. `"default"` is read as `"contextMenu"`. | -Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these are the only reliable copy paths. +Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these and the copy keys (Cmd+C, Ctrl+Shift+C, Ctrl+Insert; see below) are the only reliable copy paths. **The app's mouse comes first** ([ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md)). While the app tracks the mouse (any DEC mouse tracking mode), a press is the app's unless Shift is held, as in Ghostty, kitty, WezTerm, xterm, Windows Terminal and VS Code. `mouse-route.ts` holds the rule (`pressToApp`, `rightClickAction`), and the input handler, the selection manager and the terminal all ask it: @@ -171,6 +171,30 @@ The terminal has the one `contextmenu` listener (`handleContextMenu`). Keys, tex What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). +## Copy, Paste and Clear Keys + +Ghostty's default copy, paste and clear-screen keys, for the browser's platform (`navigator.userAgentData.platform`, else `navigator.platform`; macOS and iOS take Ghostty's macOS defaults) ([ADR 054](../adrs/054.client.copy-paste-clear-keys.md)): + +| Platform | Key | Action | When it cannot be done | +|---|---|---|---| +| macOS | Cmd+C | copy the selection (input handler) | browser default | +| macOS | Cmd+V | paste (browser `paste` event) | | +| macOS | Cmd+K | clear the screen and scrollback | the app gets the key (alternate screen) | +| others | Ctrl+Shift+C | copy the selection | the app gets the key (`\x03`) | +| others | Ctrl+Insert | copy the selection | consumed, nothing sent | +| others | Shift+Insert | paste the clipboard (a page cannot read the primary selection) | | +| all | Ctrl+V, also with Shift or Alt | paste (ADR 014) | | + +Ctrl+C is always the app's. Modifiers match exactly except for Ctrl+V. `shortcuts.ts` maps the `keydown` (`shortcutFor`); one capture listener on the container in `index.ts`, after the `keyboardBindings` listener, performs it and steps aside for any combo `keyboardBindings` has, so a binding (even `"chars": ""`) wins. A paste reads `navigator.clipboard.readText` (a failed read sends Ctrl+V); without the API the key is kept from the app and the browser's own paste may fire the `paste` event. + +**Cmd+K** is Ghostty's `clear_screen`, done by `Terminal.clearScreen()` with VT sequences written to the terminal, never the PTY: + +- on the alternate screen, nothing: the key goes to the app (the encoder sends `k`); +- at a shell prompt (`cursorAtPrompt`, `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`, which needs OSC 133 marks, as fish 4 sends): `CSI 2 J` then `CSI 3 J`, and FF to the shell, which redraws its prompt at the top; +- otherwise: the rows above the cursor scroll off (`CSI n S`, `CSI n A`), so the cursor's row is the top one, then `CSI 3 J`. + +The selection and kitty graphics placements are cleared too. The clear is the tab's own: a reload, reconnect or another tab gets the server's replay, scrollback included. + ## Program Clipboard Writes Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH ([ADR 052](../adrs/052.client.osc52-clipboard-write.md)). Ghostty parses and decodes it (also iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522) and calls the binding's `CLIPBOARD_WRITE` callback, installed only while `clipboardWrite` is on: @@ -217,6 +241,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Welcome banner and status messages | `[ webtty ]`-styled banner on first connect; consistent status messages for disconnect, error, and server stop | [ADR 010](../adrs/010.client.ux-polish.md) | ✅ | | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | +| Copy, paste and clear keys | Ghostty's defaults for the browser's platform: Ctrl+Shift+C and Ctrl+Insert copy and Shift+Insert pastes outside macOS (Ctrl+C stays the interrupt; Ctrl+Shift+C with no selection goes to the app), Cmd+K clears the screen and scrollback on macOS (at an OSC 133 prompt with FF for the shell to redraw; the app's key on the alternate screen); `keyboardBindings` win | [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) | ✅ | | Focus reports, title and bell | Mode 1004 focus reports encoded by Ghostty, sent on each change and when the mode is set; the tab title is the program's OSC 0/2 title (`<title> \| webtty`, the session id while it has none); a bell puts 🔔 in front of it until the terminal is focused or a key is pressed, and bells replayed on connect are ignored; `bellTitle: false` turns the mark off | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | diff --git a/docs/specs/key-bindings.md b/docs/specs/key-bindings.md index 0c8e88d..37ba1fc 100644 --- a/docs/specs/key-bindings.md +++ b/docs/specs/key-bindings.md @@ -1,6 +1,6 @@ # SPEC: Key Bindings -**Last Updated:** 2026-03-29 +**Last Updated:** 2026-10-14 --- @@ -28,6 +28,8 @@ User entries are **merged with defaults by `(key, mods)` identity**: - All other defaults are preserved. - To consume a key without sending anything, set `"chars": ""`. +Bindings also win over webtty's built-in copy, paste and clear-screen keys (Ctrl+Shift+C, Ctrl+Insert, Shift+Insert and Ctrl+V; Cmd+K on macOS): a binding for one of them sends its `chars` instead, and `"chars": ""` turns the key off. See [ADR 054](../adrs/054.client.copy-paste-clear-keys.md). Font-size zoom keys are not overridable. + ## Defining `chars` The `chars` value is the byte sequence the target TUI app expects for that key combo. Two encoding approaches exist: From 459862906b484e98e929f032c21a5366c24af235 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:24:02 -0400 Subject: [PATCH 109/295] fix(client): write Cmd+K's clear where the output reaches ground, not inside a cut-off sequence The clear goes through the parser the PTY output does, and a WebSocket message can end inside a CSI or OSC: its ESC ended that sequence and the rest printed as text ("1mred"). clearScreen now marks the clear pending; writes go through libghostty-vt's vt_write_until_ground first, and the clear is written where the parser reaches ground (at once if it is there), with the alternate screen, the prompt and the cursor read at that point. A reset drops a pending clear. --- docs/adrs/054.client.copy-paste-clear-keys.md | 8 ++- docs/specs/client.md | 2 + src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/ghostty.ts | 44 ++++++++++--- src/client/vendor/ghostty-web/terminal.ts | 37 +++++++++-- src/client/vendor/official-terminal.test.ts | 17 +++++ src/client/vendor/terminal.test.ts | 63 +++++++++++++++++++ 7 files changed, 157 insertions(+), 16 deletions(-) diff --git a/docs/adrs/054.client.copy-paste-clear-keys.md b/docs/adrs/054.client.copy-paste-clear-keys.md index cb85396..13f6b65 100644 --- a/docs/adrs/054.client.copy-paste-clear-keys.md +++ b/docs/adrs/054.client.copy-paste-clear-keys.md @@ -22,7 +22,7 @@ Ghostty's default bindings (`src/config/Config.zig`, `Keybinds.init`, on `main` At a prompt Ghostty erases the scrollback first and the screen second, and libghostty-vt's ED 2 moves a screen whose last non-empty row is a prompt into the scrollback (`eraseDisplay(.complete)`, Ghostty #905), so one screen survives in the scrollback; `Termio.clearScreen` marks it `TODO`. -What libghostty-vt gives an embedder, at the pinned commit (`05dcf887`): no clear-screen call, but `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT` (`cursorIsAtPrompt`, false without semantic prompt marks and on the alternate screen), the cursor position and the active screen, and the VT parser, which does the erasing: `CSI 3 J` erases the scrollback, `CSI n S` scrolls the region up by n rows (on the primary screen the rows go into the scrollback), `CSI n A` moves the cursor up. +What libghostty-vt gives an embedder, at the pinned commit (`05dcf887`): no clear-screen call, but `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT` (`cursorIsAtPrompt`, false without semantic prompt marks and on the alternate screen), the cursor position and the active screen, `ghostty_terminal_vt_write_until_ground` (writes the shortest start of a buffer that brings the parser to ground, outside any sequence, the point at which, its header says, an embedder can insert its own sequences), and the VT parser, which does the erasing: `CSI 3 J` erases the scrollback, `CSI n S` scrolls the region up by n rows (on the primary screen the rows go into the scrollback), `CSI n A` moves the cursor up. Two things are webtty's own. User `keyboardBindings` (ADR 018) are matched by a capture listener on the container first, and the Ctrl+V listener already steps aside for a combo the user has bound. And the server replays the session's recent output on every connect (ADR 007): a clear done only in the client comes back on the next reload. @@ -60,7 +60,9 @@ Modifiers match exactly, as Ghostty's triggers do: Ctrl+Alt+Shift+C is not copy. - **at a prompt** (the binding's new `cursorAtPrompt()`, `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`): writes `CSI 2 J` then `CSI 3 J` to the terminal and sends FF to the app through `onData`. The order is the other way round from Ghostty's, so the screen ED 2 moves into the scrollback is erased too: the scrollback ends up empty, which is what `clear_screen` is for; - **otherwise**: with the cursor on row y > 0, writes `CSI y S` and `CSI y A` (the rows above the cursor scroll off and the cursor follows its row to the top, as `eraseActive` leaves it), then `CSI 3 J`. -The sequences go to the terminal only (`term.write`), never to the PTY; only FF is sent. `index.ts` then clears the kitty graphics placements (`KittyGraphics.clear`, as an ED from the app does; image data is kept for re-placement) and lets the smooth scroll settle at the bottom. +The sequences go to the terminal only, never to the PTY; only FF is sent. + +**At ground.** They go through the parser the PTY output goes through, and a WebSocket message can end inside a sequence (`ESC [ 3` with `1m` in the next one). Ghostty's `clear_screen` changes the screen directly; an `ESC` written into the middle of a sequence would end it, and the rest would print as text (`1m`) with its effect lost. So `clearScreen` marks the clear pending, and every write while it is pending goes through `vt_write_until_ground` first: when the parser reaches ground (at once, with an empty write, if it is there already) the clear is written, the alternate screen and the prompt checked and the cursor row read at that point, then the rest of the output. A pending clear is dropped by a reset (a new engine, as on reconnect) and does nothing if the output before it turned the alternate screen on. `index.ts` then clears the kitty graphics placements (`KittyGraphics.clear`, as an ED from the app does; image data is kept for re-placement) and lets the smooth scroll settle at the bottom. ## Consequences @@ -68,6 +70,7 @@ The sequences go to the terminal only (`term.write`), never to the PTY; only FF - On macOS, Cmd+K clears the screen and the scrollback on the normal screen. At a fish 4 prompt (or any shell sending OSC 133) the shell redraws its prompt at the top; at a shell without semantic prompts, the prompt row moves to the top. In vim, less or any full-screen program, Cmd+K is the app's key, as in Ghostty. - The clear is this tab's own. A reload, a reconnect or another tab on the session replays the session's output from the server, scrollback included, until the program clears it. At a prompt the shell's redraw after FF is replayed as well. - With a scroll region set on the normal screen (rare at a shell), `CSI y S` scrolls only the region, and the result differs from Ghostty's, which deletes rows regardless. +- Cmd+K pressed while the output has stopped inside a sequence clears when the rest of the sequence arrives. The graphics placements and the selection are cleared at the key press. - `keyboardBindings` can still claim any of these keys. - The browser's own Ctrl+Shift+C (inspect element in Chrome) is no more reachable than before: the key was already `preventDefault`ed when it was sent as `\x03`. @@ -78,6 +81,7 @@ The sequences go to the terminal only (`term.write`), never to the PTY; only FF - **Ghostty's order at a prompt (scrollback first, then the screen).** Leaves the old screen in the scrollback, the `TODO` Ghostty records. - **Always clear the whole screen and send FF.** Without semantic prompts webtty cannot tell a prompt from a program writing output, and FF to a running program is a stray byte; Ghostty sends it only at a prompt. - **Clear on the server too, so a reload shows the cleared screen.** The replay is a byte buffer the server does not parse; cutting it at a client's request is a protocol change, and other tabs on the session would lose their history. +- **Track in the client whether the output stopped inside a sequence** (as `ControlScanner` keeps an unfinished `CSI` tail). A second VT parser to keep in step with Ghostty's for OSC, DCS, APC and UTF-8; the engine knows its own state. - **`RIS` or `CSI 2 J` everywhere.** RIS resets modes the program set; ED 2 alone keeps the scrollback. ## Related Decisions diff --git a/docs/specs/client.md b/docs/specs/client.md index 906f840..83fd6ad 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -193,6 +193,8 @@ Ctrl+C is always the app's. Modifiers match exactly except for Ctrl+V. `shortcut - at a shell prompt (`cursorAtPrompt`, `GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`, which needs OSC 133 marks, as fish 4 sends): `CSI 2 J` then `CSI 3 J`, and FF to the shell, which redraws its prompt at the top; - otherwise: the rows above the cursor scroll off (`CSI n S`, `CSI n A`), so the cursor's row is the top one, then `CSI 3 J`. +The sequences go in only where the output is at ground: if the last message ended inside a sequence, the clear waits and is written where the next output finishes it (`ghostty_terminal_vt_write_until_ground`), with the screen, prompt and cursor read then. + The selection and kitty graphics placements are cleared too. The clear is the tab's own: a reload, reconnect or another tab gets the server's replay, scrollback included. ## Program Clipboard Writes diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index fa81e98..7dc096e 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -41,5 +41,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `mouse-route.ts` (new), `input-handler.ts`, `selection-manager.ts`, `terminal.ts`, `interfaces.ts`: the mouse follows the app's tracking (ADR 051). `mouse-route.ts` holds `pressToApp` (a press is the app's while it tracks the mouse and Shift is up), `rightClickAction` (what a `contextmenu` does: the app's, the browser menu, copy, paste or nothing, from the new `rightClickBehavior` option) and `MenuSource` (a menu from a button or the keyboard). The selection manager starts no selection, and selects no word or line on a double or triple click, for a press the app takes; upstream selected on every left press, so a drag in vim both reached vim and was highlighted and copied. The input handler reports nothing for a button pressed with Shift (press, motion while held, release); upstream reported it. The input handler passes `mouseReport` to its data callback for mouse reports, and the terminal's `handleInput` clears the selection only for other input; upstream cleared it for every report, so a Shift selection vanished at the next hover in mode 1003. The selection manager's own `contextmenu` listener, which moved the textarea under the pointer for every right-click, becomes `prepareContextMenu`, called by the terminal's one `contextmenu` listener (`handleContextMenu`) when the menu is shown; `paste` fires the new `Terminal.onPasteRequest`. - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. - `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. -- `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). Upstream had no clear-screen action (its `clear`, removed above, was unused). +- `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). The binding adds `writeUntilGround` (`ghostty_terminal_vt_write_until_ground`; `write` and it share `feed`), and the clear is pending until a write reaches ground, so it never cuts a sequence the output left unfinished; `reset` drops it. Upstream had no clear-screen action (its `clear`, removed above, was unused). - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index a890030..b517f75 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -105,6 +105,12 @@ export interface GhosttyTerminal { // Lifecycle write(data: string | Uint8Array): void; + /** + * Writes the shortest start of `data` that brings the parser to ground, outside any + * sequence (ESC, CSI, OSC, DCS, UTF-8), and returns its length: 0 when already there. + * Null when all of `data` was written without reaching it. (ADR 054) + */ + writeUntilGround(data: Uint8Array): number | null; /** Grid resize, with the device-pixel cell size for size reports when known. */ resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void; /** Device-pixel cell size for size reports. */ @@ -244,6 +250,12 @@ interface OfficialWasmExports { outWritten: number ): number; ghostty_terminal_vt_write(term: number, ptr: number, len: number): void; + ghostty_terminal_vt_write_until_ground( + term: number, + ptr: number, + len: number, + outConsumed: number + ): number; ghostty_terminal_paste(term: number, paste: number, outWritten: number): number; /** The GhosttyTerminalScrollViewport struct is passed by value, i.e. by pointer on wasm32. */ ghostty_terminal_scroll_viewport(term: number, behavior: number): void; @@ -1328,21 +1340,38 @@ export class OfficialTerminal implements GhosttyTerminal { write(data: string | Uint8Array): void { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + this.feed(bytes, typeof data === 'string' ? data : undefined, false); + } + + writeUntilGround(data: Uint8Array): number | null { + return this.feed(data, undefined, true); + } + + /** vt_write, or vt_write_until_ground: the bytes consumed, null if ground was not reached. */ + private feed(bytes: Uint8Array, text: string | undefined, untilGround: boolean): number | null { // Writes never leave the view scrolled (terminal.ts scrolls to the bottom after // writing); the first frame at the bottom repaints every row. if (this.offset !== 0) { this.offset = 0; this.dirtyMax = DirtyState.FULL; } - if (bytes.length > 0) { - const ex = this.ex; - const ptr = ex.ghostty_wasm_alloc(bytes.length); - if (!ptr) throw new Error('libghostty-vt: out of memory'); - this.u8().set(bytes, ptr); + const ex = this.ex; + let consumed: number | null = bytes.length; + // until_ground takes NULL for no bytes, and then only says whether it is at ground + const ptr = bytes.length > 0 ? ex.ghostty_wasm_alloc(bytes.length) : 0; + if (bytes.length > 0 && !ptr) throw new Error('libghostty-vt: out of memory'); + if (ptr) this.u8().set(bytes, ptr); + if (untilGround) { + const rc = ex.ghostty_terminal_vt_write_until_ground(this.term, ptr, bytes.length, this.at.u32); + consumed = rc === this.L.result.SUCCESS ? this.dv().getUint32(this.at.u32, true) : null; + } else if (ptr) { ex.ghostty_terminal_vt_write(this.term, ptr, bytes.length); - ex.ghostty_wasm_free(ptr, bytes.length); + } + if (ptr) ex.ghostty_wasm_free(ptr, bytes.length); + const fed = consumed ?? bytes.length; + if (fed > 0) { const scan = scanCursorBlink( - typeof data === 'string' ? data : LATIN1.decode(bytes), + text ?? LATIN1.decode(bytes.subarray(0, fed)), this.blinkCarry ); this.blinkCarry = scan.carry; @@ -1354,6 +1383,7 @@ export class OfficialTerminal implements GhosttyTerminal { } this.generation++; if (this.titleChanged) this.readTitle(); + return consumed; } resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 55af022..e42c600 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -247,6 +247,9 @@ export class Terminal implements ITerminalCore { private focused = false; private focusReporting = false; + // A clearScreen waiting for the output to reach ground (webtty, ADR 054) + private pendingClear = false; + // Phase 2: Viewport and scrolling state public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) private customWheelEventHandler?: (event: WheelEvent) => boolean; @@ -678,22 +681,31 @@ export class Terminal implements ITerminalCore { * shell prompt the screen, sending FF so the shell redraws its prompt, and elsewhere the * rows above the cursor, whose row becomes the top one. Returns false, doing nothing, on * the alternate screen, where the key is the app's. The erasing is written to the - * terminal, not the PTY. (webtty, ADR 054) + * terminal, not the PTY, and so waits for output cut off inside a sequence to finish + * it. (webtty, ADR 054) */ clearScreen(): boolean { this.assertOpen(); - const wasmTerm = this.wasmTerm!; - if (wasmTerm.isAlternateScreen()) return false; + if (this.wasmTerm!.isAlternateScreen()) return false; this.clearSelection(); + this.pendingClear = true; + this.writeInternal(new Uint8Array(0)); + return true; + } + + /** The erasing of `clearScreen`, for when the parser is at ground. */ + private writeClear(): void { + const wasmTerm = this.wasmTerm!; + // The output before it may have turned the alternate screen on + if (wasmTerm.isAlternateScreen()) return; if (wasmTerm.cursorAtPrompt()) { // ED 2 at a prompt moves the screen into the scrollback, so ED 3 comes after it - this.write('\x1b[2J\x1b[3J'); + wasmTerm.write('\x1b[2J\x1b[3J'); if (!this.options.disableStdin) this.dataEmitter.fire('\x0c'); } else { const y = wasmTerm.getCursor().y; - this.write(`${y > 0 ? `\x1b[${y}S\x1b[${y}A` : ''}\x1b[3J`); + wasmTerm.write(`${y > 0 ? `\x1b[${y}S\x1b[${y}A` : ''}\x1b[3J`); } - return true; } /** @@ -718,6 +730,17 @@ export class Terminal implements ITerminalCore { // preserve selection when new data arrives. Selection is cleared by user actions // like clicking or typing, not by incoming data. + // A waiting clear goes in where the output reaches ground (webtty, ADR 054) + if (this.pendingClear) { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + const n = this.wasmTerm!.writeUntilGround(bytes); + if (n !== null) { + this.pendingClear = false; + this.writeClear(); + } + data = bytes.subarray(n ?? bytes.length); + } + // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); @@ -860,6 +883,8 @@ export class Terminal implements ITerminalCore { this.currentTitle = ''; // The new engine starts with mode 1004 off (webtty, ADR 053) this.focusReporting = false; + // A clear waiting for the old engine's output is not for the new one (webtty, ADR 054) + this.pendingClear = false; } /** diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index 98f4e4b..f267595 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -830,6 +830,23 @@ describe('official engine: cursor at a prompt (ADR 054)', () => { expect(t.cursorAtPrompt()).toBe(false); t.free(); }); + + test('writeUntilGround writes up to the end of a cut-off sequence', () => { + const t = term(); + const bytes = (s: string) => new TextEncoder().encode(s); + expect(t.writeUntilGround(bytes(''))).toBe(0); + expect(t.writeUntilGround(bytes('ab'))).toBe(0); + t.write('a\x1b[3'); + expect(t.writeUntilGround(new Uint8Array(0))).toBeNull(); + expect(t.writeUntilGround(bytes('1;'))).toBeNull(); + expect(t.writeUntilGround(bytes('2mbc'))).toBe(2); + t.write('\x1b]2;ti'); + expect(t.writeUntilGround(bytes('tle\x07x'))).toBe(4); + // A multi-byte character cut in two is a sequence too + t.write(new Uint8Array([0xc3])); + expect(t.writeUntilGround(new Uint8Array([0xa9, 0x41]))).toBe(1); + t.free(); + }); }); describe('official engine: memory growth', () => { diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 4026f3b..b154766 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -377,6 +377,69 @@ describe('Terminal clearScreen', () => { expect(cleared).toBe(1); wasmTerm.free(); }); + + test('waits for a sequence cut off by the end of a write, and clears where it ends', async () => { + const { term, wasmTerm, screen, scrollback, fill } = await clearTerm(); + fill(); + term.write('build \x1b[3'); + expect(term.clearScreen()).toBe(true); + expect(scrollback().length).toBeGreaterThan(0); + // The rest of the SGR finishes it; the clear goes in there, before the text after it + term.write('1mred\r\nnext'); + expect(screen()).toEqual(['build red', 'next', '', '', '']); + expect(scrollback()).toEqual([]); + const red = wasmTerm.getLine(0)?.[6]; + expect(red && red.fg_r > red.fg_g).toBe(true); + wasmTerm.free(); + }); + + test('a cut-off OSC keeps its effect', async () => { + const { term, wasmTerm, scrollback, fill } = await clearTerm(); + fill(); + const titles: string[] = []; + term.onTitleChange((t) => titles.push(t)); + term.write('\x1b]2;bui'); + term.clearScreen(); + term.write('ld\x07'); + expect(titles).toEqual(['build']); + expect(scrollback()).toEqual([]); + wasmTerm.free(); + }); + + test('a write that ends inside another sequence still gets the clear', async () => { + const { term, wasmTerm, screen, scrollback, fill } = await clearTerm(); + fill(); + term.write('a\x1b[3'); + term.clearScreen(); + term.write('1mb\x1b[0'); + expect(scrollback()).toEqual([]); + term.write('mc'); + expect(screen()).toEqual(['abc', '', '', '', '']); + wasmTerm.free(); + }); + + test('a waiting clear at a prompt checks the prompt and sends FF once it goes in', async () => { + const { term, wasmTerm, sent, screen, fill } = await clearTerm(); + fill(); + term.write('\x1b]133;A\x07$ \x1b]133;B\x07\x1b[3'); + term.clearScreen(); + expect(sent).toEqual([]); + term.write('1m'); + expect(screen()).toEqual(['', '', '', '', '']); + expect(sent).toEqual(['\x0c']); + wasmTerm.free(); + }); + + test('a waiting clear is dropped if the screen has turned alternate', async () => { + const { term, wasmTerm, scrollback, fill } = await clearTerm(); + fill(); + term.write('\x1b[?1049'); + term.clearScreen(); + term.write('h\x1b[Hvim'); + term.write('\x1b[?1049l'); + expect(scrollback().length).toBeGreaterThan(0); + wasmTerm.free(); + }); }); describe('Terminal bell and title', () => { From 9ef809be331fd0884005fe4100aeb6e866bd0c21 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:33:07 -0400 Subject: [PATCH 110/295] docs(client): ADR 055, find in the scrollback with libghostty-vt's search --- docs/adrs/055.client.find-in-scrollback.md | 106 +++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/adrs/055.client.find-in-scrollback.md diff --git a/docs/adrs/055.client.find-in-scrollback.md b/docs/adrs/055.client.find-in-scrollback.md new file mode 100644 index 0000000..8d03c30 --- /dev/null +++ b/docs/adrs/055.client.find-in-scrollback.md @@ -0,0 +1,106 @@ +# ADR 055: client — Find in the scrollback with libghostty-vt's search + +**SPEC:** [client](../specs/client.md), [key-bindings](../specs/key-bindings.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +The terminal is drawn on a canvas, so the browser's find (Cmd+F, Ctrl+F) finds nothing in it, and Cmd+F opens a find bar that searches only the page's empty DOM. There is no way to search the output. + +Ghostty 1.3 has search (`src/config/Config.zig`, `Keybinds.init`, on `main` at the time of writing): + +- **Bindings.** On macOS `super+f` is `start_search`, `super+shift+f` `end_search`, `super+g` `navigate_search:next` and `super+shift+g` `navigate_search:previous`; elsewhere `ctrl+shift+f` is `start_search`. On every platform `escape` is `end_search`. All are `performable:`: `end_search` and `navigate_search` with no search open are not performed, and the key goes to the app (Escape stays the app's until a search is open). +- **The bar** (`src/apprt/gtk/ui/1.2/search-overlay.blp`, `search_overlay.zig`): a "Find…" entry at the top right of the surface, a `selected/total` label (`0/0` with no matches, `0/n` before a match is selected), up and down buttons and a close button. Typing searches as you type (GTK's search entry debounces it); Enter is `navigate_search:next`, Shift+Enter `navigate_search:previous`. Starting a search when one is open focuses the entry and selects its text. Nothing is selected until the first Enter: all matches are highlighted, and the first `next` selects the newest one. +- **Order.** `next` moves toward older output, from the bottom of the screen up into the scrollback, and wraps around past the oldest match; `previous` moves toward newer output. The up button is `next`. +- **Matching** (`src/terminal/search/sliding_window.zig`): the page text is searched as one stream, so a match can span a soft wrap; ASCII letters compare case-insensitively (`std.ascii.indexOfIgnoreCase`) and everything else byte-exactly. There is no smart case and no regex. +- **Scrolling** (`src/terminal/search/terminal.zig`, `select`): if no row of the newly selected match is in the viewport, the viewport scrolls so the match's first row is the top row; otherwise it does not move. +- **Colours**: `search-background` / `search-foreground` (default `#FFE082` on black) for matches and `search-selected-background` / `search-selected-foreground` (default `#F2A57E` on black) for the selected one. The renderer gives the selection precedence over the selected match, and the selected match over other matches (`src/renderer/generic.zig`). +- **Output** does not move a scrolled viewport (`scroll-to-bottom` defaults to `keystroke, no-output`), so the selected match stays in view while a program writes. + +The pinned libghostty-vt wasm (`05dcf887`, ADR 042) exports Ghostty's search as a C API (`include/ghostty/vt/search.h`, `ghostty_search_*`): a search bound to a terminal, given a needle, driven in bounded steps by `ghostty_search_feed` (reads the terminal) and `ghostty_search_tick` (works on what it read), with the total, the selected match and its index (0 is the newest), every match (newest to oldest, as selections), and `SELECT_NEXT` / `SELECT_PREV`, which can be told not to scroll. Results follow output, scrollback pruning, resize, reflow and screen switches. On 10,000 lines of scrollback a full search takes about 40-50 ms in about 25 steps of 2 ms; reading all 20,000 matches of a common word and converting them to screen rows takes about 3 ms (107,000 matches of `e`: about 11 ms); a feed with no change costs 0.5-2 ms. + +webtty's viewport is its own (`viewportY`, lines up from the bottom, ADR 042); Ghostty's stays at the bottom. And webtty, as xterm.js does, scrolls a scrolled view to the bottom on any output. + +## Decision + +### Search with libghostty-vt + +Find uses Ghostty's search, not a text index of webtty's own over ADR 050's selection text: the matching, the soft-wrap joining, the newest-to-oldest order and the tracking of output, pruning and resize are Ghostty's, at no cost in bundle size. Matching is Ghostty's: case-insensitive for ASCII letters, exact otherwise; no smart case. + +The binding (`ghostty.ts`) adds, on the active screen, scrollback included: + +- `setSearch(needle)`: creates the search on first use (with `SELECT_SCROLL = NONE`: the binding's viewport is not Ghostty's) and sets the needle; `''` frees it; +- `searchStep(budgetMs)`: feeds if the terminal changed since the last feed, then ticks and feeds until the search is caught up or the budget is spent; returns whether the results may have changed; +- `searchSelect('next' | 'previous')`, `searchState()` (total and selected index), `searchSelectedMatch()` and `searchMatches()` (every match as screen rows and columns, newest to oldest). + +### The terminal drives it + +`Terminal` (vendored `terminal.ts`) keeps the needle and runs the search in its render loop, before each render: a step of at most 4 ms, so a 10,000-line search completes over a dozen frames and typing never blocks. The match rows are read again when the results changed, at most once every ten times the last read took (a few frames for a 100,000-match needle while output streams; at once otherwise). The needle survives `reset` (a reconnect's new engine) and is set on the new one. + +- `find(needle)`, `findNext()`, `findPrevious()`, `endFind()` and `onFindChange` (total, selected index) are the bar's API. +- **Scrolling to the selected match** is Ghostty's: if none of its rows is in view, the view jumps so its first row is the top row, clamped to the scrollback. SmoothScroll follows the jump, as it follows scrollbar drags. +- **While a match is selected and the view is scrolled up**, output does not pull the view to the bottom; the view keeps its rows in place (it moves by as many rows as the selected match did), as Ghostty's viewport does. At the bottom, the view follows output as before. With the bar closed, or nothing selected, webtty's scroll-to-bottom on output is unchanged. + +### Highlights + +The renderer draws every match in the rows it paints with the search colours, and the selected one with the selected colours, replacing the cell's colours as the selection does; the selection wins over both, as in Ghostty. The matches in view are found by binary search on the newest-to-oldest list, so a frame costs the same with 10 matches or 100,000. Each canvas row remembers the matches it was drawn with, and a row is repainted only when they change (moved with the rows on a scroll, ADR 043). + +Four new optional theme keys, Ghostty's names in webtty's style, with Ghostty's defaults: `searchBackground` (`#FFE082`), `searchForeground` (`#000000`), `searchSelectedBackground` (`#F2A57E`), `searchSelectedForeground` (`#000000`). + +### The find bar + +`find-bar.ts`: a small DOM overlay at the top right of the terminal, outside the `#terminal` container, so keys typed in it never reach the terminal's key handling and focus in it is not the terminal's. A labelled text field ("Find", placeholder "Find…"), the `selected/total` count as Ghostty shows it (`0/0`, `0/n` before Enter, `k/n`) in a polite live region, previous and next buttons (↑ is next, toward older output, as in Ghostty) and a close button. Its colours come from the theme (background, foreground, `brightBlack` for the border). + +- Typing searches after 150 ms (GTK's search-entry delay); Enter and Shift+Enter apply a pending needle at once and then go to the next or previous match. +- Escape, or the close button, ends the search: highlights cleared, the field's text kept for the next open, focus back on the terminal. +- Opening it when it is open focuses the field and selects its text; opening it again after a close searches the kept text again. + +### Bindings + +| Platform | Key | Action | When it cannot be done | +|---|---|---|---| +| macOS | Cmd+F | open / focus the bar | (always done) | +| macOS | Cmd+Shift+F | close | sent to the app (no search open) | +| macOS | Cmd+G / Cmd+Shift+G | next / previous match | sent to the app (no search open) | +| others | Ctrl+Shift+F | open / focus the bar | (always done) | +| all | Escape | close | sent to the app (no search open) | +| all, in the field | Enter / Shift+Enter | next / previous match | | + +They go through `shortcutFor` and the capture listener of ADR 054, so a `keyboardBindings` entry for the combo wins, and `"chars": ""` turns one off. In the field the same keys (and Cmd+G on macOS, Escape everywhere) are handled by the field itself; `keyboardBindings` are terminal keys and do not apply there. The browser's own find is `preventDefault`ed for Cmd+F and Ctrl+Shift+F only: plain Ctrl+F stays the app's (forward a character in readline). + +### Replay + +Find has no side effects: nothing is sent to the PTY, the clipboard or the title. The replay on connect is searched like any output. + +## Consequences + +- Cmd+F (macOS) or Ctrl+Shift+F (elsewhere) finds text anywhere in the scrollback and on screen, matches spanning soft wraps included, with Ghostty's matching, order, colours and scrolling. +- Escape is the app's except while a search is open, as in Ghostty: with the bar open and the terminal focused, Escape closes the bar instead of reaching vim. +- The count can rise for a few frames after typing while a long scrollback is searched. +- On the alternate screen, the alternate screen's matches are found (as Ghostty does); the primary screen's come back when the app leaves it. +- The browser's own find bar is no longer reachable by Cmd+F from the terminal page (it found nothing there). +- Highlights in the rows beyond the scrollback limit go away as Ghostty prunes them; a selected match that is pruned is no longer selected (the count shows `0/n`). +- Four theme keys are added. + +## Considered Options + +- **A JS index of the text from ADR 050's formatter** (`selection_format_alloc` with `unwrap`). It would need the scrollback formatted on every change (tens of ms per 10,000 lines), its own row mapping across soft wraps and wide characters, and its own handling of pruning; Ghostty already does all of this, with the same matching Ghostty's own find bar has. +- **Smart case or regex.** Not Ghostty's; a later ADR if wanted. +- **Ghostty's `SCROLL_IF_NEEDED`.** It scrolls Ghostty's viewport, which webtty does not show (the binding keeps it at the bottom). +- **`VIEWPORT_MATCHES`.** Computed for Ghostty's viewport, which is always the bottom one here. +- **Select the newest match as you type** (browsers, VS Code). Ghostty highlights all and selects on Enter; following it keeps typing from moving the view. +- **A smooth glide to the match.** Ghostty jumps; a glide over thousands of lines would sweep through them. +- **F3 / Shift+F3** (Windows Terminal, VS Code). Not Ghostty's defaults; `keyboardBindings` cannot add webtty actions, so this would be a fixed extra binding. +- **Keep scrolling to the bottom on output while searching.** Each chunk of output would take the view away from the selected match, so Enter could not be used during a build. + +## Related Decisions + +- [ADR 018 — Key bindings config support](018.key-bindings.config-support.md): user bindings run first +- [ADR 036 — Map webtty theme keys](036.client.theme-key-mapping.md): theme keys +- [ADR 042 — Official libghostty-vt engine](042.client.official-vt-engine.md): the pinned wasm and webtty's own viewport +- [ADR 043 — Copy rows on scroll, then overscan](043.client.scroll-copy-and-overscan.md): canvas rows repainted only on change +- [ADR 050 — Selection from libghostty-vt](050.client.selection-from-libghostty-vt.md): the selection drawn over matches +- [ADR 054 — Copy, paste and clear-screen keys](054.client.copy-paste-clear-keys.md): the shortcut listener and `performable` From bae70c7a9774bb0b27ea8eb26d66152390768316 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:44:59 -0400 Subject: [PATCH 111/295] feat(client): search the scrollback with libghostty-vt's ghostty_search_* in the binding --- src/client/vendor/ghostty-web/ghostty.ts | 206 +++++++++++++++++++- src/client/vendor/official-terminal.test.ts | 105 ++++++++++ 2 files changed, 310 insertions(+), 1 deletion(-) diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index b517f75..184106b 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -81,6 +81,12 @@ export interface ScreenRange { end: ScreenCell; } +/** Where a search is (ADR 055): its match count and the selected match's index, 0 the newest. */ +export interface SearchState { + total: number; + selected: number | null; +} + /** Bell, title and clipboard writes since the last `readEffects` (ADR 042, ADR 052). */ export interface TerminalEffects { bell: boolean; @@ -168,6 +174,28 @@ export interface GhosttyTerminal { */ selectionText(start: ScreenCell, end: ScreenCell): string; + // Search (ADR 055), Ghostty's, on the active screen, scrollback included + /** + * Sets the text to find, matched as Ghostty does: ASCII letters case-insensitively, + * the rest exactly, soft-wrapped rows joined. `''` ends the search. + */ + setSearch(needle: string): void; + /** + * Catches the search up with the terminal, working for at most about `budgetMs`. + * Returns whether the results may have changed. + */ + searchStep(budgetMs: number): boolean; + /** Selects the next match (toward older output) or the previous one, wrapping; false if none. */ + searchSelect(direction: 'next' | 'previous'): boolean; + searchState(): SearchState; + /** The selected match in screen rows, or null. */ + searchSelectedMatch(): ScreenRange | null; + /** + * Every match, newest to oldest, as screen rows: start col, start row, end col, end + * row per match, both ends inclusive. + */ + searchMatches(): Int32Array; + // Mouse reports (ADR 049) /** The geometry `encodeMouse` positions are in. */ setMouseGeometry(geometry: MouseGeometry): void; @@ -340,6 +368,12 @@ interface OfficialWasmExports { /** The GhosttyMousePosition struct is passed by value, i.e. by pointer on wasm32. */ ghostty_mouse_event_set_position(event: number, position: number): void; ghostty_focus_encode(event: number, buf: number, len: number, outWritten: number): number; + ghostty_search_new(allocator: number, out: number, term: number): number; + ghostty_search_free(search: number): void; + ghostty_search_tick(search: number, outStatus: number): number; + ghostty_search_feed(search: number): number; + ghostty_search_set(search: number, option: number, value: number): number; + ghostty_search_get(search: number, data: number, out: number): number; } interface ManifestBits { @@ -773,6 +807,24 @@ function resolveOfficialLayout(layout: VtLayout) { SUCCESS: layout.value('GhosttyClipboardWriteResult', 'SUCCESS'), UNSUPPORTED: layout.value('GhosttyClipboardWriteResult', 'UNSUPPORTED'), }, + search: { + bufferSize: layout.size('GhosttySelectionBuffer'), + ptr: layout.offset('GhosttySelectionBuffer', 'ptr'), + cap: layout.offset('GhosttySelectionBuffer', 'cap'), + len: layout.offset('GhosttySelectionBuffer', 'len'), + NEEDLE: layout.value('GhosttySearchOption', 'NEEDLE'), + SELECT_NEXT: layout.value('GhosttySearchOption', 'SELECT_NEXT'), + SELECT_PREV: layout.value('GhosttySearchOption', 'SELECT_PREV'), + SELECT_SCROLL: layout.value('GhosttySearchOption', 'SELECT_SCROLL'), + SCROLL_NONE: layout.value('GhosttySearchScroll', 'NONE'), + STATUS: layout.value('GhosttySearchData', 'STATUS'), + TOTAL_MATCHES: layout.value('GhosttySearchData', 'TOTAL_MATCHES'), + SELECTED_INDEX: layout.value('GhosttySearchData', 'SELECTED_INDEX'), + SELECTED_MATCH: layout.value('GhosttySearchData', 'SELECTED_MATCH'), + MATCHES: layout.value('GhosttySearchData', 'MATCHES'), + COMPLETE: layout.value('GhosttySearchStatus', 'COMPLETE'), + FEED_REQUIRED: layout.value('GhosttySearchStatus', 'FEED_REQUIRED'), + }, focus: { GAINED: layout.value('GhosttyFocusEvent', 'GAINED'), LOST: layout.value('GhosttyFocusEvent', 'LOST'), @@ -1169,7 +1221,8 @@ export class OfficialTerminal implements GhosttyTerminal { | 'mousePos' | 'mouseOut' | 'focusOut' - | 'clipReply', + | 'clipReply' + | 'searchBuf', number >; private readonly callbacks: InstalledFn[] = []; @@ -1240,6 +1293,13 @@ export class OfficialTerminal implements GhosttyTerminal { private pendingTitle: string | null = null; private pendingClipboard: string | null = null; + // Search (ADR 055): the handle while a needle is set, the generation it last fed, and + // the wasm buffer matches are read into, grown as needed. + private search = 0; + private searchFedGen = 0; + private matchBuf = 0; + private matchCap = 0; + constructor(vt: Ghostty, cols: number, rows: number, config?: GhosttyTerminalConfig) { this.vt = vt; this.ex = vt.ex; @@ -1301,6 +1361,7 @@ export class OfficialTerminal implements GhosttyTerminal { mouseOut: [MOUSE_REPORT_MAX, 8], focusOut: [FOCUS_REPORT_MAX, 8], clipReply: [L.clipboardWrite.replySize, 8], + searchBuf: [L.search.bufferSize, 8], } as const); this.scratch = scratch; this.at = at; @@ -1458,6 +1519,7 @@ export class OfficialTerminal implements GhosttyTerminal { free(): void { const ex = this.ex; if (!this.term) return; + this.setSearch(''); ex.ghostty_terminal_free(this.term); this.term = 0; // Slots are released only after nothing can call them (fn-table.ts rule 3). @@ -1669,6 +1731,148 @@ export class OfficialTerminal implements GhosttyTerminal { return text; } + // ========================================================================== + // Search (ADR 055) + // ========================================================================== + + setSearch(needle: string): void { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + if (!needle) { + if (this.search) ex.ghostty_search_free(this.search); + this.search = 0; + if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * this.L.selection.size); + this.matchBuf = 0; + this.matchCap = 0; + return; + } + if (!this.search) { + this.search = this.vt.newHandle('search_new', (slot) => + ex.ghostty_search_new(0, slot, this.term) + ); + // The view is webtty's own; Ghostty's viewport stays at the bottom + this.dv().setInt32(at.u32, S.SCROLL_NONE, true); + ex.ghostty_search_set(this.search, S.SELECT_SCROLL, at.u32); + } + const bytes = new TextEncoder().encode(needle); + const buf = ex.ghostty_wasm_alloc(bytes.length); + try { + this.u8().set(bytes, buf); + const dv = this.dv(); + dv.setUint32(at.str + this.L.string.ptr, buf, true); + dv.setUint32(at.str + this.L.string.len, bytes.length, true); + // An unchanged needle keeps its results + ex.ghostty_search_set(this.search, S.NEEDLE, at.str); + } finally { + ex.ghostty_wasm_free(buf, bytes.length); + } + this.searchFedGen = 0; + } + + searchStep(budgetMs: number): boolean { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + if (!this.search) return false; + let worked = false; + // Feeding is the only way the search sees output, resizes and resets + if (this.searchFedGen !== this.generation) { + ex.ghostty_search_feed(this.search); + this.searchFedGen = this.generation; + worked = true; + } + if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) return worked; + let status = this.dv().getInt32(at.u32, true); + const end = performance.now() + budgetMs; + while (status !== S.COMPLETE && performance.now() < end) { + if (status === S.FEED_REQUIRED) ex.ghostty_search_feed(this.search); + else ex.ghostty_search_tick(this.search, 0); + if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) break; + status = this.dv().getInt32(at.u32, true); + worked = true; + } + return worked; + } + + searchSelect(direction: 'next' | 'previous'): boolean { + const S = this.L.search; + if (!this.search) return false; + const option = direction === 'next' ? S.SELECT_NEXT : S.SELECT_PREV; + return this.ex.ghostty_search_set(this.search, option, 0) === 0; + } + + searchState(): SearchState { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + if (!this.search) return { total: 0, selected: null }; + const read = (data: number): number | null => + ex.ghostty_search_get(this.search, data, at.u32) === 0 + ? this.dv().getUint32(at.u32, true) + : null; + return { total: read(S.TOTAL_MATCHES) ?? 0, selected: read(S.SELECTED_INDEX) }; + } + + searchSelectedMatch(): ScreenRange | null { + const at = this.at; + const Sel = this.L.selection; + if (!this.search) return null; + this.clearSelectionStruct(); + if (this.ex.ghostty_search_get(this.search, this.L.search.SELECTED_MATCH, at.sel) !== 0) { + return null; + } + const start = this.screenCellOf(at.sel + Sel.start); + const end = this.screenCellOf(at.sel + Sel.end); + return start && end ? { start, end } : null; + } + + searchMatches(): Int32Array { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + const Sel = this.L.selection; + if (!this.search) return new Int32Array(0); + // Matches are read where they are, before anything changes the terminal; the + // buffer is grown to the count Ghostty reports, then read again. + for (let attempt = 0; attempt < 2; attempt++) { + const dv = this.dv(); + dv.setUint32(at.searchBuf + S.ptr, this.matchBuf, true); + dv.setUint32(at.searchBuf + S.cap, this.matchCap, true); + dv.setUint32(at.searchBuf + S.len, 0, true); + for (let i = 0; i < this.matchCap; i++) { + dv.setUint32(this.matchBuf + i * Sel.size + Sel.sizeField, Sel.size, true); + } + const rc = ex.ghostty_search_get(this.search, S.MATCHES, at.searchBuf); + const len = this.dv().getUint32(at.searchBuf + S.len, true); + if (rc === this.L.result.OUT_OF_SPACE && attempt === 0) { + if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * Sel.size); + // Room for the matches output adds before the next read + this.matchCap = len + (len >> 2) + 16; + this.matchBuf = ex.ghostty_wasm_alloc(this.matchCap * Sel.size); + if (!this.matchBuf) { + this.matchCap = 0; + return new Int32Array(0); + } + continue; + } + if (rc !== 0) return new Int32Array(0); + const out = new Int32Array(len * 4); + for (let i = 0; i < len; i++) { + const sel = this.matchBuf + i * Sel.size; + const start = this.screenCellOf(sel + Sel.start); + const end = this.screenCellOf(sel + Sel.end); + // A match that has no screen point (none expected) is left at row -1 + out[i * 4] = start?.col ?? 0; + out[i * 4 + 1] = start?.row ?? -1; + out[i * 4 + 2] = end?.col ?? 0; + out[i * 4 + 3] = end?.row ?? -1; + } + return out; + } + return new Int32Array(0); + } + // ========================================================================== // Mouse reports (ADR 049) // ========================================================================== diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index f267595..d6ff150 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -1165,3 +1165,108 @@ describe('official engine: selection (ADR 050)', () => { t.free(); }); }); + +describe('official engine: search (ADR 055)', () => { + /** Runs the search to completion and returns its matches as [col, row, col, row] tuples. */ + function run(t: OfficialTerminal): number[][] { + while (t.searchStep(50)) {} + const m = t.searchMatches(); + return Array.from({ length: m.length / 4 }, (_, i) => Array.from(m.slice(i * 4, i * 4 + 4))); + } + + test('finds ASCII letters case-insensitively, newest first, scrollback included', () => { + const t = term(20, 3); + t.write('Error one\r\nok\r\nerror two\r\nok\r\nERROR three'); + t.setSearch('error'); + expect(run(t)).toEqual([ + [0, 4, 4, 4], + [0, 2, 4, 2], + [0, 0, 4, 0], + ]); + expect(t.getScrollbackLength()).toBe(2); + expect(t.searchState()).toEqual({ total: 3, selected: null }); + t.free(); + }); + + test('a match spans a soft wrap', () => { + const t = term(5, 3); + t.write('abcdefg'); + t.setSearch('def'); + expect(run(t)).toEqual([[3, 0, 0, 1]]); + t.free(); + }); + + test('next moves toward older output and wraps; previous moves back', () => { + const t = term(10, 4); + t.write('a x\r\nb x\r\nc x'); + t.setSearch('x'); + run(t); + const row = () => t.searchSelectedMatch()?.start.row; + expect(t.searchSelectedMatch()).toBeNull(); + expect(t.searchSelect('next')).toBe(true); + expect([t.searchState().selected, row()]).toEqual([0, 2]); + t.searchSelect('next'); + expect([t.searchState().selected, row()]).toEqual([1, 1]); + t.searchSelect('next'); + t.searchSelect('next'); + expect([t.searchState().selected, row()]).toEqual([0, 2]); + t.searchSelect('previous'); + expect([t.searchState().selected, row()]).toEqual([2, 0]); + expect(t.searchSelectedMatch()).toEqual({ start: { col: 2, row: 0 }, end: { col: 2, row: 0 } }); + t.free(); + }); + + test('picks up output after a step, and leaves the view alone', () => { + const t = term(10, 3); + t.write('x\r\n'); + t.setSearch('x'); + expect(run(t)).toHaveLength(1); + expect(t.searchStep(50)).toBe(false); + t.write('x\r\nx\r\nx\r\n'); + expect(run(t)).toHaveLength(4); + t.searchSelect('previous'); + expect(t.searchSelectedMatch()?.start.row).toBe(0); + // SCROLL_NONE: the top row of Ghostty's viewport is still the active screen's + expect(t.getLine(0)?.[0].codepoint).toBe('x'.codePointAt(0)); + t.free(); + }); + + test('a new needle restarts the search; an empty one ends it', () => { + const t = term(10, 3); + t.write('foo bar'); + t.setSearch('foo'); + expect(run(t)).toEqual([[0, 0, 2, 0]]); + t.setSearch('BAR'); + expect(run(t)).toEqual([[4, 0, 6, 0]]); + t.setSearch(''); + expect(t.searchStep(50)).toBe(false); + expect(t.searchMatches()).toHaveLength(0); + expect(t.searchState()).toEqual({ total: 0, selected: null }); + expect(t.searchSelect('next')).toBe(false); + t.free(); + }); + + test('matches pruned from the scrollback go away', () => { + const t = term(10, 2, { scrollbackLines: 1000 }); + t.write('x0\r\n'); + t.setSearch('x'); + expect(run(t)).toHaveLength(1); + t.write('y\r\n'.repeat(5000)); + expect(run(t)).toHaveLength(0); + t.free(); + }); + + test('reads more matches than its buffer held', () => { + const t = term(40, 5, { scrollbackLines: 1000 }); + t.setSearch('ab'); + t.write('ab\r\n'); + expect(run(t)).toHaveLength(1); + t.write('ab ab ab ab ab\r\n'.repeat(100)); + const all = run(t); + expect(all).toHaveLength(501); + expect(t.searchState().total).toBe(501); + // Newest to oldest: rows never increase + for (let i = 1; i < all.length; i++) expect(all[i][1]).toBeLessThanOrEqual(all[i - 1][1]); + t.free(); + }); +}); From 97633ba4febfa0fd43d6104b2136460263820970 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:45:24 -0400 Subject: [PATCH 112/295] feat(client): highlight find matches in the renderer in Ghostty's search colours --- src/client/vendor/ghostty-web/interfaces.ts | 5 + src/client/vendor/ghostty-web/renderer.ts | 63 +++++++++ src/client/vendor/ghostty-web/search.ts | 139 ++++++++++++++++++++ src/client/vendor/renderer.test.ts | 57 ++++++++ src/client/vendor/search.test.ts | 125 ++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 src/client/vendor/ghostty-web/search.ts create mode 100644 src/client/vendor/search.test.ts diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 409f88f..37d93c0 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -42,6 +42,11 @@ export interface ITheme { cursorAccent?: string; selectionBackground?: string; selectionForeground?: string; + // Search matches and the selected match, Ghostty's `search-*` colours (webtty, ADR 055) + searchBackground?: string; + searchForeground?: string; + searchSelectedBackground?: string; + searchSelectedForeground?: string; // ANSI colors (0-15) black?: string; diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 60a4647..4d8c31d 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -11,6 +11,13 @@ */ import type { ITheme } from './interfaces'; +import { + type HighlightSpan, + highlightAt, + highlightRows, + type SearchHighlights, + spansKey, +} from './search'; import type { SelectionManager } from './selection-manager'; import type { GhosttyCell, ILink } from './types'; import { CellFlags } from './types'; @@ -210,6 +217,12 @@ export const DEFAULT_THEME: Required<ITheme> = { // Using Ghostty's approach: selection bg = default fg, selection fg = default bg selectionBackground: '#d4d4d4', selectionForeground: '#1e1e1e', + // Ghostty's defaults: black on golden yellow, and on soft peach for the selected match + // (webtty, ADR 055) + searchBackground: '#ffe082', + searchForeground: '#000000', + searchSelectedBackground: '#f2a57e', + searchSelectedForeground: '#000000', black: '#000000', red: '#cd3131', green: '#0dbc79', @@ -319,6 +332,12 @@ export class CanvasRenderer { private currentLinkRange: LinkRange | null = null; private drawnLinkRange: LinkRange | null = null; + // Search matches (webtty, ADR 055): the matches, the spans this frame draws per canvas + // row, and the spans each canvas row was drawn with (as `spansKey`) + private searchHighlights: SearchHighlights | null = null; + private searchSpans: (HighlightSpan[] | undefined)[] = []; + private drawnSearch: string[] = []; + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.element = canvas; // Positioned with no offsets, the drawing canvas sits where the element's box starts @@ -582,6 +601,11 @@ export class CanvasRenderer { if (this.scrollbarRow !== null) this.scrollbarRow += shift; this.drawnSelection = moveRows(this.drawnSelection, shift); this.drawnLinkRange = moveRange(this.drawnLinkRange, shift); + const drawnSearch = new Array<string>(total).fill(''); + for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { + drawnSearch[k + shift] = this.drawnSearch[k] ?? ''; + } + this.drawnSearch = drawnSearch; } else { forceAll = true; } @@ -626,6 +650,20 @@ export class CanvasRenderer { } this.drawnSelection = this.currentSelectionCoords; + // Search matches, in canvas rows; a row whose matches changed is repainted + // (webtty, ADR 055) + if (this.drawnSearch.length !== total) this.drawnSearch = new Array<string>(total).fill(''); + this.searchSpans = new Array(total); + if (this.searchHighlights) { + // Canvas row k shows screen row k - top plus the screen row at the viewport's top + const toScreen = scrollbackLength - view - top; + const spans = highlightRows(this.searchHighlights, toScreen, toScreen + total - 1, dims.cols); + for (const [row, s] of spans) this.searchSpans[row - toScreen] = s; + } + for (let k = 0; k < total; k++) { + if (spansKey(this.searchSpans[k]) !== this.drawnSearch[k]) changed(k); + } + const rowAt = (k: number) => this.rowAt(k - this.overscan, band, rows, getLine, buffer, scrollbackProvider, scrollbackLength); @@ -679,6 +717,9 @@ export class CanvasRenderer { repainted.fill(1, k, end + 1); k = end; } + for (let k = 0; k < total; k++) { + if (repainted[k]) this.drawnSearch[k] = spansKey(this.searchSpans[k]); + } // Selection highlighting is now integrated into renderCellBackground/renderCellText // No separate overlay pass needed - this fixes z-order issues with complex glyphs @@ -983,6 +1024,15 @@ export class CanvasRenderer { return; // Selection background replaces cell background } + // A search match replaces it too, under the selection as in Ghostty (webtty, ADR 055) + const match = highlightAt(this.searchSpans[y], x); + if (match) { + this.ctx.fillStyle = + match === 2 ? this.theme.searchSelectedBackground : this.theme.searchBackground; + this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + return; + } + // Extract background color and handle inverse let bg_r = cell.bg_r, bg_g = cell.bg_g, @@ -1038,6 +1088,11 @@ export class CanvasRenderer { this.ctx.fillStyle = colorOverride; } else if (isSelected) { this.ctx.fillStyle = this.theme.selectionForeground; + } else if (highlightAt(this.searchSpans[y], x)) { + this.ctx.fillStyle = + highlightAt(this.searchSpans[y], x) === 2 + ? this.theme.searchSelectedForeground + : this.theme.searchForeground; } else { // Extract colors and handle inverse let fg_r = cell.fg_r, @@ -1388,6 +1443,14 @@ export class CanvasRenderer { return false; } + /** + * The search matches to highlight, or null for none. Rows whose matches change are + * repainted on the next frame. (webtty, ADR 055) + */ + public setSearchHighlights(highlights: SearchHighlights | null): void { + this.searchHighlights = highlights; + } + /** * Set the currently hovered link range for rendering underlines (OSC 8 or regex links) * Pass null to clear the hover state diff --git a/src/client/vendor/ghostty-web/search.ts b/src/client/vendor/ghostty-web/search.ts new file mode 100644 index 0000000..d54ddf9 --- /dev/null +++ b/src/client/vendor/ghostty-web/search.ts @@ -0,0 +1,139 @@ +/** + * Find (webtty, ADR 055): which cells of which rows the renderer paints in the search + * colours, and where the view goes for the selected match. + * + * Matches come from libghostty-vt newest to oldest (`GhosttyTerminal.searchMatches`), so + * their rows never increase along the list, and the matches over a range of rows are a + * run of it, found by binary search: a frame costs the same with ten matches or a + * hundred thousand. + */ +import type { ScreenRange } from './ghostty'; + +/** Matches as libghostty-vt's search found them, and the selected one. */ +export interface SearchHighlights { + /** Start col, start row, end col, end row per match, in screen rows, newest first. */ + matches: Int32Array; + selected: ScreenRange | null; +} + +/** Highlighted columns of one row, both ends inclusive. */ +export interface HighlightSpan { + start: number; + end: number; + selected: boolean; +} + +/** The first index in `[0, n)` for which `past(i)` holds; `past` must be monotone. */ +function firstWhere(n: number, past: (i: number) => boolean): number { + let lo = 0; + let hi = n; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (past(mid)) hi = mid; + else lo = mid + 1; + } + return lo; +} + +/** The run `[from, to)` of matches that touch screen rows `first` to `last`. */ +export function matchesInRows( + matches: Int32Array, + first: number, + last: number +): { from: number; to: number } { + const n = matches.length >> 2; + // Start rows fall along the list: the matches starting at or above `last` are a tail + const from = firstWhere(n, (i) => matches[i * 4 + 1] <= last); + // and of those, the ones ending at or below `first` a head + const to = from + firstWhere(n - from, (i) => matches[(from + i) * 4 + 3] < first); + return { from, to }; +} + +/** + * The highlighted spans of screen rows `first` to `last`, by row. A match over a soft + * wrap runs to the end of its first row and from the start of its last. + */ +export function highlightRows( + highlights: SearchHighlights, + first: number, + last: number, + cols: number +): Map<number, HighlightSpan[]> { + const { matches, selected } = highlights; + const rows = new Map<number, HighlightSpan[]>(); + const { from, to } = matchesInRows(matches, first, last); + for (let i = from; i < to; i++) { + const [sc, sr, ec, er] = matches.subarray(i * 4, i * 4 + 4); + const isSelected = + !!selected && + selected.start.col === sc && + selected.start.row === sr && + selected.end.col === ec && + selected.end.row === er; + for (let row = Math.max(sr, first); row <= Math.min(er, last); row++) { + const span = { + start: row === sr ? sc : 0, + end: row === er ? ec : cols - 1, + selected: isSelected, + }; + const spans = rows.get(row); + if (spans) spans.push(span); + else rows.set(row, [span]); + } + } + return rows; +} + +/** A row's spans as a string, to tell whether a row was drawn with the same ones. */ +export function spansKey(spans: HighlightSpan[] | undefined): string { + if (!spans) return ''; + return spans.map((s) => `${s.start}-${s.end}${s.selected ? '*' : ''}`).join(','); +} + +/** 2 for a cell of the selected match, 1 for another match's, 0 for none. */ +export function highlightAt(spans: HighlightSpan[] | undefined, col: number): 0 | 1 | 2 { + if (!spans) return 0; + for (const s of spans) { + if (col >= s.start && col <= s.end) return s.selected ? 2 : 1; + } + return 0; +} + +/** + * The `viewportY` that shows `match` as Ghostty's search does: none (null) when any of + * its rows is in view, otherwise the one with its first row at the top, clamped to the + * scrollback. + */ +export function viewportYToShow( + match: ScreenRange, + scrollback: number, + viewportY: number, + rows: number +): number | null { + const top = scrollback - Math.floor(viewportY); + if (match.end.row >= top && match.start.row < top + rows) return null; + return Math.max(0, Math.min(scrollback, scrollback - match.start.row)); +} + +/** Where the selected match was when the view last followed it. */ +export interface FindAnchor { + row: number; + scrollback: number; +} + +/** + * The `viewportY` that keeps a scrolled view on its rows after output: it moves by the + * lines that scrolled into the scrollback, what it grew by plus what was pruned from its + * top (the rows the anchor's row moved up by). + */ +export function keptViewportY( + viewportY: number, + anchor: FindAnchor, + row: number, + scrollback: number +): number { + return Math.max( + 0, + Math.min(scrollback, viewportY + (scrollback - anchor.scrollback) + (anchor.row - row)) + ); +} diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/renderer.test.ts index 8d68ae5..7924848 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/renderer.test.ts @@ -923,6 +923,63 @@ describe('overscan and copied scrolls (ADR 043)', () => { term.free(); }); + test(`search highlights follow scrolls, output and selection changes (ADR 055)`, async () => { + const { term, provider } = await terminal(); + const sb = () => term.getScrollbackLength(); + const live = { provider: { ...provider, getScrollbackLength: sb } }; + term.setSearch('g'); + while (term.searchStep(50)) {} + const read = () => ({ matches: term.searchMatches(), selected: term.searchSelectedMatch() }); + let highlights = read(); + expect(highlights.matches.length).toBeGreaterThan(40); + for (const overscan of [1, 4]) { + const r = renderer(2, { overscan }); + r.r.setSearchHighlights(highlights); + r.r.render(term, true, 0, live.provider, 0); + const steps: [number, number, (() => void)?][] = [ + [0, 0], + [3, 0, () => term.searchSelect('next')], + [3, 7.5], + [9, 0, () => term.searchSelect('next')], + [12, 0], + [6, 2, () => term.searchSelect('previous')], + [0, 0, () => term.write('one more g\r\n')], + [2, 0], + [2, 0, () => term.setSearch('plain')], + [0, 0, () => term.setSearch('')], + ]; + for (const [view, offset, change] of steps) { + if (change) { + change(); + while (term.searchStep(50)) {} + highlights = read(); + r.r.setSearchHighlights(highlights.matches.length ? highlights : null); + } + r.r.setScrollOffset(offset); + r.r.render(term, false, view, live.provider, 0); + const full = renderer(2, { overscan }); + full.r.setSearchHighlights(highlights.matches.length ? highlights : null); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, live.provider, 0); + expect(r.shown()).toEqual(full.shown()); + } + term.setSearch('g'); + while (term.searchStep(50)) {} + highlights = read(); + } + // The matches' text in the search colours, the selected one's in its own + const r = renderer(1, { + theme: { searchForeground: '#111111', searchSelectedForeground: '#222222' }, + }); + term.searchSelect('next'); + r.r.setSearchHighlights(read()); + r.r.render(term, true, 0, live.provider, 0); + const glyphs = (color: string) => r.shown().filter((v) => v.startsWith(`${color} `)); + expect(glyphs('#111111').length).toBeGreaterThan(0); + expect(glyphs('#222222').length).toBeGreaterThan(0); + term.free(); + }); + test(`a translucent cursor drawn again is not blended over itself`, async () => { const { term, provider } = await terminal(); const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; diff --git a/src/client/vendor/search.test.ts b/src/client/vendor/search.test.ts new file mode 100644 index 0000000..51a5ea5 --- /dev/null +++ b/src/client/vendor/search.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test'; +import { + highlightAt, + highlightRows, + keptViewportY, + matchesInRows, + spansKey, + viewportYToShow, +} from './ghostty-web/search'; + +// Find (ADR 055): highlights from libghostty-vt's newest-to-oldest matches, and the view. + +/** Matches as [start col, start row, end col, end row], newest first. */ +const list = (...m: number[][]) => Int32Array.from(m.flat()); + +describe('matchesInRows', () => { + // Rows 9, 7-8 (over a wrap), 7, 4 and 0 + const matches = list([2, 9, 4, 9], [7, 8, 1, 9], [0, 7, 2, 7], [3, 4, 5, 4], [0, 0, 1, 0]); + + test('finds the run of matches touching the rows', () => { + expect(matchesInRows(matches, 4, 7)).toEqual({ from: 2, to: 4 }); + expect(matchesInRows(matches, 9, 9)).toEqual({ from: 0, to: 2 }); + expect(matchesInRows(matches, 0, 100)).toEqual({ from: 0, to: 5 }); + }); + + test('a match over a wrap touches both its rows', () => { + expect(matchesInRows(matches, 8, 8)).toEqual({ from: 1, to: 2 }); + }); + + test('is empty between, above and below the matches', () => { + for (const [first, last] of [ + [5, 6], + [10, 20], + [-5, -1], + ]) { + const { from, to } = matchesInRows(matches, first, last); + expect(to).toBe(from); + } + expect(matchesInRows(list(), 0, 10)).toEqual({ from: 0, to: 0 }); + }); + + test('takes a hundred thousand matches in a few steps', () => { + const many = new Int32Array(100_000 * 4); + for (let i = 0; i < 100_000; i++) many.set([0, 99_999 - i, 0, 99_999 - i], i * 4); + expect(matchesInRows(many, 500, 509)).toEqual({ from: 99_490, to: 99_500 }); + }); +}); + +describe('highlightRows', () => { + test('gives each row its spans, a wrapped match to the end and from the start', () => { + const matches = list([7, 8, 1, 9], [0, 7, 2, 7], [5, 7, 6, 7]); + const rows = highlightRows( + { matches, selected: { start: { col: 0, row: 7 }, end: { col: 2, row: 7 } } }, + 7, + 9, + 10, + ); + expect(rows.get(9)).toEqual([{ start: 0, end: 1, selected: false }]); + expect(rows.get(8)).toEqual([{ start: 7, end: 9, selected: false }]); + expect(rows.get(7)).toEqual([ + { start: 0, end: 2, selected: true }, + { start: 5, end: 6, selected: false }, + ]); + }); + + test('leaves out rows outside the range', () => { + const rows = highlightRows({ matches: list([7, 8, 1, 9]), selected: null }, 9, 12, 10); + expect([...rows.keys()]).toEqual([9]); + }); +}); + +describe('highlightAt and spansKey', () => { + const spans = [ + { start: 0, end: 2, selected: true }, + { start: 5, end: 6, selected: false }, + ]; + + test('tells a selected match from another and from none', () => { + expect([0, 2, 3, 5, 6, 7].map((c) => highlightAt(spans, c))).toEqual([2, 2, 0, 1, 1, 0]); + expect(highlightAt(undefined, 0)).toBe(0); + }); + + test('keys a row by its spans', () => { + expect(spansKey(spans)).toBe('0-2*,5-6'); + expect(spansKey(undefined)).toBe(''); + expect(spansKey([{ ...spans[0], selected: false }])).not.toBe(spansKey([spans[0]])); + }); +}); + +describe('viewportYToShow', () => { + const at = (start: number, end = start) => ({ + start: { col: 0, row: start }, + end: { col: 0, row: end }, + }); + // 100 scrollback lines, 10 rows: at the bottom the view shows rows 100-109 + + test('does not move when any row of the match is in view', () => { + expect(viewportYToShow(at(105), 100, 0, 10)).toBeNull(); + expect(viewportYToShow(at(99, 100), 100, 0, 10)).toBeNull(); + expect(viewportYToShow(at(60), 100, 45, 10)).toBeNull(); + }); + + test("puts the match's first row at the top, as Ghostty does", () => { + expect(viewportYToShow(at(40, 41), 100, 0, 10)).toBe(60); + expect(viewportYToShow(at(0), 100, 0, 10)).toBe(100); + }); + + test('a match on the screen below the view scrolls down to it, clamped to the bottom', () => { + expect(viewportYToShow(at(108), 100, 30, 10)).toBe(0); + }); +}); + +describe('keptViewportY', () => { + test('moves with lines that scrolled into the scrollback', () => { + expect(keptViewportY(20, { row: 50, scrollback: 100 }, 50, 103)).toBe(23); + }); + + test('moves with lines pruned from the top of a full scrollback', () => { + expect(keptViewportY(20, { row: 50, scrollback: 100 }, 47, 100)).toBe(23); + }); + + test('stays within the scrollback', () => { + expect(keptViewportY(99, { row: 5, scrollback: 100 }, 0, 100)).toBe(100); + }); +}); From 2de968b82695a7ed00a7294b37289a24a49ec7d5 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:45:43 -0400 Subject: [PATCH 113/295] feat(client): find in the terminal, scrolling to the selected match and keeping the view on it during output --- src/client/vendor/ghostty-web/terminal.ts | 116 ++++++++++++++++- src/client/vendor/terminal.test.ts | 145 ++++++++++++++++++++++ 2 files changed, 259 insertions(+), 2 deletions(-) diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index e42c600..2fff136 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -22,6 +22,7 @@ import type { GhosttyTerminal, GhosttyTerminalConfig, MouseGeometry, + SearchState, } from './ghostty'; import { AT_ONCE, type Pace } from './glide'; import { @@ -43,6 +44,7 @@ import { MenuSource, rightClickAction } from './mouse-route'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; +import { type FindAnchor, keptViewportY, viewportYToShow } from './search'; import { SelectionManager } from './selection-manager'; import type { ILink } from './types'; import { @@ -119,6 +121,9 @@ const MOUSE_REPORT_DECODER = new TextDecoder('utf-8', { fatal: true }); /** DEC private mode 1004: the app hears `CSI I` / `CSI O` on focus changes. (webtty, ADR 053) */ const FOCUS_EVENT_MODE = 1004; +/** The time the search may take in a frame (webtty, ADR 055) */ +const FIND_STEP_MS = 4; + /** * A mouse report as text for the PTY, or '' when it is not valid UTF-8. Input travels as * text frames that the server writes as UTF-8, so a report with raw bytes above 0x7f @@ -211,6 +216,7 @@ export class Terminal implements ITerminalCore { private scrollEmitter = new EventEmitter<number>(); private pasteRequestEmitter = new EventEmitter<void>(); private clipboardWriteEmitter = new EventEmitter<string>(); + private findChangeEmitter = new EventEmitter<SearchState>(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent<string> = this.dataEmitter.event; public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; @@ -228,6 +234,8 @@ export class Terminal implements ITerminalCore { * embedder writes the browser clipboard. (webtty, ADR 052) */ public readonly onClipboardWrite: IEvent<string> = this.clipboardWriteEmitter.event; + /** The match count or the selected match changed (webtty, ADR 055) */ + public readonly onFindChange: IEvent<SearchState> = this.findChangeEmitter.event; // Lifecycle state private isOpen = false; @@ -250,6 +258,15 @@ export class Terminal implements ITerminalCore { // A clearScreen waiting for the output to reach ground (webtty, ADR 054) private pendingClear = false; + // Find (webtty, ADR 055): the needle ('' with no search open), whether the matches need + // reading again and when they may be, where the selected match was when the view last + // followed it, and the count last reported + private findNeedle = ''; + private findStale = false; + private findReadAt = 0; + private findAnchor: FindAnchor | null = null; + private findState: SearchState = { total: 0, selected: null }; + // Phase 2: Viewport and scrolling state public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) private customWheelEventHandler?: (event: WheelEvent) => boolean; @@ -676,6 +693,96 @@ export class Terminal implements ITerminalCore { this.processTerminalResponses(); } + /** + * Finds `needle` in the scrollback and on screen with libghostty-vt's search, as + * Ghostty's `search` action does: every match is highlighted, none selected. The + * search runs in the render loop, a few milliseconds a frame. `''` ends it. + * (webtty, ADR 055) + */ + find(needle: string): void { + this.findNeedle = needle; + this.wasmTerm?.setSearch(needle); + this.findAnchor = null; + this.findStale = !!needle; + this.findReadAt = 0; + this.renderer?.setSearchHighlights(null); + this.reportFind(); + } + + /** Ends the search and clears its highlights (Ghostty's `end_search`). */ + endFind(): void { + this.find(''); + } + + /** Whether a search is open. */ + get finding(): boolean { + return this.findNeedle !== ''; + } + + /** + * Selects the next match, toward older output (Ghostty's `navigate_search:next`), and + * scrolls to it if none of its rows is in view. False with no search open. + */ + findNext(): boolean { + return this.selectMatch('next'); + } + + /** Selects the previous match, toward newer output. False with no search open. */ + findPrevious(): boolean { + return this.selectMatch('previous'); + } + + private selectMatch(direction: 'next' | 'previous'): boolean { + if (!this.findNeedle || !this.wasmTerm) return false; + this.wasmTerm.searchSelect(direction); + const match = this.wasmTerm.searchSelectedMatch(); + if (match) { + const y = viewportYToShow(match, this.getScrollbackLength(), this.viewportY, this.rows); + if (y !== null) this.scrollToLine(y); + } + this.findAnchor = match && { row: match.start.row, scrollback: this.getScrollbackLength() }; + this.findStale = true; + this.findReadAt = 0; + this.reportFind(); + return true; + } + + /** + * Runs the search for a few milliseconds and, when its results changed, gives the + * renderer the matches. Reading them takes a few milliseconds with tens of thousands, + * so while output keeps changing them they are read at most every ten times the last + * read took. A scrolled view on a selected match is kept on its rows. + */ + private stepFind(now: number): void { + const wasmTerm = this.wasmTerm; + if (!this.findNeedle || !wasmTerm) return; + if (wasmTerm.searchStep(FIND_STEP_MS)) { + this.findStale = true; + this.reportFind(); + } + if (!this.findStale || now < this.findReadAt) return; + const started = performance.now(); + const selected = wasmTerm.searchSelectedMatch(); + this.renderer?.setSearchHighlights({ matches: wasmTerm.searchMatches(), selected }); + this.findStale = false; + this.findReadAt = now + (performance.now() - started) * 10; + const scrollback = this.getScrollbackLength(); + if (selected && this.findAnchor && this.viewportY > 0) { + this.scrollToLine(keptViewportY(this.viewportY, this.findAnchor, selected.start.row, scrollback)); + } + this.findAnchor = selected && { row: selected.start.row, scrollback }; + } + + /** Fires onFindChange if the count or the selected match changed. */ + private reportFind(): void { + const state = this.wasmTerm?.searchState() ?? { total: 0, selected: null }; + if (state.total === this.findState.total && state.selected === this.findState.selected) { + return; + } + this.findState = state; + this.findChangeEmitter.fire(state); + } + /** * Ghostty's `clear_screen` (Cmd+K): clears the selection and the scrollback, then at a * shell prompt the screen, sending FF so the shell redraws its prompt, and elsewhere the @@ -763,8 +870,9 @@ export class Terminal implements ITerminalCore { // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); - // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) - if (this.viewportY !== 0) { + // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior), except while the + // view is on a selected match: stepFind keeps it on its rows (webtty, ADR 055) + if (this.viewportY !== 0 && !this.findAnchor) { this.scrollToBottom(); } @@ -885,6 +993,8 @@ export class Terminal implements ITerminalCore { this.focusReporting = false; // A clear waiting for the old engine's output is not for the new one (webtty, ADR 054) this.pendingClear = false; + // A search goes on in the new engine (webtty, ADR 055) + if (this.findNeedle) this.find(this.findNeedle); } /** @@ -1092,6 +1202,7 @@ export class Terminal implements ITerminalCore { this.scrollEmitter.dispose(); this.pasteRequestEmitter.dispose(); this.clipboardWriteEmitter.dispose(); + this.findChangeEmitter.dispose(); } // ========================================================================== @@ -1209,6 +1320,7 @@ export class Terminal implements ITerminalCore { const loop = (now?: number) => { if (!this.isDisposed && this.isOpen) { this.beforeRender?.(now ?? performance.now()); + this.stepFind(now ?? performance.now()); // Render using WASM's native dirty tracking // The render() method: diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index b154766..1c4d2d4 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -442,6 +442,151 @@ describe('Terminal clearScreen', () => { }); }); +describe('Terminal find', () => { + // Find with libghostty-vt's search, run in the render loop (ADR 055). + async function findTerm() { + const ghostty = await Ghostty.load(WASM); + const term = new Terminal({ ghostty, cols: 20, rows: 4, scrollbackLines: 1000 }); + const highlights: unknown[] = []; + const renderer = { + clear: () => {}, + getMetrics: () => ({ width: 8, height: 16, baseline: 12 }), + devicePixelRatio: 1, + setSearchHighlights: (h: unknown) => highlights.push(h), + }; + const internals = term as unknown as { + isOpen: boolean; + wasmTerm: ReturnType<typeof ghostty.createTerminal>; + ghostty: typeof ghostty; + renderer: typeof renderer; + showScrollbar: () => void; + stepFind: (now: number) => void; + }; + internals.wasmTerm = ghostty.createTerminal(20, 4, { scrollbackLines: 1000 }); + internals.ghostty = ghostty; + internals.renderer = renderer; + internals.isOpen = true; + internals.showScrollbar = () => {}; + let now = 0; + /** Render-loop frames: the search steps before each. */ + const frames = (n = 1) => { + for (let i = 0; i < n; i++) { + now += 1000; + internals.stepFind(now); + } + }; + const states: unknown[] = []; + term.onFindChange((s) => states.push(s)); + const last = () => + highlights[highlights.length - 1] as { + matches: Int32Array; + selected: unknown; + } | null; + return { term, internals, frames, states, last }; + } + + test('finds in the render loop, reports the count, and highlights every match', async () => { + const { term, internals, frames, states, last } = await findTerm(); + for (let i = 0; i < 20; i++) term.write(i % 5 === 0 ? `Error ${i}\r\n` : `ok ${i}\r\n`); + expect(term.finding).toBe(false); + term.find('error'); + expect(term.finding).toBe(true); + frames(3); + expect(states[states.length - 1]).toEqual({ total: 4, selected: null }); + expect(last()?.matches.length).toBe(16); + expect(last()?.selected).toBeNull(); + internals.wasmTerm.free(); + }); + + test('next scrolls to a match out of view, its first row at the top, and wraps', async () => { + const { term, internals, frames, states } = await findTerm(); + term.write('needle\r\n'); + for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + term.write('needle'); + term.find('needle'); + frames(2); + const sb = term.getScrollbackLength(); + // The newest match is on screen: no scroll + expect(term.findNext()).toBe(true); + expect(term.viewportY).toBe(0); + expect(states[states.length - 1]).toEqual({ total: 2, selected: 0 }); + // The oldest is the first scrollback row + term.findNext(); + expect(term.viewportY).toBe(sb); + expect(states[states.length - 1]).toEqual({ total: 2, selected: 1 }); + term.findPrevious(); + expect(term.viewportY).toBe(0); + internals.wasmTerm.free(); + }); + + test('with no search open, next and previous are not performed', async () => { + const { term, internals } = await findTerm(); + expect(term.findNext()).toBe(false); + expect(term.findPrevious()).toBe(false); + internals.wasmTerm.free(); + }); + + test('output keeps a scrolled view on its rows while a match is selected', async () => { + const { term, internals, frames } = await findTerm(); + term.write('needle\r\n'); + for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + term.find('needle'); + frames(2); + term.findNext(); + const view = term.viewportY; + expect(view).toBeGreaterThan(0); + term.write('more\r\nmore\r\nmore\r\n'); + expect(term.viewportY).toBe(view); + frames(); + expect(term.viewportY).toBe(view + 3); + // The view still starts at the match + expect(term.getScrollbackLength() - term.viewportY).toBe(0); + internals.wasmTerm.free(); + }); + + test('without a selected match, output scrolls to the bottom as before', async () => { + const { term, internals, frames } = await findTerm(); + for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + term.find('line'); + frames(2); + term.scrollToLine(5); + term.write('x\r\n'); + expect(term.viewportY).toBe(0); + internals.wasmTerm.free(); + }); + + test('ending the search clears the highlights and the count', async () => { + const { term, internals, frames, states, last } = await findTerm(); + term.write('abc abc'); + term.find('abc'); + frames(); + term.findNext(); + frames(); + term.endFind(); + expect(term.finding).toBe(false); + expect(last()).toBeNull(); + expect(states[states.length - 1]).toEqual({ total: 0, selected: null }); + frames(); + expect(last()).toBeNull(); + internals.wasmTerm.free(); + }); + + test('a reset (reconnect) searches the new engine', async () => { + const { term, internals, frames, states } = await findTerm(); + term.write('abc'); + term.find('abc'); + frames(); + expect(states[states.length - 1]).toEqual({ total: 1, selected: null }); + term.reset(); + frames(); + expect(states[states.length - 1]).toEqual({ total: 0, selected: null }); + term.write('abc abc'); + frames(); + expect(states[states.length - 1]).toEqual({ total: 2, selected: null }); + internals.wasmTerm.free(); + }); +}); + describe('Terminal bell and title', () => { // Bell and title come from the engine's readEffects, not from scanning the output // (ADR 047). From 9150490fd8d2c72c43666ed7502f7c9a3f6a859e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:46:07 -0400 Subject: [PATCH 114/295] feat(config): add the searchBackground, searchForeground and searchSelected* theme keys --- docs/specs/config.md | 4 ++++ src/config.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/specs/config.md b/docs/specs/config.md index d8ac925..df94d85 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -143,6 +143,10 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | `cursor` | `#FFFFFF` | Cursor color | | `selection` | `#FFFFFF` | Selection highlight (background) | | `selectionForeground` | *(same as `background` when `selection` is set)* | Text colour inside the selection | +| `searchBackground` | `#FFE082` | Background of find matches ([ADR 055](../adrs/055.client.find-in-scrollback.md); Ghostty's `search-background`) | +| `searchForeground` | `#000000` | Text colour of find matches | +| `searchSelectedBackground` | `#F2A57E` | Background of the selected find match | +| `searchSelectedForeground` | `#000000` | Text colour of the selected find match | | `padding` | *(same as `background`)* | Colour of the padding set by the top-level `padding` setting. A colour only. The few pixels left when the canvas is fitted to whole cells are not coloured by this: the client always extends the edge cells' own background into them (like Ghostty's `window-padding-color = extend`). Set it to a full-screen app's edge colour (for example a sidebar) to hide the strip. A webtty setting, not a terminal colour, so it is not sent to the terminal. | | `black` | `#0C0C0C` | ANSI 0 | | `red` | `#C50F1F` | ANSI 1 | diff --git a/src/config.ts b/src/config.ts index d064a14..aec411d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,14 @@ export interface Theme { selection?: string; /** Text colour inside the selection. Defaults to `background`. */ selectionForeground?: string; + /** Background of find matches (ADR 055). Defaults to Ghostty's `#FFE082`. */ + searchBackground?: string; + /** Text colour of find matches. Defaults to `#000000`. */ + searchForeground?: string; + /** Background of the selected find match. Defaults to Ghostty's `#F2A57E`. */ + searchSelectedBackground?: string; + /** Text colour of the selected find match. Defaults to `#000000`. */ + searchSelectedForeground?: string; /** Colour of the padding set by the `padding` setting. Defaults to `background`. The sub-cell leftover from fitting is not coloured by this: the client extends the edge cells' own background into it. */ padding?: string; /** ANSI 0. */ From edb28b38742a683360b22088df9edd5b6d904428 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:46:23 -0400 Subject: [PATCH 115/295] feat(client): a find bar on Cmd+F / Ctrl+Shift+F, Enter and Cmd+G to move, Escape to close, as Ghostty binds them --- src/client/find-bar.test.ts | 60 ++++++++++ src/client/find-bar.ts | 211 +++++++++++++++++++++++++++++++++++ src/client/index.css | 74 ++++++++++++ src/client/index.ts | 28 +++++ src/client/shortcuts.test.ts | 34 ++++++ src/client/shortcuts.ts | 29 +++-- 6 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 src/client/find-bar.test.ts create mode 100644 src/client/find-bar.ts diff --git a/src/client/find-bar.test.ts b/src/client/find-bar.test.ts new file mode 100644 index 0000000..26cfa8b --- /dev/null +++ b/src/client/find-bar.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import { countDescription, countLabel, fieldKey } from './find-bar'; + +describe('countLabel', () => { + test("shows Ghostty's selected/total, 0 before a match is selected", () => { + expect(countLabel({ total: 0, selected: null })).toBe('0/0'); + expect(countLabel({ total: 12, selected: null })).toBe('0/12'); + expect(countLabel({ total: 12, selected: 0 })).toBe('1/12'); + expect(countLabel({ total: 12, selected: 11 })).toBe('12/12'); + }); +}); + +describe('countDescription', () => { + test('says the count in words for a screen reader', () => { + expect(countDescription({ total: 0, selected: null })).toBe('No matches'); + expect(countDescription({ total: 1, selected: null })).toBe('1 match'); + expect(countDescription({ total: 3, selected: null })).toBe('3 matches'); + expect(countDescription({ total: 3, selected: 1 })).toBe('Match 2 of 3'); + }); +}); + +describe('fieldKey', () => { + const key = (k: string, code: string, mods: string[] = []) => ({ + key: k, + code, + ctrlKey: mods.includes('ctrl'), + shiftKey: mods.includes('shift'), + altKey: mods.includes('alt'), + metaKey: mods.includes('meta'), + }); + + test('Enter goes to the next match (older), Shift+Enter to the previous', () => { + for (const mac of [false, true]) { + expect(fieldKey(key('Enter', 'Enter'), mac)).toBe('next'); + expect(fieldKey(key('Enter', 'NumpadEnter'), mac)).toBe('next'); + expect(fieldKey(key('Enter', 'Enter', ['shift']), mac)).toBe('previous'); + expect(fieldKey(key('Enter', 'Enter', ['ctrl']), mac)).toBeNull(); + } + }); + + test('Escape closes', () => { + expect(fieldKey(key('Escape', 'Escape'), false)).toBe('close'); + expect(fieldKey(key('Escape', 'Escape'), true)).toBe('close'); + }); + + test('the find shortcuts work in the field: Cmd+G, Cmd+Shift+F, and the open key selects', () => { + expect(fieldKey(key('g', 'KeyG', ['meta']), true)).toBe('next'); + expect(fieldKey(key('G', 'KeyG', ['meta', 'shift']), true)).toBe('previous'); + expect(fieldKey(key('F', 'KeyF', ['meta', 'shift']), true)).toBe('close'); + expect(fieldKey(key('f', 'KeyF', ['meta']), true)).toBe('select'); + expect(fieldKey(key('F', 'KeyF', ['ctrl', 'shift']), false)).toBe('select'); + }); + + test('other keys are the field’s: typing, copy, paste, clear', () => { + expect(fieldKey(key('a', 'KeyA'), false)).toBeNull(); + expect(fieldKey(key('v', 'KeyV', ['ctrl']), false)).toBeNull(); + expect(fieldKey(key('k', 'KeyK', ['meta']), true)).toBeNull(); + expect(fieldKey(key('C', 'KeyC', ['ctrl', 'shift']), false)).toBeNull(); + }); +}); diff --git a/src/client/find-bar.ts b/src/client/find-bar.ts new file mode 100644 index 0000000..f734d4c --- /dev/null +++ b/src/client/find-bar.ts @@ -0,0 +1,211 @@ +/** + * The find bar (ADR 055): Ghostty's search overlay as a small DOM element at the top + * right of the terminal. The canvas is invisible to the browser's own find, so this + * finds with libghostty-vt's search through the terminal. + * + * It sits outside the terminal's container, so keys typed in it never reach the + * terminal's key handling and focus in it is not the terminal's. Typing searches after + * a short pause, as GTK's search entry does; Enter and Shift+Enter go to the next and + * previous match, Escape closes. The count is Ghostty's `selected/total`: `0/n` until a + * match is selected. + */ +import { shortcutFor } from './shortcuts'; + +/** The match count and the selected match's index (0 the newest), as the terminal reports them. */ +export interface FindState { + total: number; + selected: number | null; +} + +export interface FindBarActions { + find(needle: string): void; + next(): void; + previous(): void; + /** The bar closed: end the search and give the terminal its focus back. */ + close(): void; +} + +/** GtkSearchEntry's default search delay. */ +export const FIND_DELAY_MS = 150; + +/** The count as Ghostty's search overlay shows it. */ +export function countLabel(state: FindState): string { + if (state.total === 0) return '0/0'; + return `${state.selected === null ? 0 : state.selected + 1}/${state.total}`; +} + +/** The count for a screen reader. */ +export function countDescription(state: FindState): string { + if (state.total === 0) return 'No matches'; + const matches = state.total === 1 ? '1 match' : `${state.total} matches`; + if (state.selected === null) return matches; + return `Match ${state.selected + 1} of ${state.total}`; +} + +/** What a key in the find field does: the field's own keys, then the find shortcuts. */ +export function fieldKey( + e: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>, + mac: boolean, +): 'next' | 'previous' | 'close' | 'select' | null { + if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { + return e.shiftKey ? 'previous' : 'next'; + } + switch (shortcutFor(e, mac)?.action) { + case 'findClose': + return 'close'; + case 'findNext': + return 'next'; + case 'findPrevious': + return 'previous'; + case 'find': + return 'select'; + } + return null; +} + +export class FindBar { + private readonly root: HTMLElement; + private readonly input: HTMLInputElement; + private readonly count: HTMLElement; + private readonly status: HTMLElement; + private timer: ReturnType<typeof setTimeout> | null = null; + /** The text the terminal searches for: the field's, once the delay has passed. */ + private searched = ''; + private shown = false; + + constructor( + parent: HTMLElement, + theme: Record<string, string | undefined>, + private readonly mac: boolean, + private readonly actions: FindBarActions, + ) { + const root = document.createElement('div'); + root.className = 'find-bar'; + root.setAttribute('role', 'search'); + root.hidden = true; + const style = root.style; + if (theme.background) style.setProperty('--find-bg', theme.background); + if (theme.foreground) style.setProperty('--find-fg', theme.foreground); + const border = theme.brightBlack ?? theme.foreground; + if (border) style.setProperty('--find-border', border); + + const label = document.createElement('label'); + label.className = 'find-bar-hidden'; + label.htmlFor = 'find-bar-input'; + label.textContent = 'Find in terminal'; + + const input = document.createElement('input'); + input.id = 'find-bar-input'; + input.type = 'text'; + input.placeholder = 'Find…'; + input.autocomplete = 'off'; + input.spellcheck = false; + input.addEventListener('input', () => this.schedule()); + input.addEventListener('keydown', (e) => this.onKey(e)); + + const count = document.createElement('span'); + count.className = 'find-bar-count'; + count.setAttribute('aria-hidden', 'true'); + const status = document.createElement('span'); + status.className = 'find-bar-hidden'; + status.setAttribute('role', 'status'); + + // ↑ is next (older output), as in Ghostty + const button = (text: string, title: string, run: () => void) => { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = text; + b.title = title; + b.setAttribute('aria-label', title); + b.addEventListener('click', run); + return b; + }; + root.append( + label, + input, + count, + status, + button('↑', 'Next match (older)', () => this.go('next')), + button('↓', 'Previous match (newer)', () => this.go('previous')), + button('×', 'Close', () => this.close()), + ); + parent.appendChild(root); + this.root = root; + this.input = input; + this.count = count; + this.status = status; + this.update({ total: 0, selected: null }); + } + + get isOpen(): boolean { + return this.shown; + } + + /** + * Shows the bar with the field focused and its text selected. Reopened, it searches + * the text it kept again. + */ + open(): void { + if (!this.shown) { + this.shown = true; + this.root.hidden = false; + this.searched = ''; + this.flush(); + } + this.input.focus(); + this.input.select(); + } + + /** Hides the bar and ends the search; the field keeps its text. */ + close(): void { + if (!this.shown) return; + this.cancel(); + this.shown = false; + this.root.hidden = true; + this.actions.close(); + } + + /** Goes to the next or previous match, searching the field's text first if pending. */ + go(direction: 'next' | 'previous'): void { + if (!this.shown) return; + this.flush(); + if (direction === 'next') this.actions.next(); + else this.actions.previous(); + } + + update(state: FindState): void { + this.count.textContent = countLabel(state); + const description = countDescription(state); + if (this.status.textContent !== description) this.status.textContent = description; + } + + private onKey(e: KeyboardEvent): void { + // Composing (IME): Enter commits the text, not a search step + if (e.isComposing) return; + const action = fieldKey(e, this.mac); + if (!action) return; + e.preventDefault(); + if (action === 'close') this.close(); + else if (action === 'select') this.input.select(); + else this.go(action); + } + + private schedule(): void { + this.cancel(); + this.timer = setTimeout(() => this.flush(), FIND_DELAY_MS); + } + + private cancel(): void { + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + } + + /** Searches the field's text now, if the terminal is not already searching it. */ + private flush(): void { + this.cancel(); + const text = this.input.value; + if (text === this.searched) return; + this.searched = text; + this.actions.find(text); + } +} diff --git a/src/client/index.css b/src/client/index.css index b12acfc..9298571 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -29,3 +29,77 @@ body { #terminal canvas { display: block; } + +/* The find bar (ADR 055), coloured from the theme */ +.find-bar { + position: fixed; + top: 8px; + right: 20px; + z-index: 10; + display: flex; + align-items: center; + gap: 4px; + max-width: calc(100vw - 28px); + padding: 4px; + border: 1px solid var(--find-border, #767676); + border-radius: 6px; + background: var(--find-bg, #000); + color: var(--find-fg, #ccc); + font-family: system-ui, sans-serif; + font-size: 13px; + box-shadow: 0 2px 8px rgb(0 0 0 / 40%); +} + +.find-bar[hidden] { + display: none; +} + +.find-bar input { + flex: 0 1 16em; + min-width: 0; + padding: 3px 6px; + border: 1px solid var(--find-border, #767676); + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + outline: none; +} + +.find-bar input:focus { + border-color: var(--find-fg, #ccc); +} + +.find-bar-count { + min-width: 4.5em; + text-align: right; + opacity: 0.7; + font-variant-numeric: tabular-nums; +} + +.find-bar button { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + font-size: 15px; + cursor: pointer; +} + +.find-bar button:hover, +.find-bar button:focus-visible { + background: color-mix(in srgb, currentColor 18%, transparent); + outline: none; +} + +.find-bar-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/src/client/index.ts b/src/client/index.ts index 5c405ac..f5eaef1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -2,6 +2,7 @@ import { OutputAck } from './ack'; import { ClipboardWriter } from './clipboard-write'; import { Composer } from './compose'; import { EdgeExtender } from './edge'; +import { FindBar } from './find-bar'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { nonTextPaste } from './paste'; @@ -25,6 +26,10 @@ interface Theme { cursor?: string; selection?: string; selectionForeground?: string; + searchBackground?: string; + searchForeground?: string; + searchSelectedBackground?: string; + searchSelectedForeground?: string; padding?: string; black?: string; red?: string; @@ -441,6 +446,16 @@ container.addEventListener( (e: KeyboardEvent) => { const shortcut = shortcutFor(e, mac); if (!shortcut || findBinding(e)) return; + if (shortcut.action.startsWith('find')) { + // Escape, closing and moving are the app's while the bar is closed (ADR 055) + if (shortcut.action !== 'find' && !findBar.isOpen) return; + e.preventDefault(); + e.stopImmediatePropagation(); + if (shortcut.action === 'find') findBar.open(); + else if (shortcut.action === 'findClose') findBar.close(); + else findBar.go(shortcut.action === 'findNext' ? 'next' : 'previous'); + return; + } if (shortcut.action === 'paste') { // Without the clipboard API the browser's own paste may still fire 'paste' if (pasteFromClipboard()) e.preventDefault(); @@ -456,6 +471,19 @@ container.addEventListener( { capture: true }, ); +// Find in the scrollback with libghostty-vt's search (ADR 055). The bar is outside the +// container: its keys are its own. Closing it ends the search and refocuses the terminal. +const findBar = new FindBar(document.body, terminalTheme, mac, { + find: (needle) => term.find(needle), + next: () => term.findNext(), + previous: () => term.findPrevious(), + close: () => { + term.endFind(); + term.focus(); + }, +}); +term.onFindChange((state) => findBar.update(state)); + // Cmd+K: nothing on the alternate screen; the clear is this tab's own, and a reload // replays the session's output again (ADR 054) function clearScreen(): boolean { diff --git a/src/client/shortcuts.test.ts b/src/client/shortcuts.test.ts index 8ab4f0a..dca2e2e 100644 --- a/src/client/shortcuts.test.ts +++ b/src/client/shortcuts.test.ts @@ -84,3 +84,37 @@ describe('shortcutFor on macOS', () => { expect(at('KeyC', ['meta'])).toBeNull(); }); }); + +describe('shortcutFor find keys (ADR 055)', () => { + const at = (mac: boolean, code: string, mods: Mod[] = []) => shortcutFor(key(code, mods), mac); + + test('Ctrl+Shift+F opens the find bar outside macOS, Cmd+F on macOS', () => { + expect(at(false, 'KeyF', ['ctrl', 'shift'])).toEqual({ action: 'find', performable: false }); + expect(at(true, 'KeyF', ['meta'])).toEqual({ action: 'find', performable: false }); + expect(at(true, 'KeyF', ['ctrl', 'shift'])).toBeNull(); + expect(at(false, 'KeyF', ['meta'])).toBeNull(); + }); + + test('Ctrl+F stays the app’s', () => { + expect(at(false, 'KeyF', ['ctrl'])).toBeNull(); + expect(at(true, 'KeyF', ['ctrl'])).toBeNull(); + }); + + test('Escape closes, and goes to the app with no search open', () => { + for (const mac of [false, true]) { + expect(at(mac, 'Escape')).toEqual({ action: 'findClose', performable: true }); + expect(at(mac, 'Escape', ['shift'])).toBeNull(); + } + }); + + test('Cmd+Shift+F closes and Cmd+G / Cmd+Shift+G move on macOS only', () => { + expect(at(true, 'KeyF', ['meta', 'shift'])).toEqual({ action: 'findClose', performable: true }); + expect(at(true, 'KeyG', ['meta'])).toEqual({ action: 'findNext', performable: true }); + expect(at(true, 'KeyG', ['meta', 'shift'])).toEqual({ + action: 'findPrevious', + performable: true, + }); + expect(at(false, 'KeyG', ['meta'])).toBeNull(); + expect(at(false, 'KeyG', ['ctrl'])).toBeNull(); + }); +}); diff --git a/src/client/shortcuts.ts b/src/client/shortcuts.ts index 0f29ae3..b9a32c7 100644 --- a/src/client/shortcuts.ts +++ b/src/client/shortcuts.ts @@ -1,12 +1,13 @@ /** - * A copy, paste or clear-screen key, as Ghostty binds them by default (ADR 054). - * `performable`: when the action cannot be done (no selection to copy, the alternate - * screen for a clear) the key goes on to the app, as Ghostty's `performable:` bindings - * do; otherwise it is consumed either way. A paste is never the app's: without a - * clipboard API the browser's own paste is left to run instead. + * A copy, paste, clear-screen (ADR 054) or find (ADR 055) key, as Ghostty binds them by + * default. `performable`: when the action cannot be done (no selection to copy, the + * alternate screen for a clear, no search open to close or move in) the key goes on to + * the app, as Ghostty's `performable:` bindings do; otherwise it is consumed either way. + * A paste is never the app's: without a clipboard API the browser's own paste is left to + * run instead. */ export interface Shortcut { - action: 'copy' | 'paste' | 'clear'; + action: 'copy' | 'paste' | 'clear' | 'find' | 'findClose' | 'findNext' | 'findPrevious'; performable: boolean; } @@ -26,11 +27,25 @@ export function shortcutFor(e: Keys, mac: boolean): Shortcut | null { if (e.ctrlKey && !e.metaKey && e.code === 'KeyV') return { action: 'paste', performable: false }; const mods = `${e.ctrlKey ? 'ctrl+' : ''}${e.altKey ? 'alt+' : ''}${e.shiftKey ? 'shift+' : ''}${e.metaKey ? 'super+' : ''}`; const trigger = `${mods}${e.code}`; + if (trigger === 'Escape') return { action: 'findClose', performable: true }; if (mac) { - if (trigger === 'super+KeyK') return { action: 'clear', performable: true }; + switch (trigger) { + case 'super+KeyK': + return { action: 'clear', performable: true }; + case 'super+KeyF': + return { action: 'find', performable: false }; + case 'shift+super+KeyF': + return { action: 'findClose', performable: true }; + case 'super+KeyG': + return { action: 'findNext', performable: true }; + case 'shift+super+KeyG': + return { action: 'findPrevious', performable: true }; + } return null; } switch (trigger) { + case 'ctrl+shift+KeyF': + return { action: 'find', performable: false }; case 'ctrl+shift+KeyC': return { action: 'copy', performable: true }; case 'ctrl+Insert': From 6e7cf6b7ac51f1f60d291838f175ddddf82048cf Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:46:38 -0400 Subject: [PATCH 116/295] docs(client): record find in the scrollback in the specs and provenance --- docs/specs/client.md | 24 +++++++++++++++++++++ docs/specs/key-bindings.md | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 83fd6ad..59ced02 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -197,6 +197,29 @@ The sequences go in only where the output is at ground: if the last message ende The selection and kitty graphics placements are cleared too. The clear is the tab's own: a reload, reconnect or another tab gets the server's replay, scrollback included. +## Find in the Scrollback + +The canvas is invisible to the browser's own find, so webtty has Ghostty's search ([ADR 055](../adrs/055.client.find-in-scrollback.md)), run by libghostty-vt (`ghostty_search_*`): + +| Platform | Key | Action | When it cannot be done | +|---|---|---|---| +| macOS | Cmd+F | open the find bar (focus it and select its text if open) | | +| macOS | Cmd+Shift+F | close | the app gets the key (bar closed) | +| macOS | Cmd+G / Cmd+Shift+G | next / previous match | the app gets the key (bar closed) | +| others | Ctrl+Shift+F | open the find bar | | +| all | Escape | close | the app gets the key (bar closed) | +| in the field | Enter / Shift+Enter | next / previous match | | + +Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `find-bar.ts`). + +- **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. +- **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). +- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows. +- **Highlights**: every match in `searchBackground` / `searchForeground`, the selected one in `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults); the selection is drawn over both. +- **The bar** (`find-bar.ts`) is a DOM overlay at the top right, outside `#terminal`, coloured from the theme, with a labelled field, the count in a live region, ↑ (next), ↓ (previous) and × buttons. Typing searches after 150 ms; closing ends the search, clears the highlights and refocuses the terminal, and the field keeps its text for the next open. + +`Terminal` (vendored) keeps the needle (also across `reset`), runs the search for at most 4 ms a frame before each render, and reads the matches again when they changed, at most every ten times the last read took; the renderer finds the matches in view by binary search on the newest-to-oldest list, and repaints a row only when its matches change. Find has no side effects, so the replay on connect needs no special handling. + ## Program Clipboard Writes Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH ([ADR 052](../adrs/052.client.osc52-clipboard-write.md)). Ghostty parses and decodes it (also iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522) and calls the binding's `CLIPBOARD_WRITE` callback, installed only while `clipboardWrite` is on: @@ -244,6 +267,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Copy behavior | `copyOnSelect` + `rightClickBehavior` — two independent configurable copy modes | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Copy, paste and clear keys | Ghostty's defaults for the browser's platform: Ctrl+Shift+C and Ctrl+Insert copy and Shift+Insert pastes outside macOS (Ctrl+C stays the interrupt; Ctrl+Shift+C with no selection goes to the app), Cmd+K clears the screen and scrollback on macOS (at an OSC 133 prompt with FF for the shell to redraw; the app's key on the alternate screen); `keyboardBindings` win | [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) | ✅ | +| Find in the scrollback | Cmd+F (macOS) or Ctrl+Shift+F opens a find bar; libghostty-vt's search over scrollback and screen (ASCII case-insensitive, across soft wraps), every match highlighted and the selected one distinct in Ghostty's colours, Enter / Shift+Enter (Cmd+G / Cmd+Shift+G) to the next (older) and previous match with Ghostty's `k/n` count, the view jumping to a match out of view and kept on it while output streams, Escape to close; `keyboardBindings` win | [ADR 055](../adrs/055.client.find-in-scrollback.md) | ✅ | | Focus reports, title and bell | Mode 1004 focus reports encoded by Ghostty, sent on each change and when the mode is set; the tab title is the program's OSC 0/2 title (`<title> \| webtty`, the session id while it has none); a bell puts 🔔 in front of it until the terminal is focused or a key is pressed, and bells replayed on connect are ignored; `bellTitle: false` turns the mark off | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | diff --git a/docs/specs/key-bindings.md b/docs/specs/key-bindings.md index 37ba1fc..acfb7e0 100644 --- a/docs/specs/key-bindings.md +++ b/docs/specs/key-bindings.md @@ -28,7 +28,7 @@ User entries are **merged with defaults by `(key, mods)` identity**: - All other defaults are preserved. - To consume a key without sending anything, set `"chars": ""`. -Bindings also win over webtty's built-in copy, paste and clear-screen keys (Ctrl+Shift+C, Ctrl+Insert, Shift+Insert and Ctrl+V; Cmd+K on macOS): a binding for one of them sends its `chars` instead, and `"chars": ""` turns the key off. See [ADR 054](../adrs/054.client.copy-paste-clear-keys.md). Font-size zoom keys are not overridable. +Bindings also win over webtty's built-in copy, paste and clear-screen keys (Ctrl+Shift+C, Ctrl+Insert, Shift+Insert and Ctrl+V; Cmd+K on macOS) and find keys (Ctrl+Shift+F; Cmd+F, Cmd+Shift+F, Cmd+G and Cmd+Shift+G on macOS; Escape while the find bar is open): a binding for one of them sends its `chars` instead, and `"chars": ""` turns the key off. See [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) and [ADR 055](../adrs/055.client.find-in-scrollback.md). Bindings do not apply in the find bar's field. Font-size zoom keys are not overridable. ## Defining `chars` diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 7dc096e..3505eb5 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -42,4 +42,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. - `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. - `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). The binding adds `writeUntilGround` (`ghostty_terminal_vt_write_until_ground`; `write` and it share `feed`), and the clear is pending until a write reaches ground, so it never cuts a sequence the output left unfinished; `reset` drops it. Upstream had no clear-screen action (its `clear`, removed above, was unused). +- `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. From d3fbd98fdacf8657f6a9c994c2eb1792470502fb Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:56:50 -0400 Subject: [PATCH 117/295] fix(client): scroll to the bottom on a key or a paste sent to the program, as Ghostty's scroll-to-bottom = keystroke --- docs/adrs/055.client.find-in-scrollback.md | 3 ++- docs/specs/client.md | 2 +- src/client/index.ts | 12 ++++++--- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/terminal.ts | 14 ++++++++--- src/client/vendor/terminal.test.ts | 28 +++++++++++++++++++++ 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/docs/adrs/055.client.find-in-scrollback.md b/docs/adrs/055.client.find-in-scrollback.md index 8d03c30..7f76c16 100644 --- a/docs/adrs/055.client.find-in-scrollback.md +++ b/docs/adrs/055.client.find-in-scrollback.md @@ -18,7 +18,7 @@ Ghostty 1.3 has search (`src/config/Config.zig`, `Keybinds.init`, on `main` at t - **Matching** (`src/terminal/search/sliding_window.zig`): the page text is searched as one stream, so a match can span a soft wrap; ASCII letters compare case-insensitively (`std.ascii.indexOfIgnoreCase`) and everything else byte-exactly. There is no smart case and no regex. - **Scrolling** (`src/terminal/search/terminal.zig`, `select`): if no row of the newly selected match is in the viewport, the viewport scrolls so the match's first row is the top row; otherwise it does not move. - **Colours**: `search-background` / `search-foreground` (default `#FFE082` on black) for matches and `search-selected-background` / `search-selected-foreground` (default `#F2A57E` on black) for the selected one. The renderer gives the selection precedence over the selected match, and the selected match over other matches (`src/renderer/generic.zig`). -- **Output** does not move a scrolled viewport (`scroll-to-bottom` defaults to `keystroke, no-output`), so the selected match stays in view while a program writes. +- **Output** does not move a scrolled viewport (`scroll-to-bottom` defaults to `keystroke, no-output`), so the selected match stays in view while a program writes. A key sent to the program (not a modifier) and a paste do scroll it to the bottom (`Surface.zig`, `keyCallback`, `completeClipboardPaste`). The pinned libghostty-vt wasm (`05dcf887`, ADR 042) exports Ghostty's search as a C API (`include/ghostty/vt/search.h`, `ghostty_search_*`): a search bound to a terminal, given a needle, driven in bounded steps by `ghostty_search_feed` (reads the terminal) and `ghostty_search_tick` (works on what it read), with the total, the selected match and its index (0 is the newest), every match (newest to oldest, as selections), and `SELECT_NEXT` / `SELECT_PREV`, which can be told not to scroll. Results follow output, scrollback pruning, resize, reflow and screen switches. On 10,000 lines of scrollback a full search takes about 40-50 ms in about 25 steps of 2 ms; reading all 20,000 matches of a common word and converting them to screen rows takes about 3 ms (107,000 matches of `e`: about 11 ms); a feed with no change costs 0.5-2 ms. @@ -43,6 +43,7 @@ The binding (`ghostty.ts`) adds, on the active screen, scrollback included: - `find(needle)`, `findNext()`, `findPrevious()`, `endFind()` and `onFindChange` (total, selected index) are the bar's API. - **Scrolling to the selected match** is Ghostty's: if none of its rows is in view, the view jumps so its first row is the top row, clamped to the scrollback. SmoothScroll follows the jump, as it follows scrollbar drags. - **While a match is selected and the view is scrolled up**, output does not pull the view to the bottom; the view keeps its rows in place (it moves by as many rows as the selected match did), as Ghostty's viewport does. At the bottom, the view follows output as before. With the bar closed, or nothing selected, webtty's scroll-to-bottom on output is unchanged. +- **A key or a paste sent to the program scrolls to the bottom**, Ghostty's `keystroke` half, which webtty had only through the echo: without it, typing with a match selected would leave the view up in the scrollback, the prompt and the output out of sight. Mouse reports do not scroll. Once at the bottom the view follows output again, the match still selected. ### Highlights diff --git a/docs/specs/client.md b/docs/specs/client.md index 59ced02..18da554 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -214,7 +214,7 @@ Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of th - **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. - **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). -- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows. +- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows. A key or a paste sent to the program scrolls to the bottom, as in Ghostty (`scroll-to-bottom = keystroke, no-output`); mouse reports do not. - **Highlights**: every match in `searchBackground` / `searchForeground`, the selected one in `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults); the selection is drawn over both. - **The bar** (`find-bar.ts`) is a DOM overlay at the top right, outside `#terminal`, coloured from the theme, with a labelled field, the count in a live region, ↑ (next), ↓ (previous) and × buttons. Typing searches after 150 ms; closing ends the search, clears the highlights and refocuses the terminal, and the field keeps its text for the next open. diff --git a/src/client/index.ts b/src/client/index.ts index f5eaef1..809f14d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -351,6 +351,8 @@ container.addEventListener( e.preventDefault(); e.stopPropagation(); if (binding.chars && ws.readyState === WebSocket.OPEN) { + // A keystroke scrolls to the bottom, as the terminal's own keys do (ADR 055) + term.scrollToBottom(); ws.send(binding.chars); } }, @@ -403,7 +405,9 @@ term.onResize(({ cols, rows }: { cols: number; rows: number }) => { // event only fires for Cmd+V (the OS paste gesture) - Ctrl+V produces no // event at all. See ADR 014. function sendCtrlV(): void { - if (ws && ws.readyState === WebSocket.OPEN) ws.send('\x16'); + if (!ws || ws.readyState !== WebSocket.OPEN) return; + term.scrollToBottom(); + ws.send('\x16'); } // Text goes through the terminal, which encodes it as Ghostty does: bracketed @@ -413,8 +417,10 @@ function sendCtrlV(): void { function sendPaste(text: string): void { if (!ws || ws.readyState !== WebSocket.OPEN) return; const ctrlV = nonTextPaste(text); - if (ctrlV) ws.send(ctrlV); - else term.paste(text); + if (ctrlV) { + term.scrollToBottom(); + ws.send(ctrlV); + } else term.paste(text); } // Reads the clipboard and pastes it, as Cmd+V does; a failed read sends Ctrl+V. diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 3505eb5..8f30ed2 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -42,5 +42,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. - `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. - `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). The binding adds `writeUntilGround` (`ghostty_terminal_vt_write_until_ground`; `write` and it share `feed`), and the clear is pending until a write reaches ground, so it never cuts a sequence the output left unfinished; `reset` drops it. Upstream had no clear-screen action (its `clear`, removed above, was unused). -- `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. +- `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom), scrolls to the bottom on a key or a paste sent to the program as Ghostty's `scroll-to-bottom = keystroke` does (`handleInput` without a mouse report, `paste`), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 2fff136..8843f3b 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -689,6 +689,8 @@ export class Terminal implements ITerminalCore { paste(text: string): void { this.assertOpen(); if (this.options.disableStdin) return; + // Ghostty always scrolls to the bottom on a paste (webtty, ADR 055) + this.scrollToBottom(); this.wasmTerm!.paste(text); this.processTerminalResponses(); } @@ -1773,12 +1775,18 @@ export class Terminal implements ITerminalCore { } /** - * Input from the InputHandler. Keys and text clear the selection; a mouse report leaves - * it alone, so a Shift selection outlives the app's hover and clicks. (webtty, ADR 051) + * Input from the InputHandler. Keys and text clear the selection and scroll to the + * bottom; a mouse report leaves both alone, so a Shift selection outlives the app's + * hover and clicks. (webtty, ADR 051, ADR 055) */ private handleInput = (data: string, mouseReport = false): void => { if (this.options.disableStdin) return; - if (!mouseReport) this.selectionManager?.clearSelection(); + if (!mouseReport) { + this.selectionManager?.clearSelection(); + // Ghostty's `scroll-to-bottom = keystroke`, which output no longer does while a + // match is selected (webtty, ADR 055) + this.scrollToBottom(); + } this.awaitingEcho = true; this.dataEmitter.fire(data); }; diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 1c4d2d4..18c368b 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -453,6 +453,7 @@ describe('Terminal find', () => { getMetrics: () => ({ width: 8, height: 16, baseline: 12 }), devicePixelRatio: 1, setSearchHighlights: (h: unknown) => highlights.push(h), + render: () => {}, }; const internals = term as unknown as { isOpen: boolean; @@ -555,6 +556,33 @@ describe('Terminal find', () => { internals.wasmTerm.free(); }); + test('a key or a paste scrolls a view on a selected match to the bottom', async () => { + // Ghostty's `scroll-to-bottom = keystroke` (ADR 055) + const { term, internals, frames } = await findTerm(); + const input = term as unknown as { handleInput: (data: string, mouse?: boolean) => void }; + term.write('needle\r\n'); + for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + term.find('needle'); + frames(2); + term.findNext(); + const view = term.viewportY; + expect(view).toBeGreaterThan(0); + // A mouse report leaves the view alone + input.handleInput('\x1b[M !!', true); + expect(term.viewportY).toBe(view); + input.handleInput('l'); + expect(term.viewportY).toBe(0); + // and the echo is followed + term.write('l\r\n'); + frames(); + expect(term.viewportY).toBe(0); + term.findNext(); + expect(term.viewportY).toBeGreaterThan(0); + term.paste('ls'); + expect(term.viewportY).toBe(0); + internals.wasmTerm.free(); + }); + test('ending the search clears the highlights and the count', async () => { const { term, internals, frames, states, last } = await findTerm(); term.write('abc abc'); From dbed08cdd4605f0b53cd6a1a257474fefa4a4205 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 11:57:10 -0400 Subject: [PATCH 118/295] fix(client): output on the alternate screen scrolls to the bottom with a match selected, so a pager is not drawn past the top --- docs/adrs/055.client.find-in-scrollback.md | 1 + docs/specs/client.md | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/terminal.ts | 8 ++++++-- src/client/vendor/terminal.test.ts | 15 +++++++++++++++ 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/adrs/055.client.find-in-scrollback.md b/docs/adrs/055.client.find-in-scrollback.md index 7f76c16..92508d8 100644 --- a/docs/adrs/055.client.find-in-scrollback.md +++ b/docs/adrs/055.client.find-in-scrollback.md @@ -44,6 +44,7 @@ The binding (`ghostty.ts`) adds, on the active screen, scrollback included: - **Scrolling to the selected match** is Ghostty's: if none of its rows is in view, the view jumps so its first row is the top row, clamped to the scrollback. SmoothScroll follows the jump, as it follows scrollbar drags. - **While a match is selected and the view is scrolled up**, output does not pull the view to the bottom; the view keeps its rows in place (it moves by as many rows as the selected match did), as Ghostty's viewport does. At the bottom, the view follows output as before. With the bar closed, or nothing selected, webtty's scroll-to-bottom on output is unchanged. - **A key or a paste sent to the program scrolls to the bottom**, Ghostty's `keystroke` half, which webtty had only through the echo: without it, typing with a match selected would leave the view up in the scrollback, the prompt and the output out of sight. Mouse reports do not scroll. Once at the bottom the view follows output again, the match still selected. +- **Output on the alternate screen scrolls to the bottom** even with a match selected: that screen has no scrollback to keep a view in, and its matches are its own, so a pager started while scrolled up is drawn in full. ### Highlights diff --git a/docs/specs/client.md b/docs/specs/client.md index 18da554..d757e88 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -214,7 +214,7 @@ Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of th - **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. - **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). -- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows. A key or a paste sent to the program scrolls to the bottom, as in Ghostty (`scroll-to-bottom = keystroke, no-output`); mouse reports do not. +- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows, except on the alternate screen. A key or a paste sent to the program scrolls to the bottom, as in Ghostty (`scroll-to-bottom = keystroke, no-output`); mouse reports do not. - **Highlights**: every match in `searchBackground` / `searchForeground`, the selected one in `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults); the selection is drawn over both. - **The bar** (`find-bar.ts`) is a DOM overlay at the top right, outside `#terminal`, coloured from the theme, with a labelled field, the count in a live region, ↑ (next), ↓ (previous) and × buttons. Typing searches after 150 ms; closing ends the search, clears the highlights and refocuses the terminal, and the field keeps its text for the next open. diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 8f30ed2..617373e 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -42,5 +42,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`: programs write the clipboard (ADR 052). The binding installs Ghostty's `CLIPBOARD_WRITE` callback when the new `GhosttyTerminalConfig.clipboardWrite` is on (default true): it keeps the first plain-text representation (`isTextMime`) of a write to the standard clipboard, decoded as UTF-8, `''` for a clear, and answers `SUCCESS`; selection and primary writes are answered `UNSUPPORTED` and dropped. `TerminalEffects` gains `clipboard`, the last write since `readEffects`. `Terminal` gains the `clipboardWrite` option, passed to the engine, and `onClipboardWrite`, fired after a write that carried one, whose listeners `dispose` drops. Upstream ignored OSC 52. - `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. - `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). The binding adds `writeUntilGround` (`ghostty_terminal_vt_write_until_ground`; `write` and it share `feed`), and the clear is pending until a write reaches ground, so it never cuts a sequence the output left unfinished; `reset` drops it. Upstream had no clear-screen action (its `clear`, removed above, was unused). -- `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom), scrolls to the bottom on a key or a paste sent to the program as Ghostty's `scroll-to-bottom = keystroke` does (`handleInput` without a mouse report, `paste`), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. +- `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom; still to the bottom on the alternate screen), scrolls to the bottom on a key or a paste sent to the program as Ghostty's `scroll-to-bottom = keystroke` does (`handleInput` without a mouse report, `paste`), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 8843f3b..262d9c0 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -873,8 +873,12 @@ export class Terminal implements ITerminalCore { this.linkDetector?.invalidateCache(); // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior), except while the - // view is on a selected match: stepFind keeps it on its rows (webtty, ADR 055) - if (this.viewportY !== 0 && !this.findAnchor) { + // view is on a selected match: stepFind keeps it on its rows. The alternate screen + // has no scrollback to keep a view in, and its matches are its own (webtty, ADR 055) + if ( + this.viewportY !== 0 && + (!this.findAnchor || this.wasmTerm!.isAlternateScreen()) + ) { this.scrollToBottom(); } diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 18c368b..4020376 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -583,6 +583,21 @@ describe('Terminal find', () => { internals.wasmTerm.free(); }); + test('output that switches to the alternate screen scrolls to the bottom', async () => { + const { term, internals, frames } = await findTerm(); + term.write('needle\r\n'); + for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + term.find('needle'); + frames(2); + term.findNext(); + expect(term.viewportY).toBeGreaterThan(0); + term.write('\x1b[?1049hpager'); + expect(term.viewportY).toBe(0); + frames(); + expect(term.viewportY).toBe(0); + internals.wasmTerm.free(); + }); + test('ending the search clears the highlights and the count', async () => { const { term, internals, frames, states, last } = await findTerm(); term.write('abc abc'); From d4c9f79cd0a5be08e10d5b2184d0e4526d127bb7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:03:38 -0400 Subject: [PATCH 119/295] docs(client): ADR 056, desktop notifications from OSC 9 and OSC 777 --- docs/adrs/056.client.desktop-notifications.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/adrs/056.client.desktop-notifications.md diff --git a/docs/adrs/056.client.desktop-notifications.md b/docs/adrs/056.client.desktop-notifications.md new file mode 100644 index 0000000..0fad2af --- /dev/null +++ b/docs/adrs/056.client.desktop-notifications.md @@ -0,0 +1,80 @@ +# ADR 056: client — Desktop notifications from OSC 9 and OSC 777 + +**SPEC:** [client](../specs/client.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +Programs ask their terminal for a desktop notification with one of two sequences: + +- **OSC 9** (iTerm2): `ESC ] 9 ; <body> BEL` (or ST). No title. Claude Code, build tools and shell hooks (`printf '\e]9;done\a'`) send it. +- **OSC 777** (rxvt-unicode's `notify` extension, also Ghostty's and foot's): `ESC ] 777 ; notify ; <title> ; <body> BEL`. + +webtty showed neither: the sequences reached the engine and stopped there. + +OSC 9 is shared. ConEmu uses `OSC 9 ; <n> ; ...` for other things: `9;4` is the progress report (`9;4;1;50`), and `9;1` to `9;12` are sleep, a message box, the tab title, wait for input, a GUI macro, run a process, an environment variable, the working directory, xterm emulation, a comment and a prompt mark. Ghostty's parser (`src/terminal/osc/parsers/osc9.zig`, at the pinned commit `05dcf887`) tries the ConEmu forms first and takes `OSC 9` as a notification only when the payload is none of them: `9;4;1;50` is a progress report, `9;42 tests passed` and a bare `9;4` are notifications. OSC 777 (`rxvt_extension.zig`) takes only the `notify` extension and needs both fields: `777;notify;<title>;<body>`, the body running to the end (it may hold `;`), the title possibly empty. Other extensions, and `notify` without a second `;`, are dropped. Kitty's OSC 99 is a separate command that does not reach this callback. + +Ghostty's official libghostty-vt reports both to the embedder. `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION` (`include/ghostty/vt/terminal.h`) takes a `GhosttyTerminalDesktopNotificationFn(terminal, userdata, const GhosttyTerminalDesktopNotification*)`, called synchronously during the write, with a sized struct holding a `title` (empty for OSC 9) and a `body`, both borrowed for the callback's duration. NULL, the default, ignores the requests. Option and struct are in the wasm's `ghostty_type_json` manifest, as the options the binding already sets are. So no scanner of our own is needed: Ghostty's parser does the disambiguation, chunk boundaries and size limits. + +Ghostty the app, the embedder webtty follows: + +- **Config.** `desktop-notifications` (default `true`): "applications running in the terminal can show desktop notifications using certain escape sequences such as OSC 9 or OSC 777". Off, `Surface.zig` logs and drops them. +- **Rate limit.** `Surface.showDesktopNotification` shows at most one notification a second, and the same title and body at most once in five seconds. +- **When it shows (macOS).** The app posts every notification but presents it only when `!window.isKeyWindow || !surface.focused` (`shouldPresentNotification`): nothing appears while the user is looking at the terminal that asked. Notifications are removed when the surface gains focus; clicking one brings its window forward and focuses the surface. The notification's title is the program's title, its subtitle the surface title. On first use the app asks the system for permission (`requestAuthorization`), and shows nothing until it is granted. +- **GTK** shows them focused or not, with `Ghostty` for an empty title, and uses the body as the notification id, so the same body replaces the earlier one. + +The browser has its own rules. The Notification API exists only in a secure context (https, or localhost). Permission is `default` until asked, then `granted` or `denied`, per origin. Firefox and Safari grant the prompt only from a user gesture, and Chrome demotes prompts that come without one to a quiet icon in the address bar; a prompt on page load is the pattern browsers penalise. Chrome on Android throws from `new Notification()` (pages must use a service worker), and iOS Safari has no Notification API outside home-screen web apps. + +And, as for the bell and OSC 52 (ADR 052, ADR 053), the server replays the session's recent output on every connect. A notification in that history would pop up again on every reload and reconnect. + +## Decision + +### Take them from libghostty-vt's callback + +The binding (`ghostty.ts`) installs a `DESKTOP_NOTIFICATION` callback when the terminal is created with the new `GhosttyTerminalConfig.desktopNotifications` on (default true), and leaves it NULL otherwise. The callback checks the struct's `size` covers `body`, copies `title` and `body` out as UTF-8 (invalid bytes become U+FFFD), and queues them. `readEffects` gains `notifications`, every request since the previous call in order (two in one write are two notifications). `Terminal` gains the `desktopNotifications` option, passed to the engine, and `onDesktopNotification({ title, body })`, fired for each. + +### Show them as Ghostty on macOS does + +`DesktopNotifier` (`src/client/notifications.ts`) decides what happens to each, in this order: + +1. **Replayed ones are dropped.** The replay's end is found as for bells (ADR 053): from each connect, output is replay through the first message shorter than the server's piece, or until a 1 s pause. That logic moves out of `TabTitle` into `ReplayWindow` (`src/client/replay.ts`), which `index.ts` feeds and both consult. Not held until input as OSC 52 writes are: like the bell, a notification is for a tab the user is away from, so after a reconnect in the background (laptop wake, server restart) it must still arrive. +2. **Rate limited** as Ghostty does: one a second, and the same title and body once in five seconds, per tab. +3. **Nothing while the terminal is focused.** Focus is ADR 053's: the window has focus and keyboard focus is in the terminal. In another tab, another window or another app, or with focus in the address bar, it shows. This is Ghostty on macOS. Not `document.hidden`: a visible tab in a window behind another app is as unattended as a hidden one, and visibility misses it. +4. **Shown with `new Notification(title, { body, tag })`** when permission is `granted`. The title is the program's title, or for OSC 9 (no title) the tab's name: the program's OSC 0/2 title, or the session id. `tag` is the session, title and body, so two tabs on one session, or a repeat after the rate limit, replace one notification instead of stacking (GTK's body-as-id). Title and body go to the API as plain text; nothing is parsed as HTML. Clicking it focuses the window and the terminal and closes it; the terminal gaining focus or a key in the tab closes every notification it showed, as Ghostty removes them on focus. +5. **Otherwise, the bell mark.** With permission not granted, no API (http, iOS Safari) or a constructor that throws (Chrome on Android), the tab title gets the bell's `🔔 ` (ADR 053) until the terminal is attended. It is the bell's mark, so `bellTitle: false` turns it off too. + +### Ask for permission on the next input after the first notification + +Never on page load. The first notification that arrives (live, focused or not) while permission is `default` arms the request, and the next `keydown` or `mousedown` in the tab calls `Notification.requestPermission()` from that event, a user gesture in every browser. It is asked once per page load: a dismissed prompt is not repeated until a reload, and `denied` is never asked again (the browser would not show it). The notification that armed it is not shown afterwards: by then the user is in the tab. Ghostty asks the system on first use too; the gesture is what the browser adds. + +### Config + +`desktopNotifications` (boolean, default `true`, Ghostty's `desktop-notifications`), served by `/api/config` and read on every tab reload. `false` leaves the callback unset, so Ghostty ignores OSC 9 and OSC 777 and webtty never asks for permission. + +## Consequences + +- `printf '\e]9;done\a'`, `printf '\e]777;notify;Build;passed\a'`, and Claude Code's terminal notifications show as desktop notifications while the user is elsewhere, once permission is granted. Clicking one returns to the tab. +- The first notification of a page shows only as `🔔 ` in the title; the browser's permission prompt comes with the next key or click in the tab. A user who never interacts with the tab is never asked. +- ConEmu's progress reports (`9;4`) and the other `9;<n>` forms do not notify, as in Ghostty. They are not shown in any other way either. +- Reloads and reconnects do not notify for history. As for bells (ADR 053), a notification in the first live message after a replay that ends on a full piece is taken as replay unless it comes 1 s later, and a replay piece delayed by 1 s or more can let a replayed one through. +- Over plain http (not localhost) notifications are never shown; the bell mark is. +- A program can notify the user without asking, as in Ghostty's default; `desktopNotifications: false` turns that off. + +## Considered Options + +- **libghostty-vt's callback (chosen).** The parser that already runs, with Ghostty's own OSC 9 / ConEmu split. +- **Scan the PTY stream for OSC 9 / OSC 777 ourselves.** A second parser to keep in step with Ghostty's (ConEmu forms, ST vs BEL, sequences split across messages, size limits), for something the engine already reports. +- **Show only while the tab is hidden (`visibilityState`).** Misses a visible window behind another app, the common case for a notification. +- **Show always, as GTK does.** A banner about the terminal the user is typing in; Ghostty on macOS suppresses it, and the bell mark would do for that case anyway. +- **Ask for permission on page load, or on the first input whatever happens.** Browsers penalise prompts without a reason in view, and a session that never notifies should never ask. +- **Hold until input after a connect, as OSC 52.** Loses every notification after a background reconnect, which is when they matter. +- **Our own in-page toast as the fallback.** Visible only to a user already looking at the tab, who does not need it; the title mark shows in the tab strip. + +## Related Decisions + +- [ADR 042 — Official libghostty-vt engine behind `?engine`](042.client.official-vt-engine.md): effects read after each write +- [ADR 052 — Programs write the clipboard with OSC 52](052.client.osc52-clipboard-write.md): the replay and side effects +- [ADR 053 — Focus reports, the app's title and the bell](053.client.focus-reports-title-and-bell.md): terminal focus, the replay's end, the bell mark From 25c7f670cc7236ffa0fee4174fca5aeca9ccca6f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:04:31 -0400 Subject: [PATCH 120/295] feat(config): add desktopNotifications, served by /api/config --- src/config.test.ts | 17 +++++++++++++++++ src/config.ts | 6 ++++++ src/server/routes.test.ts | 1 + src/server/routes.ts | 1 + 4 files changed, 25 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index fc387aa..d3f0f1b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -274,6 +274,23 @@ describe('loadConfig — reads and merges', () => { } }); + test('defaults desktopNotifications to true', () => { + writeConfig(JSON.stringify({})); + expect(loadConfig().desktopNotifications).toBe(true); + }); + + test('overrides desktopNotifications when set to false', () => { + writeConfig(JSON.stringify({ desktopNotifications: false })); + expect(loadConfig().desktopNotifications).toBe(false); + }); + + test('ignores a desktopNotifications that is not a boolean', () => { + for (const value of ['false', 0, null]) { + writeConfig(JSON.stringify({ desktopNotifications: value })); + expect(loadConfig().desktopNotifications).toBe(true); + } + }); + test('overrides rightClickBehavior when set to copyPaste', () => { writeConfig(JSON.stringify({ rightClickBehavior: 'copyPaste' })); expect(loadConfig().rightClickBehavior).toBe('copyPaste'); diff --git a/src/config.ts b/src/config.ts index aec411d..272a9eb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -119,6 +119,8 @@ export interface Config { clipboardWrite: boolean; /** Put a 🔔 in front of the tab title when a program rings the bell (BEL), until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title`. Bells replayed on reconnect are ignored. */ bellTitle: boolean; + /** Let programs show desktop notifications with OSC 9 and OSC 777, as Ghostty's `desktop-notifications`: shown while the terminal is not focused, once the browser grants permission. Notifications replayed on reconnect are ignored. `false` ignores them. */ + desktopNotifications: boolean; /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ mouseScrollSpeed: number; /** Padding in px around the terminal canvas, on all four sides. `0` = none. Coloured by `theme.padding`, or `theme.background` if that is unset. */ @@ -199,6 +201,7 @@ export const DEFAULT_CONFIG: Config = { rightClickBehavior: 'contextMenu' as RightClickBehavior, clipboardWrite: true, bellTitle: true, + desktopNotifications: true, mouseScrollSpeed: 1, padding: 0, logs: false, @@ -317,6 +320,9 @@ export function loadConfig(): Config { }), ...(typeof p.clipboardWrite === 'boolean' && { clipboardWrite: p.clipboardWrite }), ...(typeof p.bellTitle === 'boolean' && { bellTitle: p.bellTitle }), + ...(typeof p.desktopNotifications === 'boolean' && { + desktopNotifications: p.desktopNotifications, + }), ...(typeof p.mouseScrollSpeed === 'number' && p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), ...(typeof p.padding === 'number' && diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index e8be4d3..e4569fd 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -119,6 +119,7 @@ describe('server — routes', () => { expect(typeof body.rightClickBehavior).toBe('string'); expect(body.clipboardWrite).toBe(true); expect(body.bellTitle).toBe(true); + expect(body.desktopNotifications).toBe(true); expect(typeof body.mouseScrollSpeed).toBe('number'); expect(body.scrollbackLines).toBe(10000); expect(typeof body.padding).toBe('number'); diff --git a/src/server/routes.ts b/src/server/routes.ts index 641918d..4043b96 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -124,6 +124,7 @@ export async function handleRequest( rightClickBehavior: config.rightClickBehavior, clipboardWrite: config.clipboardWrite, bellTitle: config.bellTitle, + desktopNotifications: config.desktopNotifications, mouseScrollSpeed: config.mouseScrollSpeed, padding: config.padding, keyboardBindings: config.keyboardBindings, From d5e982c9643815cd8a29b0e43b1368a6b8a8eba9 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:04:31 -0400 Subject: [PATCH 121/295] feat(client): take OSC 9 and OSC 777 notifications from libghostty-vt's DESKTOP_NOTIFICATION callback --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + src/client/vendor/ghostty-web/ghostty.ts | 66 ++++++++++++- src/client/vendor/ghostty-web/index.ts | 7 +- src/client/vendor/ghostty-web/interfaces.ts | 3 + src/client/vendor/ghostty-web/terminal.ts | 17 +++- src/client/vendor/ghostty-web/types.ts | 6 ++ src/client/vendor/official-terminal.test.ts | 100 +++++++++++++++++++- src/client/vendor/terminal.test.ts | 40 +++++++- 8 files changed, 226 insertions(+), 14 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 617373e..0233edc 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -43,4 +43,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `terminal.ts`: focus reports (ADR 053). The binding adds `encodeFocus` (`ghostty_focus_encode`). `Terminal` follows focus with `focusin` and `focusout` on the container (a `focusout` to an element inside it is not a change, and a window blur or focus reaches it as one of the two) and, while the app has mode 1004 on, sends `CSI I` or `CSI O` through `onData` on each change, and the current state after a write that turned the mode on, as Ghostty does when the mode is set. The new `resyncFocus` sends the current state if the mode is on, for `index.ts` to call when a socket opens. Upstream sent no focus reports. - `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). The binding adds `writeUntilGround` (`ghostty_terminal_vt_write_until_ground`; `write` and it share `feed`), and the clear is pending until a write reaches ground, so it never cuts a sequence the output left unfinished; `reset` drops it. Upstream had no clear-screen action (its `clear`, removed above, was unused). - `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom; still to the bottom on the alternate screen), scrolls to the bottom on a key or a paste sent to the program as Ghostty's `scroll-to-bottom = keystroke` does (`handleInput` without a mouse report, `paste`), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. +- `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`, `index.ts`: desktop notifications (ADR 056). The binding installs Ghostty's `DESKTOP_NOTIFICATION` callback when the new `GhosttyTerminalConfig.desktopNotifications` is on (default true): it copies the request's `title` (`''` for OSC 9) and `body` out as UTF-8, reading only what the struct's `size` covers. `TerminalEffects` gains `notifications`, every request since `readEffects`, in order, and `DesktopNotification` is exported. `Terminal` gains the `desktopNotifications` option, passed to the engine, and `onDesktopNotification`, fired for each. Upstream ignored OSC 9 and OSC 777. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 184106b..5f87a64 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -87,7 +87,17 @@ export interface SearchState { selected: number | null; } -/** Bell, title and clipboard writes since the last `readEffects` (ADR 042, ADR 052). */ +/** A desktop notification a program asked for (OSC 9, OSC 777). (webtty, ADR 056) */ +export interface DesktopNotification { + /** `''` when the sequence has none (OSC 9). */ + title: string; + body: string; +} + +/** + * Bell, title, clipboard writes and desktop notifications since the last `readEffects` + * (ADR 042, ADR 052, ADR 056). + */ export interface TerminalEffects { bell: boolean; title: string | null; @@ -97,6 +107,12 @@ export interface TerminalEffects { * while the terminal was created with `clipboardWrite` on. (webtty, ADR 052) */ clipboard: string | null; + /** + * The desktop notifications the program asked for (OSC 9 and OSC 777 `notify`), in + * order. Only set while the terminal was created with `desktopNotifications` on. + * (webtty, ADR 056) + */ + notifications: DesktopNotification[]; } /** @@ -221,7 +237,7 @@ export interface GhosttyTerminal { // Replies and effects hasResponse(): boolean; readResponse(): string | null; - /** Bell, title and clipboard writes since the last call. */ + /** Bell, title, clipboard writes and desktop notifications since the last call. */ readEffects(): TerminalEffects; } @@ -557,6 +573,7 @@ function resolveOfficialLayout(layout: VtLayout) { DEFAULT_CURSOR_STYLE: opt('DEFAULT_CURSOR_STYLE'), DEFAULT_CURSOR_BLINK: opt('DEFAULT_CURSOR_BLINK'), CLIPBOARD_WRITE: opt('CLIPBOARD_WRITE'), + DESKTOP_NOTIFICATION: opt('DESKTOP_NOTIFICATION'), }, defaultCursorStyle: { bar: layout.value('GhosttyTerminalCursorStyle', 'BAR'), @@ -792,6 +809,12 @@ function resolveOfficialLayout(layout: VtLayout) { STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), CLIPBOARD: layout.value('GhosttyPasteSource', 'CLIPBOARD'), }, + notification: { + size: layout.offset('GhosttyTerminalDesktopNotification', 'size'), + title: layout.offset('GhosttyTerminalDesktopNotification', 'title'), + body: layout.offset('GhosttyTerminalDesktopNotification', 'body'), + bodyEnd: layout.offset('GhosttyTerminalDesktopNotification', 'body') + layout.size('GhosttyString'), + }, clipboardWrite: { location: layout.offset('GhosttyClipboardWrite', 'location'), contents: layout.offset('GhosttyClipboardWrite', 'contents'), @@ -1292,6 +1315,7 @@ export class OfficialTerminal implements GhosttyTerminal { private titleChanged = false; private pendingTitle: string | null = null; private pendingClipboard: string | null = null; + private pendingNotifications: DesktopNotification[] = []; // Search (ADR 055): the handle while a needle is set, the generation it last fed, and // the wasm buffer matches are read into, grown as needed. @@ -1382,7 +1406,10 @@ export class OfficialTerminal implements GhosttyTerminal { ]); this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); - this.installCallbacks(config?.clipboardWrite ?? true); + this.installCallbacks( + config?.clipboardWrite ?? true, + config?.desktopNotifications ?? true + ); this.applyConfig(config); this.allocView(); } @@ -2068,10 +2095,16 @@ export class OfficialTerminal implements GhosttyTerminal { } readEffects(): TerminalEffects { - const effects = { bell: this.bell, title: this.pendingTitle, clipboard: this.pendingClipboard }; + const effects = { + bell: this.bell, + title: this.pendingTitle, + clipboard: this.pendingClipboard, + notifications: this.pendingNotifications, + }; this.bell = false; this.pendingTitle = null; this.pendingClipboard = null; + this.pendingNotifications = []; return effects; } @@ -2146,7 +2179,7 @@ export class OfficialTerminal implements GhosttyTerminal { // Private: setup // ========================================================================== - private installCallbacks(clipboardWrite: boolean): void { + private installCallbacks(clipboardWrite: boolean, desktopNotifications: boolean): void { const L = this.L; const table = this.vt.fnTable; const install = ( @@ -2253,6 +2286,29 @@ export class OfficialTerminal implements GhosttyTerminal { false ); } + // DESKTOP_NOTIFICATION(terminal, userdata, notification): OSC 9 (but not ConEmu's + // OSC 9;<n> forms, which Ghostty's parser takes first) and OSC 777;notify. The + // strings are borrowed, so they are copied out here. Left NULL with + // desktopNotifications off, so Ghostty ignores them (webtty, ADR 056). + if (desktopNotifications) { + install( + L.opt.DESKTOP_NOTIFICATION, + 'DESKTOP_NOTIFICATION', + (_t, _u, notification) => { + const N = this.L.notification; + // A sized struct: read only the fields its size covers. + if (this.dv().getUint32(notification + N.size, true) < N.bodyEnd) return undefined; + const text = (ptr: number) => new TextDecoder().decode(this.stringAt(ptr)); + this.pendingNotifications.push({ + title: text(notification + N.title), + body: text(notification + N.body), + }); + return undefined; + }, + 3, + false + ); + } } /** diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index cf1272f..d274852 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -25,7 +25,12 @@ export { DirtyState, KeyEncoderOption, } from './ghostty'; -export type { GhosttyTerminal, KeyEncoder, TerminalEffects } from './ghostty'; +export type { + DesktopNotification, + GhosttyTerminal, + KeyEncoder, + TerminalEffects, +} from './ghostty'; export { Key, KeyAction, Mods } from './types'; export type { KeyEvent, GhosttyCell } from './types'; diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 37d93c0..f0e8853 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -30,6 +30,9 @@ export interface ITerminalOptions { // Clipboard options clipboardWrite?: boolean; // Report program clipboard writes (OSC 52) through onClipboardWrite; read when the terminal is created (default: true) (webtty, ADR 052) + // Notification options + desktopNotifications?: boolean; // Report desktop notifications (OSC 9, OSC 777) through onDesktopNotification; read when the terminal is created (default: true) (webtty, ADR 056) + // The Ghostty WASM instance, from Ghostty.load() (webtty: upstream's init() and its // module-level instance are removed) ghostty: Ghostty; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 262d9c0..e6ca1a3 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -17,6 +17,7 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; import type { + DesktopNotification, Ghostty, GhosttyCell, GhosttyTerminal, @@ -216,6 +217,7 @@ export class Terminal implements ITerminalCore { private scrollEmitter = new EventEmitter<number>(); private pasteRequestEmitter = new EventEmitter<void>(); private clipboardWriteEmitter = new EventEmitter<string>(); + private desktopNotificationEmitter = new EventEmitter<DesktopNotification>(); private findChangeEmitter = new EventEmitter<SearchState>(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent<string> = this.dataEmitter.event; @@ -234,6 +236,13 @@ export class Terminal implements ITerminalCore { * embedder writes the browser clipboard. (webtty, ADR 052) */ public readonly onClipboardWrite: IEvent<string> = this.clipboardWriteEmitter.event; + /** + * The program asked for a desktop notification (OSC 9, OSC 777 `notify`), with its + * title (`''` for none) and body as plain text. Fires only while the + * `desktopNotifications` option is on; the embedder shows it. (webtty, ADR 056) + */ + public readonly onDesktopNotification: IEvent<DesktopNotification> = + this.desktopNotificationEmitter.event; /** The match count or the selected match changed (webtty, ADR 055) */ public readonly onFindChange: IEvent<SearchState> = this.findChangeEmitter.event; @@ -310,6 +319,7 @@ export class Terminal implements ITerminalCore { copyOnSelect: options.copyOnSelect ?? true, rightClickBehavior: options.rightClickBehavior ?? 'contextMenu', clipboardWrite: options.clipboardWrite ?? true, + desktopNotifications: options.desktopNotifications ?? true, }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) @@ -482,6 +492,7 @@ export class Terminal implements ITerminalCore { cursorStyle: this.options.cursorStyle, cursorBlink: this.options.cursorBlink, clipboardWrite: this.options.clipboardWrite, + desktopNotifications: this.options.desktopNotifications, }; } @@ -862,12 +873,13 @@ export class Terminal implements ITerminalCore { if (focusReporting && !this.focusReporting) this.reportFocus(); this.focusReporting = focusReporting; - // Bell, title (OSC 0/2) and clipboard writes (OSC 52) as Ghostty reported them - // (webtty, ADR 042, ADR 052) + // Bell, title (OSC 0/2), clipboard writes (OSC 52) and desktop notifications (OSC 9, + // OSC 777) as Ghostty reported them (webtty, ADR 042, ADR 052, ADR 056) const effects = this.wasmTerm!.readEffects(); if (effects.bell) this.bellEmitter.fire(); if (effects.title !== null) this.setTitle(effects.title); if (effects.clipboard !== null) this.clipboardWriteEmitter.fire(effects.clipboard); + for (const n of effects.notifications) this.desktopNotificationEmitter.fire(n); // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); @@ -1208,6 +1220,7 @@ export class Terminal implements ITerminalCore { this.scrollEmitter.dispose(); this.pasteRequestEmitter.dispose(); this.clipboardWriteEmitter.dispose(); + this.desktopNotificationEmitter.dispose(); this.findChangeEmitter.dispose(); } diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index a3979b8..4ed0d6b 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -304,6 +304,12 @@ export interface GhosttyTerminalConfig { * left unset and the writes are ignored. Default true. (webtty, ADR 052) */ clipboardWrite?: boolean; + /** + * Whether programs may ask for desktop notifications (OSC 9, OSC 777 `notify`): they + * are reported by `readEffects`. Off, Ghostty's desktop notification callback is left + * unset and the requests are ignored. Default true. (webtty, ADR 056) + */ + desktopNotifications?: boolean; } /** diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index d6ff150..b7f95a0 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -630,10 +630,25 @@ describe('official engine: effects and replies', () => { test('reports the title and the bell once', () => { const t = term(); t.write('\x1b]2;first\x07'); - expect(t.readEffects()).toEqual({ bell: false, title: 'first', clipboard: null }); + expect(t.readEffects()).toEqual({ + bell: false, + title: 'first', + clipboard: null, + notifications: [], + }); t.write('\x07\x1b]0;second\x1b\\'); - expect(t.readEffects()).toEqual({ bell: true, title: 'second', clipboard: null }); - expect(t.readEffects()).toEqual({ bell: false, title: null, clipboard: null }); + expect(t.readEffects()).toEqual({ + bell: true, + title: 'second', + clipboard: null, + notifications: [], + }); + expect(t.readEffects()).toEqual({ + bell: false, + title: null, + clipboard: null, + notifications: [], + }); t.free(); }); @@ -849,6 +864,85 @@ describe('official engine: cursor at a prompt (ADR 054)', () => { }); }); +describe('official engine: desktop notifications (ADR 056)', () => { + /** The notifications one write reports. */ + function notified(seq: string, config: object = THEME) { + const t = term(80, 24, config); + t.write(seq); + const { notifications } = t.readEffects(); + expect(t.readEffects().notifications).toEqual([]); + // Neither sequence has a reply. + expect(t.readResponse()).toBeNull(); + t.free(); + return notifications; + } + + test('OSC 9 is a notification with no title, ended by BEL or ST', () => { + expect(notified('\x1b]9;Build done\x07')).toEqual([{ title: '', body: 'Build done' }]); + expect(notified('\x1b]9;héllo 中文 🎉; with; semicolons\x1b\\')).toEqual([ + { title: '', body: 'héllo 中文 🎉; with; semicolons' }, + ]); + }); + + test('OSC 777;notify has a title and a body; the body may hold semicolons', () => { + expect(notified('\x1b]777;notify;Claude Code;Needs your input\x07')).toEqual([ + { title: 'Claude Code', body: 'Needs your input' }, + ]); + expect(notified('\x1b]777;notify;t;a;b\x07')).toEqual([{ title: 't', body: 'a;b' }]); + expect(notified('\x1b]777;notify;;body\x07')).toEqual([{ title: '', body: 'body' }]); + }); + + test("ConEmu's OSC 9;<n> forms are not notifications", () => { + for (const seq of [ + '9;4;1;50', // progress + '9;4;0', + '9;1;100', // sleep + '9;2;message box', + '9;3;tab title', + '9;5', // wait for input + '9;6;macro', + '9;7;cmd', + '9;8;PATH', + '9;9;/tmp', + '9;10', + '9;11;comment', + '9;12', // prompt start + ]) { + expect(notified(`\x1b]${seq}\x07`)).toEqual([]); + } + }); + + test('an OSC 9 that only looks like ConEmu is a notification, as in Ghostty', () => { + expect(notified('\x1b]9;4\x07')).toEqual([{ title: '', body: '4' }]); + expect(notified('\x1b]9;42 tests passed\x07')).toEqual([ + { title: '', body: '42 tests passed' }, + ]); + }); + + test('OSC 777 other than notify, or with no title, is ignored', () => { + expect(notified('\x1b]777;other;a;b\x07')).toEqual([]); + expect(notified('\x1b]777;notify;only a title\x07')).toEqual([]); + }); + + test('a sequence split across writes is reported when it ends', () => { + const t = term(); + t.write('\x1b]777;notify;Do'); + expect(t.readEffects().notifications).toEqual([]); + t.write('ne;all green\x07\x1b]9;second\x07'); + expect(t.readEffects().notifications).toEqual([ + { title: 'Done', body: 'all green' }, + { title: '', body: 'second' }, + ]); + t.free(); + }); + + test('with desktopNotifications off, nothing is reported', () => { + const off = { ...THEME, desktopNotifications: false }; + expect(notified('\x1b]9;Build done\x07', off)).toEqual([]); + expect(notified('\x1b]777;notify;t;b\x07', off)).toEqual([]); + }); +}); + describe('official engine: memory growth', () => { test('reads correct cells and replies after the wasm memory has grown', () => { const t = term(120, 40, { scrollbackLines: 0 }); diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 4020376..a13cf50 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Ghostty } from './ghostty-web/ghostty'; +import { Ghostty, type TerminalEffects } from './ghostty-web/ghostty'; import { InputHandler } from './ghostty-web/input-handler'; import { LinkDetector } from './ghostty-web/link-detector'; import { OSC8LinkProvider } from './ghostty-web/providers/osc8-link-provider'; @@ -635,10 +635,11 @@ describe('Terminal bell and title', () => { // (ADR 047). test('a bell and a title reported by readEffects reach onBell and onTitleChange', () => { const term = new Terminal({ ghostty: {} as never }); - let effects: { bell: boolean; title: string | null; clipboard: string | null } = { + let effects: TerminalEffects = { bell: true, title: 'vim', clipboard: null, + notifications: [], }; (term as unknown as { wasmTerm: object }).wasmTerm = { write: () => {}, @@ -647,7 +648,7 @@ describe('Terminal bell and title', () => { getMode: () => false, readEffects: () => { const e = effects; - effects = { bell: false, title: null, clipboard: null }; + effects = { bell: false, title: null, clipboard: null, notifications: [] }; return e; }, }; @@ -814,6 +815,39 @@ describe('Terminal clipboard writes', () => { }); }); +describe('Terminal desktop notifications', () => { + // OSC 9 and OSC 777 come from the engine's readEffects and reach the embedder through + // onDesktopNotification (ADR 056). + test('OSC 9 and OSC 777 output fire onDesktopNotification, each once, in order', async () => { + const ghostty = await Ghostty.load(WASM); + const term = new Terminal({ ghostty: {} as never }); + (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); + const write = (data: string) => + (term as unknown as { writeInternal(data: string): void }).writeInternal(data); + const shown: { title: string; body: string }[] = []; + term.onDesktopNotification((n) => shown.push(n)); + + write('\x1b]9;Build done\x07\x1b]777;notify;Claude Code;Needs your input\x1b\\'); + expect(shown).toEqual([ + { title: '', body: 'Build done' }, + { title: 'Claude Code', body: 'Needs your input' }, + ]); + write('plain output'); + expect(shown).toHaveLength(2); + }); + + test('passes desktopNotifications to the engine, on by default', () => { + const wasmConfig = (options: object) => + ( + new Terminal({ ghostty: {} as never, ...options }) as unknown as { + buildWasmConfig(): GhosttyTerminalConfig; + } + ).buildWasmConfig(); + expect(wasmConfig({}).desktopNotifications).toBe(true); + expect(wasmConfig({ desktopNotifications: false }).desktopNotifications).toBe(false); + }); +}); + describe('Terminal input and the selection', () => { // Keys and text clear the selection; mouse reports leave it, so a Shift selection in an // app with any-motion mode 1003 survives the next hover or click (ADR 051). From ff736f75daea15de109d805fbebdfe343f195aa0 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:05:34 -0400 Subject: [PATCH 122/295] refactor(client): move the replay's end out of TabTitle into ReplayWindow, for notifications too --- src/client/index.ts | 12 ++++-- src/client/replay.test.ts | 58 +++++++++++++++++++++++++++ src/client/replay.ts | 54 +++++++++++++++++++++++++ src/client/tab-title.test.ts | 78 ++++++------------------------------ src/client/tab-title.ts | 58 +++++---------------------- 5 files changed, 143 insertions(+), 117 deletions(-) create mode 100644 src/client/replay.test.ts create mode 100644 src/client/replay.ts diff --git a/src/client/index.ts b/src/client/index.ts index 809f14d..44dde55 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,6 +6,7 @@ import { FindBar } from './find-bar'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { nonTextPaste } from './paste'; +import { ReplayWindow } from './replay'; import { isMacPlatform, shortcutFor } from './shortcuts'; import { SmoothScroll } from './smooth-scroll'; import { TabTitle } from './tab-title'; @@ -71,9 +72,12 @@ const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); +// Whether the output being parsed is the replay sent on connect (ADR 053) +const replay = new ReplayWindow(); + // `<title> | webtty` from the program's OSC 0/2 title, or the session id, with a 🔔 // after a bell until the terminal is attended; replayed bells are ignored (ADR 053). -const tabTitle = new TabTitle(sessionId, config.bellTitle, (title) => { +const tabTitle = new TabTitle(sessionId, config.bellTitle, replay, (title) => { document.title = title; }); @@ -247,7 +251,7 @@ function connect(): void { // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) clipboardWriter.connected(); // ... and ignore its bells until the replay's last piece (ADR 053) - tabTitle.connected(); + replay.connected(); const ack = new OutputAck((message) => { if (socket.readyState === WebSocket.OPEN) socket.send(message); }); @@ -270,9 +274,9 @@ function connect(): void { ws.onmessage = (event: MessageEvent<string>) => { // term.write() parses synchronously, so the output has been parsed once feed() // returns: acknowledging it now lets a slow tab slow the server down. - tabTitle.output(event.data.length, performance.now()); + replay.output(event.data.length, performance.now()); graphics.feed(event.data); - tabTitle.parsed(performance.now()); + replay.parsed(performance.now()); ack.parsed(event.data.length); smoothScroll.onOutput(); }; diff --git a/src/client/replay.test.ts b/src/client/replay.test.ts new file mode 100644 index 0000000..8fa2f5b --- /dev/null +++ b/src/client/replay.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_FLOW_LIMITS } from '../server/flow'; +import { REPLAY_PIECE, REPLAY_QUIET_MS, ReplayWindow } from './replay'; + +describe('ReplayWindow', () => { + test('is replay from the connect through its last piece', () => { + const replay = new ReplayWindow(); + expect(replay.active).toBe(false); + replay.connected(); + // Full pieces, the second one short of a surrogate pair, then the shorter last one. + for (const chars of [REPLAY_PIECE, REPLAY_PIECE - 1, 10]) { + replay.output(chars, 0); + expect(replay.active).toBe(true); + replay.parsed(0); + } + // The next message is live output, however soon it comes. + replay.output(REPLAY_PIECE, 0); + expect(replay.active).toBe(false); + }); + + test('a replay ending on a full piece is over when the output pauses after it', () => { + const replay = new ReplayWindow(); + replay.connected(); + replay.output(REPLAY_PIECE, 0); + // Slow to parse: the pause counts from the end of the parse. + replay.parsed(REPLAY_QUIET_MS); + replay.output(10, REPLAY_QUIET_MS * 2 - 1); + expect(replay.active).toBe(true); + replay.parsed(REPLAY_QUIET_MS * 2 - 1); + // The short message ended it too: the next is live, as is one after a pause. + replay.output(10, REPLAY_QUIET_MS * 2); + expect(replay.active).toBe(false); + + const other = new ReplayWindow(); + other.connected(); + other.output(REPLAY_PIECE, 0); + other.parsed(0); + other.output(10, REPLAY_QUIET_MS); + expect(other.active).toBe(false); + }); + + test('a reconnect replays again', () => { + const replay = new ReplayWindow(); + replay.connected(); + replay.output(100, 0); + replay.parsed(0); + replay.output(10, 0); + expect(replay.active).toBe(false); + replay.connected(); + // The first message is the replay, however long after the connect it arrives. + replay.output(10, REPLAY_QUIET_MS * 10); + expect(replay.active).toBe(true); + }); + + test("the replay's piece size is the server's", () => { + expect(REPLAY_PIECE).toBe(DEFAULT_FLOW_LIMITS.chunk); + }); +}); diff --git a/src/client/replay.ts b/src/client/replay.ts new file mode 100644 index 0000000..c3e3ede --- /dev/null +++ b/src/client/replay.ts @@ -0,0 +1,54 @@ +/** + * The size, in characters, of the pieces the server sends the replay in (its + * `FlowLimits.chunk`, ADR 048): every piece but the last is this long, or one shorter + * where it would split a surrogate pair. A shorter message ends the replay (ADR 053). + */ +export const REPLAY_PIECE = 64 * 1024; + +/** + * Output quiet for this long, after the previous message was parsed, also ends the + * replay: for a replay whose last piece is full length (ADR 053). + */ +export const REPLAY_QUIET_MS = 1000; + +/** + * Whether the output being parsed is the replay (ADR 053). The server replays the + * session's recent output on every connect; a bell or a notification in it is not a new + * one (ADR 056). Output is replay from each connect through the first message shorter + * than {@link REPLAY_PIECE}, the replay's last piece, or until the output pauses for + * {@link REPLAY_QUIET_MS}. + */ +export class ReplayWindow { + private replaying = false; + private lastParsed: number | null = null; + private lastWasTail = false; + + /** Whether the message being parsed is replay. */ + get active(): boolean { + return this.replaying; + } + + /** A connection opened: the output that follows starts with the replay. */ + connected(): void { + this.replaying = true; + this.lastParsed = null; + this.lastWasTail = false; + } + + /** A message of `chars` characters arrived at `now` (ms) and is about to be parsed. */ + output(chars: number, now: number): void { + if ( + this.replaying && + this.lastParsed !== null && + (this.lastWasTail || now - this.lastParsed >= REPLAY_QUIET_MS) + ) { + this.replaying = false; + } + this.lastWasTail = chars < REPLAY_PIECE - 1; + } + + /** The message passed to {@link output} was parsed at `now` (ms). */ + parsed(now: number): void { + this.lastParsed = now; + } +} diff --git a/src/client/tab-title.test.ts b/src/client/tab-title.test.ts index 0fb879a..a194b6b 100644 --- a/src/client/tab-title.test.ts +++ b/src/client/tab-title.test.ts @@ -1,35 +1,28 @@ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_FLOW_LIMITS } from '../server/flow'; -import { REPLAY_PIECE, REPLAY_QUIET_MS, TabTitle } from './tab-title'; +import { TabTitle } from './tab-title'; function setup(bellTitle = true) { const titles: string[] = []; - const tab = new TabTitle('main', bellTitle, (t) => titles.push(t)); + const replay = { active: false }; + const tab = new TabTitle('main', bellTitle, replay, (t) => titles.push(t)); const last = () => titles[titles.length - 1]; - return { tab, titles, last }; -} - -/** Connects and parses a one-message replay, then a live message arrives. */ -function afterReplay(tab: TabTitle): void { - tab.connected(); - tab.output(100, 0); - tab.parsed(0); - tab.output(10, 0); + return { tab, replay, titles, last }; } describe('TabTitle', () => { test("shows the program's title, or the session id while it has none", () => { const { tab, last } = setup(); expect(last()).toBe('main | webtty'); + expect(tab.name).toBe('main'); tab.title('vim README.md'); expect(last()).toBe('vim README.md | webtty'); + expect(tab.name).toBe('vim README.md'); tab.title(''); expect(last()).toBe('main | webtty'); }); test('a bell marks the title until the terminal is attended', () => { const { tab, titles, last } = setup(); - afterReplay(tab); tab.title('make'); tab.bell(); expect(last()).toBe('🔔 make | webtty'); @@ -39,6 +32,7 @@ describe('TabTitle', () => { tab.bell(); expect(titles).toHaveLength(count); expect(last()).toBe('🔔 done | webtty'); + expect(tab.name).toBe('done'); tab.attended(); expect(last()).toBe('done | webtty'); // Attending again changes nothing. @@ -46,71 +40,25 @@ describe('TabTitle', () => { expect(titles).toHaveLength(count + 1); }); - test('ignores the bells of the replay, through its last piece', () => { - const { tab, last } = setup(); - tab.connected(); - // Full pieces, the second one short of a surrogate pair, then the shorter last one. - for (const chars of [REPLAY_PIECE, REPLAY_PIECE - 1, 10]) { - tab.output(chars, 0); - tab.bell(); - tab.parsed(0); - } - expect(last()).toBe('main | webtty'); - // The next message is live output, however soon it comes. - tab.output(REPLAY_PIECE, 0); - tab.bell(); - expect(last()).toBe('🔔 main | webtty'); - }); - - test('a replay ending on a full piece is over when the output pauses after it', () => { - const { tab, last } = setup(); - tab.connected(); - tab.output(REPLAY_PIECE, 0); - // Slow to parse: the pause counts from the end of the parse. - tab.parsed(REPLAY_QUIET_MS); - tab.output(10, REPLAY_QUIET_MS * 2 - 1); + test('ignores bells while the replay is active', () => { + const { tab, replay, last } = setup(); + replay.active = true; tab.bell(); - tab.parsed(REPLAY_QUIET_MS * 2 - 1); expect(last()).toBe('main | webtty'); - // The short message ended it too: the next is live, as is one after a pause. - tab.output(10, REPLAY_QUIET_MS * 2); + replay.active = false; tab.bell(); expect(last()).toBe('🔔 main | webtty'); - - const other = setup(); - other.tab.connected(); - other.tab.output(REPLAY_PIECE, 0); - other.tab.parsed(0); - other.tab.output(10, REPLAY_QUIET_MS); - other.tab.bell(); - expect(other.last()).toBe('🔔 main | webtty'); - }); - - test('a reconnect replays again, and its bells are ignored', () => { - const { tab, last } = setup(); - afterReplay(tab); - tab.connected(); - // The first message is the replay, however long after the connect it arrives. - tab.output(10, REPLAY_QUIET_MS * 10); - tab.bell(); - expect(last()).toBe('main | webtty'); - }); - - test("the replay's piece size is the server's", () => { - expect(REPLAY_PIECE).toBe(DEFAULT_FLOW_LIMITS.chunk); }); test('applies titles from the replay', () => { - const { tab, last } = setup(); - tab.connected(); - tab.output(10, 0); + const { tab, replay, last } = setup(); + replay.active = true; tab.title('htop'); expect(last()).toBe('htop | webtty'); }); test('with bellTitle off, a bell changes nothing', () => { const { tab, titles } = setup(false); - afterReplay(tab); tab.bell(); expect(titles).toEqual(['main | webtty']); }); diff --git a/src/client/tab-title.ts b/src/client/tab-title.ts index eef8b74..5082b16 100644 --- a/src/client/tab-title.ts +++ b/src/client/tab-title.ts @@ -1,16 +1,3 @@ -/** - * The size, in characters, of the pieces the server sends the replay in (its - * `FlowLimits.chunk`, ADR 048): every piece but the last is this long, or one shorter - * where it would split a surrogate pair. A shorter message ends the replay (ADR 053). - */ -export const REPLAY_PIECE = 64 * 1024; - -/** - * Output quiet for this long, after the previous message was parsed, also ends the - * replay: for a replay whose last piece is full length (ADR 053). - */ -export const REPLAY_QUIET_MS = 1000; - const BELL_MARK = '🔔 '; /** @@ -18,27 +5,27 @@ const BELL_MARK = '🔔 '; * set none, as `<title> | webtty`, with a 🔔 in front after a bell until the terminal * gains focus or a key is pressed, as Ghostty's `bell-features = title` does. * - * The server replays the session's recent output on every connect. A title in it is - * applied (it is the title the program last set), but a bell in it is not a new bell: - * bells are ignored from each connect through the first message shorter than - * {@link REPLAY_PIECE}, the replay's last piece, or until the output pauses for - * {@link REPLAY_QUIET_MS}. + * A title in the replay is applied (it is the title the program last set), but a bell + * in it is not a new bell: bells are ignored while `replay` is active. */ export class TabTitle { private appTitle = ''; private ringing = false; - private replaying = false; - private lastParsed: number | null = null; - private lastWasTail = false; constructor( private readonly sessionId: string, private readonly bellTitle: boolean, + private readonly replay: { readonly active: boolean }, private readonly set: (title: string) => void, ) { this.update(); } + /** The program's title, or the session id while it has set none. */ + get name(): string { + return this.appTitle || this.sessionId; + } + /** The program set its title (`''` for none). */ title(title: string): void { this.appTitle = title; @@ -47,7 +34,7 @@ export class TabTitle { /** The program rang the bell. */ bell(): void { - if (!this.bellTitle || this.replaying || this.ringing) return; + if (!this.bellTitle || this.replay.active || this.ringing) return; this.ringing = true; this.update(); } @@ -59,32 +46,7 @@ export class TabTitle { this.update(); } - /** A connection opened: the output that follows starts with the replay. */ - connected(): void { - this.replaying = true; - this.lastParsed = null; - this.lastWasTail = false; - } - - /** A message of `chars` characters arrived at `now` (ms) and is about to be parsed. */ - output(chars: number, now: number): void { - if ( - this.replaying && - this.lastParsed !== null && - (this.lastWasTail || now - this.lastParsed >= REPLAY_QUIET_MS) - ) { - this.replaying = false; - } - this.lastWasTail = chars < REPLAY_PIECE - 1; - } - - /** The message passed to {@link output} was parsed at `now` (ms). */ - parsed(now: number): void { - this.lastParsed = now; - } - private update(): void { - const name = this.appTitle || this.sessionId; - this.set(`${this.ringing ? BELL_MARK : ''}${name} | webtty`); + this.set(`${this.ringing ? BELL_MARK : ''}${this.name} | webtty`); } } From 20adecabdba8fe6360813ca2a972ec1ce6dbf0f1 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:08:57 -0400 Subject: [PATCH 123/295] feat(client): show OSC 9 and OSC 777 as browser notifications while the terminal is unfocused, replayed ones ignored --- src/client/index.ts | 26 ++++- src/client/notifications.test.ts | 176 +++++++++++++++++++++++++++++++ src/client/notifications.ts | 147 ++++++++++++++++++++++++++ src/client/tab-title.ts | 2 +- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/client/notifications.test.ts create mode 100644 src/client/notifications.ts diff --git a/src/client/index.ts b/src/client/index.ts index 44dde55..35d6ea9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,6 +5,7 @@ import { EdgeExtender } from './edge'; import { FindBar } from './find-bar'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; +import { browserNotifications, DesktopNotifier } from './notifications'; import { nonTextPaste } from './paste'; import { ReplayWindow } from './replay'; import { isMacPlatform, shortcutFor } from './shortcuts'; @@ -63,6 +64,7 @@ interface ClientConfig { rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none'; clipboardWrite: boolean; bellTitle: boolean; + desktopNotifications: boolean; mouseScrollSpeed: number; padding: number; keyboardBindings: KeyboardBinding[]; @@ -109,6 +111,8 @@ const term = new Terminal({ rightClickBehavior: config.rightClickBehavior, // Programs may write the clipboard with OSC 52; off, Ghostty ignores it (ADR 052) clipboardWrite: config.clipboardWrite, + // Programs may ask for desktop notifications with OSC 9 / OSC 777 (ADR 056) + desktopNotifications: config.desktopNotifications, theme: terminalTheme, }); @@ -242,6 +246,25 @@ term.onBell(() => tabTitle.bell()); container.addEventListener('focusin', () => tabTitle.attended()); window.addEventListener('keydown', () => tabTitle.attended(), { capture: true }); +// Desktop notifications (OSC 9, OSC 777), shown while the terminal is not focused, as +// Ghostty on macOS does; the bell mark without permission. Permission is asked on the +// next key or click after the first one, a gesture browsers accept (ADR 056). +const notifier = new DesktopNotifier(browserNotifications(), sessionId, replay, { + focused: () => document.hasFocus() && container.contains(document.activeElement), + name: () => tabTitle.name, + mark: () => tabTitle.bell(), + focus: () => { + window.focus(); + term.focus(); + }, +}); +term.onDesktopNotification((n) => notifier.notify(n, performance.now())); +for (const type of ['keydown', 'mousedown'] as const) { + window.addEventListener(type, () => notifier.userInput(), { capture: true }); +} +container.addEventListener('focusin', () => notifier.attended()); +window.addEventListener('keydown', () => notifier.attended(), { capture: true }); + function connect(): void { // flow=1: this client acknowledges output as it parses it, so the server paces it // (ADR 048). @@ -250,7 +273,8 @@ function connect(): void { ws = socket; // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) clipboardWriter.connected(); - // ... and ignore its bells until the replay's last piece (ADR 053) + // ... and ignore its bells and notifications until the replay's last piece (ADR 053, + // ADR 056) replay.connected(); const ack = new OutputAck((message) => { if (socket.readyState === WebSocket.OPEN) socket.send(message); diff --git a/src/client/notifications.test.ts b/src/client/notifications.test.ts new file mode 100644 index 0000000..40f387d --- /dev/null +++ b/src/client/notifications.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test'; +import { + DesktopNotifier, + NOTIFY_INTERVAL_MS, + NOTIFY_REPEAT_MS, + type NotificationApi, +} from './notifications'; + +interface Shown { + title: string; + body: string; + tag: string; + closed: boolean; + click: () => void; +} + +function setup( + options: { + permission?: NotificationPermission | 'none'; + focused?: boolean; + throws?: boolean; + } = {}, +) { + let permission = options.permission ?? 'granted'; + const shown: Shown[] = []; + const requests: number[] = []; + const logs: string[] = []; + const api: NotificationApi | undefined = + permission === 'none' + ? undefined + : { + permission: () => permission as NotificationPermission, + requestPermission: async () => { + requests.push(1); + permission = 'granted'; + return 'granted'; + }, + show: (title, { body, tag }, onClick) => { + if (options.throws) throw new TypeError('Illegal constructor.'); + const n: Shown = { title, body, tag, closed: false, click: onClick }; + shown.push(n); + return { + close: () => { + n.closed = true; + }, + }; + }, + }; + const state = { focused: options.focused ?? false, marks: 0, focuses: 0 }; + const replay = { active: false }; + const notifier = new DesktopNotifier( + api, + 'main', + replay, + { + focused: () => state.focused, + name: () => 'vim', + mark: () => state.marks++, + focus: () => state.focuses++, + }, + (m) => logs.push(m), + ); + return { notifier, shown, requests, logs, state, replay }; +} + +describe('DesktopNotifier', () => { + test('shows a notification as plain text while the terminal is not focused', () => { + const { notifier, shown, state } = setup(); + notifier.notify({ title: 'Build', body: '<b>passed</b>' }, 0); + expect(shown).toHaveLength(1); + expect(shown[0]).toMatchObject({ title: 'Build', body: '<b>passed</b>' }); + expect(state.marks).toBe(0); + }); + + test("an OSC 9 notification, with no title, takes the tab's name", () => { + const { notifier, shown } = setup(); + notifier.notify({ title: '', body: 'done' }, 0); + expect(shown[0]).toMatchObject({ title: 'vim', body: 'done' }); + }); + + test('tags it with the session, title and body, so another tab replaces it', () => { + const { notifier, shown } = setup(); + notifier.notify({ title: 't', body: 'b' }, 0); + expect(shown[0].tag).toBe('main\0t\0b'); + }); + + test('shows nothing while the terminal is focused, as Ghostty on macOS', () => { + const { notifier, shown, state } = setup({ focused: true }); + notifier.notify({ title: 't', body: 'b' }, 0); + expect(shown).toHaveLength(0); + expect(state.marks).toBe(0); + }); + + test('ignores notifications in the replay', () => { + const { notifier, shown, requests, state, replay } = setup({ permission: 'default' }); + replay.active = true; + notifier.notify({ title: 't', body: 'b' }, 0); + notifier.userInput(); + expect(shown).toHaveLength(0); + expect(state.marks).toBe(0); + // Nor does a replayed one arm the permission prompt. + replay.active = false; + notifier.userInput(); + expect(requests).toHaveLength(0); + }); + + test('one a second, and the same one once in five seconds', () => { + const { notifier, shown } = setup(); + notifier.notify({ title: '', body: 'a' }, 0); + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS - 1); + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS); + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS * 2); + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS + NOTIFY_REPEAT_MS); + expect(shown.map((n) => n.body)).toEqual(['a', 'b', 'b']); + }); + + test('a click focuses the terminal and closes it; attending closes the rest', () => { + const { notifier, shown, state } = setup(); + notifier.notify({ title: '', body: 'a' }, 0); + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS); + shown[0].click(); + expect(state.focuses).toBe(1); + expect(shown.map((n) => n.closed)).toEqual([true, false]); + notifier.attended(); + expect(shown.map((n) => n.closed)).toEqual([true, true]); + }); + + test('asks for permission on the next input after the first notification, once', async () => { + const { notifier, shown, requests, state } = setup({ permission: 'default' }); + // Never before a notification. + notifier.userInput(); + expect(requests).toHaveLength(0); + notifier.notify({ title: '', body: 'a' }, 0); + // Undecided: the tab title is marked instead. + expect(shown).toHaveLength(0); + expect(state.marks).toBe(1); + notifier.userInput(); + notifier.userInput(); + expect(requests).toHaveLength(1); + await Promise.resolve(); + // Granted: the next one shows. + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS); + expect(shown.map((n) => n.body)).toEqual(['b']); + }); + + test('a notification while focused arms the prompt too', () => { + const { notifier, requests } = setup({ permission: 'default', focused: true }); + notifier.notify({ title: '', body: 'a' }, 0); + notifier.userInput(); + expect(requests).toHaveLength(1); + }); + + test('denied: never asks, and marks the tab title', () => { + const { notifier, requests, shown, state, logs } = setup({ permission: 'denied' }); + notifier.notify({ title: '', body: 'a' }, 0); + notifier.userInput(); + notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS); + expect(requests).toHaveLength(0); + expect(shown).toHaveLength(0); + expect(state.marks).toBe(2); + expect(logs).toHaveLength(1); + }); + + test('no Notification API, or one that throws: marks the tab title', () => { + const none = setup({ permission: 'none' }); + none.notifier.notify({ title: '', body: 'a' }, 0); + none.notifier.userInput(); + expect(none.state.marks).toBe(1); + expect(none.logs).toHaveLength(1); + + const throws = setup({ throws: true }); + throws.notifier.notify({ title: '', body: 'a' }, 0); + expect(throws.state.marks).toBe(1); + expect(throws.logs[0]).toContain('Illegal constructor.'); + }); +}); diff --git a/src/client/notifications.ts b/src/client/notifications.ts new file mode 100644 index 0000000..09c760e --- /dev/null +++ b/src/client/notifications.ts @@ -0,0 +1,147 @@ +/** A desktop notification the program asked for (OSC 9: no title; OSC 777). */ +export interface NotificationRequest { + title: string; + body: string; +} + +/** A notification on screen. */ +export interface ShownNotification { + close(): void; +} + +/** The part of the browser's Notification API the notifier uses. */ +export interface NotificationApi { + permission(): NotificationPermission; + requestPermission(): Promise<NotificationPermission>; + /** Shows a notification as plain text; throws where the browser cannot. */ + show( + title: string, + options: { body: string; tag: string }, + onClick: () => void, + ): ShownNotification; +} + +/** What the notifier needs from the page. */ +export interface NotifierHost { + /** Whether the terminal has focus, in a focused window (ADR 053). */ + focused(): boolean; + /** The tab's name: the program's title, or the session id. */ + name(): string; + /** Marks the tab title, as a bell does: the fallback. */ + mark(): void; + /** Brings the window and the terminal forward, for a click on a notification. */ + focus(): void; +} + +/** At most one notification a second, as Ghostty does. */ +export const NOTIFY_INTERVAL_MS = 1000; + +/** The same title and body at most once in five seconds, as Ghostty does. */ +export const NOTIFY_REPEAT_MS = 5000; + +/** The browser's Notification API, or undefined where there is none (http, iOS Safari). */ +export function browserNotifications(): NotificationApi | undefined { + if (typeof Notification === 'undefined') return undefined; + return { + permission: () => Notification.permission, + requestPermission: () => Notification.requestPermission(), + show: (title, options, onClick) => { + const notification = new Notification(title, options); + notification.onclick = onClick; + return notification; + }, + }; +} + +/** + * Shows a program's desktop notifications (OSC 9, OSC 777) as Ghostty on macOS does + * (ADR 056): not for the replay, at most one a second and the same one once in five + * seconds, and only while the terminal is not focused. Without permission, or without + * the API, the tab title is marked as for a bell instead. + * + * Permission is asked on the next key or click in the tab after the first + * notification while it is undecided, once per page load, never on load: browsers + * want a user gesture for the prompt. + */ +export class DesktopNotifier { + private last: { at: number; key: string } | null = null; + private askOnInput = false; + private asked = false; + private readonly shown = new Set<ShownNotification>(); + private readonly logged = new Set<string>(); + + constructor( + private readonly api: NotificationApi | undefined, + private readonly sessionId: string, + private readonly replay: { readonly active: boolean }, + private readonly host: NotifierHost, + private readonly log: (message: string) => void = (message) => console.warn(message), + ) {} + + /** The program asked for a notification at `now` (ms). */ + notify(request: NotificationRequest, now: number): void { + if (this.replay.active) return; + if (!this.asked && this.api?.permission() === 'default') this.askOnInput = true; + const key = `${request.title}\0${request.body}`; + if (this.last) { + const since = now - this.last.at; + if (since < NOTIFY_INTERVAL_MS) return; + if (key === this.last.key && since < NOTIFY_REPEAT_MS) return; + } + this.last = { at: now, key }; + if (this.host.focused()) return; + if (!this.show(request.title || this.host.name(), request.body, key)) this.host.mark(); + } + + /** The user pressed a key or clicked in this tab: a gesture to ask permission with. */ + userInput(): void { + if (!this.askOnInput || !this.api) return; + this.askOnInput = false; + this.asked = true; + if (this.api.permission() !== 'default') return; + this.api.requestPermission().catch(() => {}); + } + + /** The terminal gained focus or got a key: its notifications have been seen. */ + attended(): void { + for (const notification of this.shown) notification.close(); + this.shown.clear(); + } + + private show(title: string, body: string, key: string): boolean { + if (!this.api) { + this.once( + 'api', + 'webtty: desktop notification shown in the tab title: no Notification API (needs https or localhost)', + ); + return false; + } + if (this.api.permission() !== 'granted') { + this.once( + 'permission', + 'webtty: desktop notification shown in the tab title: notifications are not allowed for this page', + ); + return false; + } + try { + // The tag replaces rather than stacks the same notification from another tab + const notification = this.api.show(title, { body, tag: `${this.sessionId}\0${key}` }, () => { + this.host.focus(); + notification.close(); + this.shown.delete(notification); + }); + this.shown.add(notification); + return true; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + this.once('refused', `webtty: the browser refused a desktop notification: ${reason}`); + return false; + } + } + + private once(key: string, message: string): void { + if (this.logged.has(key)) return; + this.logged.add(key); + this.log(message); + } +} diff --git a/src/client/tab-title.ts b/src/client/tab-title.ts index 5082b16..5c877ae 100644 --- a/src/client/tab-title.ts +++ b/src/client/tab-title.ts @@ -32,7 +32,7 @@ export class TabTitle { this.update(); } - /** The program rang the bell. */ + /** The program rang the bell, or a notification could not be shown (ADR 056). */ bell(): void { if (!this.bellTitle || this.replay.active || this.ringing) return; this.ringing = true; From 5cfb23ed8c369ba50a8a6023b33b1ff095bd1b1c Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:10:00 -0400 Subject: [PATCH 124/295] docs(client): record desktop notifications and desktopNotifications in the specs --- docs/specs/client.md | 26 +++++++++++++++++++++++++- docs/specs/config.md | 5 ++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index d757e88..f15fc3a 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -74,6 +74,7 @@ The vendored TS is excluded from Biome so it stays byte-identical to upstream un theme, copyOnSelect, rightClickBehavior, clipboardWrite, // Terminal option: report OSC 52 clipboard writes (ADR 052) bellTitle, // TabTitle: 🔔 in the tab title after a bell (ADR 053) + desktopNotifications, // Terminal option: report OSC 9 / OSC 777 notifications (ADR 056) mouseScrollSpeed, // Terminal option: SGR wheel ticks per line/page event when the app tracks the mouse padding, // px around the canvas, used by fit(); coloured by theme.padding or theme.background keyboardBindings // used by the keydown capture handler, not passed to Terminal constructor @@ -240,7 +241,29 @@ Programs hear focus changes, name the tab and ring the bell as in Ghostty ([ADR - **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Each socket that opens with the mode still on (a reconnect without a reset) sends the current state too (`resyncFocus`), since reports while it was down were dropped. Nothing is sent with `disableStdin`. - **Title.** `TabTitle` (`tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. -- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. +- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. `ReplayWindow` (`replay.ts`) keeps that state, fed by `index.ts` around each message, for the bell and for desktop notifications. + +## Desktop Notifications + +Programs ask for desktop notifications with OSC 9 (`ESC ] 9 ; <body> BEL`, iTerm2) and OSC 777 (`ESC ] 777 ; notify ; <title> ; <body> BEL`, rxvt), as Ghostty shows them ([ADR 056](../adrs/056.client.desktop-notifications.md)). Ghostty's parser tells them apart from ConEmu's `OSC 9 ; <n>` forms and calls the binding's `DESKTOP_NOTIFICATION` callback, installed only while `desktopNotifications` is on: + +| Sequence | Result | +|---|---| +| `9;<text>` | a notification with no title, body `<text>` (semicolons included) | +| `777;notify;<title>;<body>` | title and body; the body may hold `;`, the title may be empty | +| `9;4;…` (progress), `9;1;…` to `9;12` (ConEmu) | nothing | +| `9;4`, `9;42 tests passed` (not a full ConEmu form) | a notification, as in Ghostty | +| `777;<other>;…`, `777;notify;<title>` (no body) | nothing | + +`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and `index.ts` passes it to `DesktopNotifier` (`notifications.ts`), which, as Ghostty on macOS: + +- drops it while `ReplayWindow` says the output is replay; +- shows at most one a second, and the same title and body at most once in five seconds; +- shows nothing while the terminal has focus (window focused, keyboard focus in the container, as for mode 1004); +- otherwise, with permission granted, shows `new Notification(title, { body, tag })`: an OSC 9 notification takes the tab's name (the program's title, or the session id) as its title; the tag (session, title, body) makes a second tab on the session replace rather than stack it. Text is passed as plain text. A click focuses the window and the terminal; the terminal gaining focus or a key closes every notification it showed; +- otherwise (permission not granted, no API over http or on iOS Safari, a constructor that throws as on Chrome for Android) puts the bell's `🔔 ` in front of the tab title, which `bellTitle: false` turns off too. Each reason is logged to the console once. + +Permission is never asked on load. The first live notification while it is `default` (focused or not) arms the request, and the next `keydown` or `mousedown` in the tab calls `Notification.requestPermission()`, once per page load. ## Behavior Notes @@ -268,6 +291,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Copy, paste and clear keys | Ghostty's defaults for the browser's platform: Ctrl+Shift+C and Ctrl+Insert copy and Shift+Insert pastes outside macOS (Ctrl+C stays the interrupt; Ctrl+Shift+C with no selection goes to the app), Cmd+K clears the screen and scrollback on macOS (at an OSC 133 prompt with FF for the shell to redraw; the app's key on the alternate screen); `keyboardBindings` win | [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) | ✅ | | Find in the scrollback | Cmd+F (macOS) or Ctrl+Shift+F opens a find bar; libghostty-vt's search over scrollback and screen (ASCII case-insensitive, across soft wraps), every match highlighted and the selected one distinct in Ghostty's colours, Enter / Shift+Enter (Cmd+G / Cmd+Shift+G) to the next (older) and previous match with Ghostty's `k/n` count, the view jumping to a match out of view and kept on it while output streams, Escape to close; `keyboardBindings` win | [ADR 055](../adrs/055.client.find-in-scrollback.md) | ✅ | +| Desktop notifications | OSC 9 and OSC 777 `notify`, parsed by Ghostty (ConEmu's `9;<n>` forms excluded), shown as browser notifications while the terminal is not focused, one a second and the same one once in five seconds; permission asked on the next key or click after the first one; the 🔔 title mark without permission; replayed ones ignored; `desktopNotifications: false` turns them off | [ADR 056](../adrs/056.client.desktop-notifications.md) | ✅ | | Focus reports, title and bell | Mode 1004 focus reports encoded by Ghostty, sent on each change and when the mode is set; the tab title is the program's OSC 0/2 title (`<title> \| webtty`, the session id while it has none); a bell puts 🔔 in front of it until the terminal is focused or a key is pressed, and bells replayed on connect are ignored; `bellTitle: false` turns the mark off | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Cursor style | `cursorStyle` / `cursorStyleBlink` are Ghostty's default cursor; Ghostty applies DECSCUSR from the PTY (Ps 0 and RIS restore the default); mode 12 is ignored, as in Ghostty with `cursor-style-blink` set; and the renderer draws the shape and blink from its state, repainting the cursor cell so the old shape does not linger | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 015](../adrs/015.client.cursor-ghost-repaint.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index df94d85..62acc38 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -91,7 +91,7 @@ spawn PTY with fresh: shell, term, colorTerm, scrollback - **Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back. - **Hot config reload**: - `port` / `host` — locked at startup (server socket already bound; restart required). - - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `clipboardWrite`, `bellTitle`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. + - `cols`, `rows`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorStyleBlink`, `scrollback`, `scrollbackLines`, `theme`, `copyOnSelect`, `rightClickBehavior`, `clipboardWrite`, `bellTitle`, `desktopNotifications`, `mouseScrollSpeed`, `padding`, `keyboardBindings` — re-read on every tab reload. `cursorStyle` and `cursorStyleBlink` set the terminal's default cursor; apps override it at runtime via DECSCUSR (mode 12 is ignored, as in Ghostty with `cursor-style-blink` set), and DECSCUSR 0 or a reset restores it. - `shell`, `term`, `colorTerm`, `scrollback` — re-read when a new PTY is spawned (i.e. first connection to a session that has no running shell). - An already-running session is never affected mid-flight. - Historical note: ADR 008/009/012 describe an earlier config flow that used a `cursorBlink` key and different HTML injection mechanics. Those ADRs are considered historical; this spec's `cursorStyle` / `cursorStyleBlink` behavior is authoritative. @@ -117,6 +117,7 @@ All keys are optional — omit any key to use the default value. | `rightClickBehavior` | string | `"contextMenu"` | What a right-click does when the app does not take it: `"contextMenu"` shows the browser menu; `"copyPaste"` copies and clears an existing selection, otherwise shows the menu; `"paste"` pastes the clipboard as Ctrl+V does; `"none"` does nothing and shows no menu. While the app tracks the mouse, a right-click goes only to the app and no menu is shown; Shift+right-click applies this setting instead and sends nothing to the app. `"default"`, the old name of `"contextMenu"`, is still accepted; invalid values fall back to `"contextMenu"`. Windows Terminal users may prefer `"copyPaste"`. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `clipboardWrite` | boolean | `true` | Let programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL`), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH; iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522 writes too. Writes only: a query (`52;c;?`) is never answered. Only the clipboard (`c`, or an empty or unknown selection) is written; `s` and `p` are ignored, as in Ghostty on macOS. A write is held back after each connect until you press a key or click in the tab, so the replayed history never overwrites the clipboard, and the browser may still refuse it (Chrome needs focus, Firefox recent input, Safari refuses most): the console says so once. `false` ignores all program clipboard writes. See [ADR 052](../adrs/052.client.osc52-clipboard-write.md). | | `bellTitle` | boolean | `true` | When a program rings the bell (BEL), put a 🔔 in front of the tab title until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title` does; it shows in a background tab until you come back. Bells in the output replayed on reload or reconnect are ignored. The tab title itself is the program's title (OSC 0/2) as `<title> \| webtty`, or `<session> \| webtty` while it has none. `false` leaves the title alone on a bell. See [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md). | +| `desktopNotifications` | boolean | `true` | Let programs show desktop notifications with OSC 9 (`ESC ] 9 ; <body> BEL`) and OSC 777 (`ESC ] 777 ; notify ; <title> ; <body> BEL`), as Ghostty's `desktop-notifications`; ConEmu's `OSC 9 ; <n>` forms (`9;4` progress) are not notifications. They show as browser notifications while the terminal is not focused, at most one a second, and clicking one returns to the tab. The browser asks for permission on your next key or click after the first one (never on load); until it is granted, or over http other than localhost, the tab title gets the bell's 🔔 instead (off with `bellTitle: false`). Notifications in the output replayed on reload or reconnect are ignored. `false` ignores them all. See [ADR 056](../adrs/056.client.desktop-notifications.md). | | `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one scroll report (SGR 64/65 under mode 1006) per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | @@ -185,6 +186,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter "rightClickBehavior": "contextMenu", "clipboardWrite": true, "bellTitle": true, + "desktopNotifications": true, "fontSize": 13, "fontFamily": "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", @@ -229,6 +231,7 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Mouse follows app tracking | While the app tracks the mouse, drags, double clicks and right-clicks go only to the app (no selection, copy or menu), Shift to bypass; `rightClickBehavior` gains `"contextMenu"` (default, was `"default"`), `"paste"` and `"none"` | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | | Program clipboard writes | `clipboardWrite` (default `true`) lets programs write the clipboard with OSC 52 (writes only, standard clipboard only, held back until input after each connect); `false` ignores them | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Bell in the tab title | `bellTitle` (default `true`) puts 🔔 in front of the tab title after a bell until the terminal is focused or a key is pressed; replayed bells are ignored | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | +| Desktop notifications | `desktopNotifications` (default `true`) shows OSC 9 and OSC 777 notifications as browser notifications while the terminal is not focused, with the 🔔 title mark without permission; replayed ones are ignored | [ADR 056](../adrs/056.client.desktop-notifications.md) | ✅ | | Server logs | `logs: true` appends server stdout/stderr to `~/.config/webtty/server.log` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime, and Ps 0 restores it | [ADR 013](../adrs/013.client.cursor-style.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Padding | `padding` (px, default `0`) around the terminal canvas, coloured by `theme.padding` or `theme.background` | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | From 220d09fd88f75b26a204f8f6e7858ec9c9d7be5e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:14:36 -0400 Subject: [PATCH 125/295] fix(client): ask for notification permission only on an input that activates the page, not Esc Esc and browser-reserved keys give no user activation, so a requestPermission() from them is refused (Firefox, Safari) or demoted (Chrome), and the one request per page load was spent. Such an input now leaves the request armed for the next key or click; navigator.userActivation.isActive decides, and without it only Esc is passed over (ADR 056). --- docs/adrs/056.client.desktop-notifications.md | 2 +- docs/specs/client.md | 2 +- src/client/index.ts | 11 ++++-- src/client/notifications.test.ts | 39 +++++++++++++++---- src/client/notifications.ts | 30 +++++++++++--- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/docs/adrs/056.client.desktop-notifications.md b/docs/adrs/056.client.desktop-notifications.md index 0fad2af..978c375 100644 --- a/docs/adrs/056.client.desktop-notifications.md +++ b/docs/adrs/056.client.desktop-notifications.md @@ -48,7 +48,7 @@ The binding (`ghostty.ts`) installs a `DESKTOP_NOTIFICATION` callback when the t ### Ask for permission on the next input after the first notification -Never on page load. The first notification that arrives (live, focused or not) while permission is `default` arms the request, and the next `keydown` or `mousedown` in the tab calls `Notification.requestPermission()` from that event, a user gesture in every browser. It is asked once per page load: a dismissed prompt is not repeated until a reload, and `denied` is never asked again (the browser would not show it). The notification that armed it is not shown afterwards: by then the user is in the tab. Ghostty asks the system on first use too; the gesture is what the browser adds. +Never on page load. The first notification that arrives (live, focused or not) while permission is `default` arms the request, and the next `keydown` or `mousedown` in the tab that activates the page calls `Notification.requestPermission()` from that event, a user gesture in every browser. Esc and keys the browser reserves do not activate the page (HTML's activation-triggering input events), and a request from one is refused or demoted, so such a key leaves the request armed for the next: `navigator.userActivation.isActive` decides, and where it is missing only Esc is passed over. It is asked once per page load: a dismissed prompt is not repeated until a reload, and `denied` is never asked again (the browser would not show it). The notification that armed it is not shown afterwards: by then the user is in the tab. Ghostty asks the system on first use too; the gesture is what the browser adds. ### Config diff --git a/docs/specs/client.md b/docs/specs/client.md index f15fc3a..ab7ac40 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -263,7 +263,7 @@ Programs ask for desktop notifications with OSC 9 (`ESC ] 9 ; <body> BEL`, iTerm - otherwise, with permission granted, shows `new Notification(title, { body, tag })`: an OSC 9 notification takes the tab's name (the program's title, or the session id) as its title; the tag (session, title, body) makes a second tab on the session replace rather than stack it. Text is passed as plain text. A click focuses the window and the terminal; the terminal gaining focus or a key closes every notification it showed; - otherwise (permission not granted, no API over http or on iOS Safari, a constructor that throws as on Chrome for Android) puts the bell's `🔔 ` in front of the tab title, which `bellTitle: false` turns off too. Each reason is logged to the console once. -Permission is never asked on load. The first live notification while it is `default` (focused or not) arms the request, and the next `keydown` or `mousedown` in the tab calls `Notification.requestPermission()`, once per page load. +Permission is never asked on load. The first live notification while it is `default` (focused or not) arms the request, and the next `keydown` or `mousedown` in the tab that gives the page user activation (`navigator.userActivation.isActive`; without it, any but Esc) calls `Notification.requestPermission()`, once per page load. Esc, which does not, leaves the request armed. ## Behavior Notes diff --git a/src/client/index.ts b/src/client/index.ts index 35d6ea9..0549f34 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,7 +5,7 @@ import { EdgeExtender } from './edge'; import { FindBar } from './find-bar'; import { computeInsets } from './fit'; import { KittyGraphics } from './graphics'; -import { browserNotifications, DesktopNotifier } from './notifications'; +import { activates, browserNotifications, DesktopNotifier } from './notifications'; import { nonTextPaste } from './paste'; import { ReplayWindow } from './replay'; import { isMacPlatform, shortcutFor } from './shortcuts'; @@ -248,7 +248,8 @@ window.addEventListener('keydown', () => tabTitle.attended(), { capture: true }) // Desktop notifications (OSC 9, OSC 777), shown while the terminal is not focused, as // Ghostty on macOS does; the bell mark without permission. Permission is asked on the -// next key or click after the first one, a gesture browsers accept (ADR 056). +// next key or click after the first one that activates the page (not Esc), a gesture +// browsers accept (ADR 056). const notifier = new DesktopNotifier(browserNotifications(), sessionId, replay, { focused: () => document.hasFocus() && container.contains(document.activeElement), name: () => tabTitle.name, @@ -260,7 +261,11 @@ const notifier = new DesktopNotifier(browserNotifications(), sessionId, replay, }); term.onDesktopNotification((n) => notifier.notify(n, performance.now())); for (const type of ['keydown', 'mousedown'] as const) { - window.addEventListener(type, () => notifier.userInput(), { capture: true }); + window.addEventListener( + type, + (e) => notifier.userInput(activates(e instanceof KeyboardEvent ? e : {})), + { capture: true }, + ); } container.addEventListener('focusin', () => notifier.attended()); window.addEventListener('keydown', () => notifier.attended(), { capture: true }); diff --git a/src/client/notifications.test.ts b/src/client/notifications.test.ts index 40f387d..545fd54 100644 --- a/src/client/notifications.test.ts +++ b/src/client/notifications.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { + activates, DesktopNotifier, NOTIFY_INTERVAL_MS, NOTIFY_REPEAT_MS, @@ -95,12 +96,12 @@ describe('DesktopNotifier', () => { const { notifier, shown, requests, state, replay } = setup({ permission: 'default' }); replay.active = true; notifier.notify({ title: 't', body: 'b' }, 0); - notifier.userInput(); + notifier.userInput(true); expect(shown).toHaveLength(0); expect(state.marks).toBe(0); // Nor does a replayed one arm the permission prompt. replay.active = false; - notifier.userInput(); + notifier.userInput(true); expect(requests).toHaveLength(0); }); @@ -128,14 +129,14 @@ describe('DesktopNotifier', () => { test('asks for permission on the next input after the first notification, once', async () => { const { notifier, shown, requests, state } = setup({ permission: 'default' }); // Never before a notification. - notifier.userInput(); + notifier.userInput(true); expect(requests).toHaveLength(0); notifier.notify({ title: '', body: 'a' }, 0); // Undecided: the tab title is marked instead. expect(shown).toHaveLength(0); expect(state.marks).toBe(1); - notifier.userInput(); - notifier.userInput(); + notifier.userInput(true); + notifier.userInput(true); expect(requests).toHaveLength(1); await Promise.resolve(); // Granted: the next one shows. @@ -143,17 +144,26 @@ describe('DesktopNotifier', () => { expect(shown.map((n) => n.body)).toEqual(['b']); }); + test('an input that does not activate the page (Esc) leaves the request armed', () => { + const { notifier, requests } = setup({ permission: 'default' }); + notifier.notify({ title: '', body: 'a' }, 0); + notifier.userInput(false); + expect(requests).toHaveLength(0); + notifier.userInput(true); + expect(requests).toHaveLength(1); + }); + test('a notification while focused arms the prompt too', () => { const { notifier, requests } = setup({ permission: 'default', focused: true }); notifier.notify({ title: '', body: 'a' }, 0); - notifier.userInput(); + notifier.userInput(true); expect(requests).toHaveLength(1); }); test('denied: never asks, and marks the tab title', () => { const { notifier, requests, shown, state, logs } = setup({ permission: 'denied' }); notifier.notify({ title: '', body: 'a' }, 0); - notifier.userInput(); + notifier.userInput(true); notifier.notify({ title: '', body: 'b' }, NOTIFY_INTERVAL_MS); expect(requests).toHaveLength(0); expect(shown).toHaveLength(0); @@ -164,7 +174,7 @@ describe('DesktopNotifier', () => { test('no Notification API, or one that throws: marks the tab title', () => { const none = setup({ permission: 'none' }); none.notifier.notify({ title: '', body: 'a' }, 0); - none.notifier.userInput(); + none.notifier.userInput(true); expect(none.state.marks).toBe(1); expect(none.logs).toHaveLength(1); @@ -174,3 +184,16 @@ describe('DesktopNotifier', () => { expect(throws.logs[0]).toContain('Illegal constructor.'); }); }); + +describe('activates', () => { + test('follows navigator.userActivation where there is one', () => { + expect(activates({ key: 'a' }, { isActive: false })).toBe(false); + expect(activates({ key: 'Escape' }, { isActive: true })).toBe(true); + }); + + test('without it, every input but Esc', () => { + expect(activates({ key: 'Escape' }, undefined)).toBe(false); + expect(activates({ key: 'a' }, undefined)).toBe(true); + expect(activates({}, undefined)).toBe(true); + }); +}); diff --git a/src/client/notifications.ts b/src/client/notifications.ts index 09c760e..02e134f 100644 --- a/src/client/notifications.ts +++ b/src/client/notifications.ts @@ -53,15 +53,30 @@ export function browserNotifications(): NotificationApi | undefined { }; } +/** + * Whether a key or click gave the page user activation, so a permission request from + * it can prompt. Esc and keys the browser reserves do not (HTML's activation-triggering + * input events); `navigator.userActivation` knows, and without it only Esc is ruled out. + */ +export function activates( + event: { key?: string }, + activation: { readonly isActive: boolean } | undefined = typeof navigator === 'undefined' + ? undefined + : navigator.userActivation, +): boolean { + if (activation) return activation.isActive; + return event.key !== 'Escape'; +} + /** * Shows a program's desktop notifications (OSC 9, OSC 777) as Ghostty on macOS does * (ADR 056): not for the replay, at most one a second and the same one once in five * seconds, and only while the terminal is not focused. Without permission, or without * the API, the tab title is marked as for a bell instead. * - * Permission is asked on the next key or click in the tab after the first - * notification while it is undecided, once per page load, never on load: browsers - * want a user gesture for the prompt. + * Permission is asked on the next key or click in the tab that activates the page + * (not Esc) after the first notification while it is undecided, once per page load, + * never on load: browsers want a user gesture for the prompt. */ export class DesktopNotifier { private last: { at: number; key: string } | null = null; @@ -93,9 +108,12 @@ export class DesktopNotifier { if (!this.show(request.title || this.host.name(), request.body, key)) this.host.mark(); } - /** The user pressed a key or clicked in this tab: a gesture to ask permission with. */ - userInput(): void { - if (!this.askOnInput || !this.api) return; + /** + * The user pressed a key or clicked in this tab: a gesture to ask permission with if + * it `activated` the page. One that did not (Esc) leaves the request for the next. + */ + userInput(activated: boolean): void { + if (!this.askOnInput || !this.api || !activated) return; this.askOnInput = false; this.asked = true; if (this.api.permission() !== 'default') return; From e41a2162b8114a472a9ad8727f26f3304caf7cb1 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:24:18 -0400 Subject: [PATCH 126/295] docs(client): ADR 057, jump to shell prompts and select command output with OSC 133 --- ...7.client.shell-integration-prompt-jumps.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/adrs/057.client.shell-integration-prompt-jumps.md diff --git a/docs/adrs/057.client.shell-integration-prompt-jumps.md b/docs/adrs/057.client.shell-integration-prompt-jumps.md new file mode 100644 index 0000000..832804d --- /dev/null +++ b/docs/adrs/057.client.shell-integration-prompt-jumps.md @@ -0,0 +1,95 @@ +# ADR 057: client — Jump to shell prompts and select command output with OSC 133 + +**SPEC:** [client](../specs/client.md), [key-bindings](../specs/key-bindings.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +Shells mark their prompts with OSC 133 (FinalTerm's semantic prompts): `133;A` where the prompt starts, `133;B` where the command line starts, `133;C` where the command's output starts and `133;D;<status>` where it ends. webtty passes them to libghostty-vt, which already uses them for one thing: Cmd+K at a prompt (ADR 054, `cursorAtPrompt`). Nothing else reads them. + +Ghostty uses them for (`src/config/Config.zig`, `Keybinds.init`, `src/Surface.zig`, at the pinned commit `05dcf887`): + +- **`jump_to_prompt:<n>`**: "Jump the viewport forward or back by the given number of prompts. Requires shell integration." Bound by default to `ctrl+shift+arrow_up` / `ctrl+shift+arrow_down` (-1 / 1) outside macOS, and on macOS to both `super+shift+arrow_up` / `super+shift+arrow_down` and `super+arrow_up` / `super+arrow_down` ("matches Terminal.app"). None is `performable:`: the key is consumed even when there is no prompt to go to. +- **Selecting a command's output**: a triple click with Ctrl or Cmd held selects the output of the command under the pointer instead of the line (`Surface.zig`, the left-click handler's gesture behaviours: `if (mods.ctrlOrSuper()) .output else .line`; `SelectionGesture.zig` calls `Screen.selectOutput`, which yields nothing outside output). There is no default key that selects or copies the last command's output. +- **Prompt clicks** (`click_events=1`, `cl=`): a click on the command line moves the cursor there. Not taken up here. + +The pinned wasm exports both as C API: + +- `ghostty_terminal_scroll_viewport` with `GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`: "Scroll by a number of prompts relative to the viewport top. Negative values move backward and positive values move forward. Requires semantic prompt markers from the shell. Zero or no matching prompt is a no-op. If fewer prompts remain than requested, move to the last matching prompt, clamped to the active area." The new position can be read back with `GHOSTTY_TERMINAL_DATA_SCROLLBAR` (`offset`, in the row space of `GHOSTTY_SCROLL_VIEWPORT_ROW`). +- `ghostty_terminal_select_output(terminal, ref, out_selection)`: the output of the command at a grid reference, `GHOSTTY_NO_VALUE` outside output. + +webtty's viewport is its own (`viewportY`, rows up from the bottom, ADR 042); the binding keeps Ghostty's at the bottom and moves it only to read a page of scrollback (`readPage`), putting it back after. + +**Who sends the marks.** webtty spawns the configured shell (`shell`, default `$SHELL`) with no arguments and no integration. Checked through a PTY on this machine, answering the shell's device-attributes query as a terminal does: + +| Shell | OSC 133 without any setup | +|---|---| +| fish 4.9.3 | yes: `133;A;click_events=1`, `133;B`, `133;C;cmdline_url=…`, `133;D;<status>` (fish 4.0 and later emit them themselves) | +| zsh 5.9 | no | +| bash 5.3, bash 3.2 (macOS `/bin/bash`) | no | +| zsh or bash with starship 1.26 | no | +| zsh, bash 5.3 and bash 3.2 sourcing Ghostty's integration script by hand | yes | + +Ghostty injects its integration automatically (`shell-integration = detect`, the default, `src/termio/shell_integration.zig`): fish and elvish by prepending a directory to `XDG_DATA_DIRS` (fish loads its `vendor_conf.d`), zsh by pointing `ZDOTDIR` at a directory whose `.zshenv` restores the user's `ZDOTDIR` and sources the user's `.zshenv`, bash by starting `bash --posix` with `ENV` set to its script, which then loads the user's startup files itself (not for macOS's `/bin/bash` 3.2). The zsh and bash scripts carry kitty's GPLv3 (they started as copies of kitty's); webtty is MIT and ships as one npm package. Beyond the marks, the scripts do Ghostty things gated by `GHOSTTY_SHELL_FEATURES`: the cursor shape, the title, a `sudo` wrapper for Ghostty's terminfo, `ssh` wrappers that call the `ghostty` binary, and `GHOSTTY_BIN_DIR` on `PATH`. With `GHOSTTY_SHELL_FEATURES` unset they emit the marks and nothing else. + +## Decision + +### Jump to a prompt (terminal side) + +Ghostty's `jump_to_prompt`, with Ghostty's default keys for the browser's platform: + +| Platform | Key | Action | +|---|---|---| +| macOS | Cmd+↑, Cmd+Shift+↑ | to the previous prompt | +| macOS | Cmd+↓, Cmd+Shift+↓ | to the next prompt | +| others | Ctrl+Shift+↑ / Ctrl+Shift+↓ | to the previous / next prompt | + +- **The move is Ghostty's.** The binding's `promptJumpRow(top, delta)` puts Ghostty's viewport on webtty's top row (`ROW`), moves it with `DELTA_PROMPT`, reads where it landed from `SCROLLBAR`, and puts it back at the bottom, as `readPage` does. `Terminal.jumpToPrompt(delta)` scrolls webtty's view there with `scrollToLine`, as find does (ADR 055): the prompt row becomes the top row, the bottom is the furthest down, and SmoothScroll follows the jump. Up from the bottom goes to the nearest prompt above the screen's top row, as in Ghostty. +- **Always consumed**, as Ghostty's bindings are not `performable:`: without marks, on the alternate screen or past the last prompt the key does nothing, and it does not reach the app. The keys go through `shortcutFor` and the listener of ADR 054, so a `keyboardBindings` entry for the combo wins and `"chars": ""` turns one off. +- Output still pulls a scrolled view to the bottom (unchanged webtty behaviour), so a jump during streaming output holds only until the next output. + +### Select a command's output + +A triple click with Ctrl or Cmd held selects the output of the command under the pointer (`ghostty_terminal_select_output`), as in Ghostty; outside output, or without marks, it selects nothing. Like the other multi-clicks it is copied with `copyOnSelect`, and it stays the app's while the app tracks the mouse (Shift bypasses, ADR 051). There is no key for it, as in Ghostty. + +### Shell side: no injection yet + +webtty does not inject shell integration in this change: + +- fish 4, the shell this was asked for, already sends the marks with no setup. +- The zsh and bash scripts worth vendoring are GPLv3. Shipping them in the MIT package is a licensing decision for the maintainers, not one to make in passing; writing our own is a separate piece of work. +- Injection has to be done well or not at all: the scripts must ship in `dist` and be found at run time (the CLI, the npm package and a dev checkout lay files out differently), `ZDOTDIR`, `ENV` and `XDG_DATA_DIRS` must be restored before the user's files run, bash needs `--posix` and a fallback for 3.2, and the Ghostty-only features (`sudo`, `ssh-*`, `path`) must stay off, including when the webtty server itself was started from inside Ghostty and inherited `GHOSTTY_*` variables. + +Instead the specs say how to get the marks: nothing to do for fish 4; for zsh and bash, source Ghostty's integration script at the top of `.zshrc` / `.bashrc` (from a Ghostty install, or a copy of `src/shell-integration` from Ghostty's repository). With `GHOSTTY_SHELL_FEATURES` unset it only emits the marks. + +**Follow-up**: a `shellIntegration` config key (`"detect"` | `"none"`, default `"detect"` as Ghostty's `shell-integration`), injecting marks-only scripts for zsh and bash (and fish 3) by Ghostty's methods, after a decision on vendoring GPLv3 files or writing MIT ones; tested with fish, zsh and bash spawned through the server. + +### Replay + +Neither feature has side effects: nothing is sent to the PTY, the clipboard (except through a user's own triple click) or the title. The replay on connect brings its marks with it, so the prompts before a reload can be jumped to after it. + +## Consequences + +- With fish 4, or zsh and bash set up as above, Cmd+↑ / Cmd+↓ (macOS) and Ctrl+Shift+↑ / Ctrl+Shift+↓ (elsewhere) step through the commands in the scrollback, and Ctrl/Cmd+triple-click selects one command's output. +- These keys no longer reach programs: Ctrl+Shift+↑ / ↓ in an editor that uses them for selection, or Cmd+↑ / ↓ in a program that reads Super, now scroll instead, as in Ghostty. A `keyboardBindings` entry gives a combo back. +- zsh and bash users get nothing until they source an integration script; webtty does not detect or warn. +- Moving Ghostty's viewport and back may report a full redraw for the next frame, as reading a page of scrollback already can. + +## Considered Options + +- **Find the prompts in JS** by reading each row's `GHOSTTY_ROW_DATA_SEMANTIC_PROMPT` through grid references. A walk over up to 10,000 rows per key, and Ghostty's rules (continuation rows, clamping to the active area) re-implemented; `DELTA_PROMPT` is Ghostty's own. +- **`performable` jumps** that let the key through when nothing moves. Not Ghostty's, and it would make the key's effect depend on scrollback contents the user cannot see. +- **A key to copy the last command's output.** Ghostty has none; the triple click covers selecting it. +- **Inject shell integration now.** See above; recorded as the follow-up. +- **Our own minimal zsh/bash snippets in the docs.** More for users to paste and for us to keep working across prompt themes; Ghostty's scripts already do it and work when sourced by hand (checked with zsh 5.9, bash 5.3 and 3.2). + +## Related Decisions + +- [ADR 042 — Official libghostty-vt engine](042.client.official-vt-engine.md): the pinned wasm and webtty's own viewport +- [ADR 050 — Selection from libghostty-vt](050.client.selection-from-libghostty-vt.md): double and triple clicks +- [ADR 051 — Mouse tracking, selection and right-click](051.client.mouse-tracking-selection-and-right-click.md): the app's mouse comes first +- [ADR 054 — Copy, paste and clear-screen keys](054.client.copy-paste-clear-keys.md): the shortcut listener, and Cmd+K at an OSC 133 prompt +- [ADR 055 — Find in the scrollback](055.client.find-in-scrollback.md): jumping the view with `scrollToLine` From e28c6629fd8b941690d5f049062b6d6607b59c8b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:26:56 -0400 Subject: [PATCH 127/295] feat(client): move by OSC 133 prompts and select command output with libghostty-vt in the binding --- src/client/vendor/ghostty-web/ghostty.ts | 57 +++++++++++++++- src/client/vendor/official-terminal.test.ts | 74 +++++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 5f87a64..7a8fc13 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -169,6 +169,12 @@ export interface GhosttyTerminal { * without them and on the alternate screen. (ADR 054) */ cursorAtPrompt(): boolean; + /** + * Ghostty's `jump_to_prompt`: the top row (a screen row, 0 = oldest scrollback row) of + * a view whose top row is `top` after moving it by `delta` OSC 133 prompts (negative + * is up). Ghostty's own viewport is left at the bottom. (ADR 057) + */ + promptJumpRow(top: number, delta: number): number; // Scrollback, graphemes, hyperlinks getScrollbackLength(): number; @@ -184,6 +190,11 @@ export interface GhosttyTerminal { selectWord(cell: ScreenCell): ScreenRange | null; /** The line at a cell, soft wraps followed and outer whitespace left out; null when blank. */ selectLine(cell: ScreenCell): ScreenRange | null; + /** + * The output of the command at a cell, from OSC 133 marks (Ghostty's `selectOutput`); + * null outside command output or without marks. (ADR 057) + */ + selectOutput(cell: ScreenCell): ScreenRange | null; /** * The text from `start` to `end` (inclusive, in either order) as Ghostty copies it: * soft-wrapped rows joined, trailing whitespace trimmed, spacer cells skipped. @@ -309,6 +320,8 @@ interface OfficialWasmExports { ghostty_terminal_point_from_grid_ref(term: number, ref: number, tag: number, out: number): number; ghostty_terminal_select_word(term: number, options: number, outSelection: number): number; ghostty_terminal_select_line(term: number, options: number, outSelection: number): number; + /** The GhosttyGridRef struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_select_output(term: number, ref: number, outSelection: number): number; /** GhosttyTerminalSelectionFormatOptions is passed by value, i.e. by pointer on wasm32. */ ghostty_terminal_selection_format_alloc( term: number, @@ -586,6 +599,7 @@ function resolveOfficialLayout(layout: VtLayout) { ACTIVE_SCREEN: tdata('ACTIVE_SCREEN'), MOUSE_TRACKING: tdata('MOUSE_TRACKING'), CURSOR_AT_PROMPT: tdata('CURSOR_AT_PROMPT'), + SCROLLBAR: tdata('SCROLLBAR'), TITLE: tdata('TITLE'), SCROLLBACK_ROWS: tdata('SCROLLBACK_ROWS'), COLOR_PALETTE_DEFAULT: tdata('COLOR_PALETTE_DEFAULT'), @@ -694,6 +708,11 @@ function resolveOfficialLayout(layout: VtLayout) { value: layout.offset('GhosttyTerminalScrollViewport', 'value'), BOTTOM: layout.value('GhosttyTerminalScrollViewportTag', 'BOTTOM'), ROW: layout.value('GhosttyTerminalScrollViewportTag', 'ROW'), + DELTA_PROMPT: layout.value('GhosttyTerminalScrollViewportTag', 'DELTA_PROMPT'), + }, + scrollbar: { + size: layout.size('GhosttyTerminalScrollbar'), + offset: layout.offset('GhosttyTerminalScrollbar', 'offset'), }, point: { size: layout.size('GhosttyPoint'), @@ -1226,6 +1245,7 @@ export class OfficialTerminal implements GhosttyTerminal { | 'sbRows' | 'screen' | 'scroll' + | 'scrollbar' | 'point' | 'gridRef' | 'sel' @@ -1363,6 +1383,7 @@ export class OfficialTerminal implements GhosttyTerminal { sbRows: [4, 4], screen: [4, 4], scroll: [L.scroll.size, 8], + scrollbar: [L.scrollbar.size, 8], point: [L.point.size, 8], gridRef: [L.gridRef.size, 8], sel: [L.selection.size, 8], @@ -1641,6 +1662,23 @@ export class OfficialTerminal implements GhosttyTerminal { return rc === 0 && this.u8()[at.u8] !== 0; } + promptJumpRow(top: number, delta: number): number { + const at = this.at; + const S = this.L.scroll; + this.info(); + // Ghostty moves its viewport from its top row, so it is put on webtty's for the move + this.scrollViewport(S.ROW, top); + try { + this.scrollViewport(S.DELTA_PROMPT, delta); + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.SCROLLBAR, at.scrollbar); + if (rc !== 0) return top; + // The offset is a u64; a screen row fits in the low 32 bits + return this.dv().getUint32(at.scrollbar + this.L.scrollbar.offset, true); + } finally { + this.scrollViewport(S.BOTTOM, 0); + } + } + getMode(mode: number, isAnsi: boolean = false): boolean { const at = this.at; const M = this.L.mode; @@ -1718,6 +1756,18 @@ export class OfficialTerminal implements GhosttyTerminal { ); } + selectOutput(cell: ScreenCell): ScreenRange | null { + const at = this.at; + const S = this.L.selection; + this.info(); + if (!this.screenRef(cell, at.gridRef)) return null; + this.clearSelectionStruct(); + if (this.ex.ghostty_terminal_select_output(this.term, at.gridRef, at.sel) !== 0) return null; + const start = this.screenCellOf(at.sel + S.start); + const end = this.screenCellOf(at.sel + S.end); + return start && end ? { start, end } : null; + } + selectionText(start: ScreenCell, end: ScreenCell): string { const ex = this.ex; const at = this.at; @@ -2163,15 +2213,16 @@ export class OfficialTerminal implements GhosttyTerminal { if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); } - private scrollViewport(tag: number, row: number): void { + private scrollViewport(tag: number, value: number): void { const at = this.at; const S = this.L.scroll; const u8 = this.u8(); u8.fill(0, at.scroll, at.scroll + S.size); const dv = this.dv(); dv.setInt32(at.scroll + S.tag, tag, true); - // The ROW arm is a usize (u32 on wasm32); the 8-byte union is zeroed above. - dv.setUint32(at.scroll + S.value, row, true); + // The ROW arm is a usize and DELTA_PROMPT an isize (32 bits on wasm32); the union is + // zeroed above. + dv.setInt32(at.scroll + S.value, value, true); this.ex.ghostty_terminal_scroll_viewport(this.term, at.scroll); } diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/official-terminal.test.ts index b7f95a0..742d1ca 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/official-terminal.test.ts @@ -1260,6 +1260,80 @@ describe('official engine: selection (ADR 050)', () => { }); }); +describe('official engine: OSC 133 prompts (ADR 057)', () => { + /** A command as fish 4 marks it: prompt, command line, output, end. */ + const command = (name: string, lines: number) => + `\x1b]133;A\x07$ \x1b]133;B\x07${name}\r\n\x1b]133;C\x07` + + Array.from({ length: lines }, (_, i) => `${name} ${i}`).join('\r\n') + + '\r\n\x1b]133;D;0\x07'; + /** Prompts on rows 0, 7 and 14, and the current one on row 21 of 22. */ + const session = () => { + const t = term(20, 5); + t.write(`${command('one', 6)}${command('two', 6)}${command('three', 6)}\x1b]133;A\x07$ `); + return t; + }; + + test('promptJumpRow moves the top row by prompts, as Ghostty, and stops at either end', () => { + const t = session(); + expect(t.getScrollbackLength()).toBe(17); + // Up from the bottom: the nearest prompt above the top row + expect(t.promptJumpRow(17, -1)).toBe(14); + expect(t.promptJumpRow(14, -1)).toBe(7); + expect(t.promptJumpRow(17, -2)).toBe(7); + expect(t.promptJumpRow(7, -5)).toBe(0); + expect(t.promptJumpRow(0, -1)).toBe(0); + expect(t.promptJumpRow(10, -1)).toBe(7); + // Down past the last prompt in the scrollback: the bottom + expect(t.promptJumpRow(0, 1)).toBe(7); + expect(t.promptJumpRow(14, 1)).toBe(17); + expect(t.promptJumpRow(17, 1)).toBe(17); + expect(t.promptJumpRow(10, 0)).toBe(10); + t.free(); + }); + + test('promptJumpRow leaves the viewport at the bottom, so output and reads are unchanged', () => { + const t = session(); + t.promptJumpRow(17, -2); + expect(rowText(t, 4)).toBe('$'); + expect(scrollbackText(t, 7)).toBe('$ two'); + t.write('ls'); + expect(rowText(t, 4)).toBe('$ ls'); + t.free(); + }); + + test('promptJumpRow does nothing without marks or on the alternate screen', () => { + const t = term(20, 5); + t.write(Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\r\n')); + expect(t.getScrollbackLength()).toBe(25); + expect(t.promptJumpRow(25, -1)).toBe(25); + expect(t.promptJumpRow(10, 1)).toBe(10); + t.free(); + const alt = session(); + alt.write('\x1b[?1049h'); + expect(alt.promptJumpRow(0, -1)).toBe(0); + alt.free(); + }); + + test('selectOutput selects the output of the command at a cell, null elsewhere', () => { + const t = session(); + const one = { start: { col: 0, row: 1 }, end: { col: 4, row: 6 } }; + expect(t.selectOutput({ col: 0, row: 1 })).toEqual(one); + expect(t.selectOutput({ col: 3, row: 4 })).toEqual(one); + expect(t.selectionText(one.start, one.end)).toBe('one 0\none 1\none 2\none 3\none 4\none 5'); + expect(t.selectOutput({ col: 0, row: 10 })).toEqual({ + start: { col: 0, row: 8 }, + end: { col: 4, row: 13 }, + }); + // The prompt row, and a terminal with no marks + expect(t.selectOutput({ col: 0, row: 21 })).toBeNull(); + t.free(); + const plain = term(20, 5); + plain.write('a\r\nb'); + expect(plain.selectOutput({ col: 0, row: 0 })).toBeNull(); + plain.free(); + }); +}); + describe('official engine: search (ADR 055)', () => { /** Runs the search to completion and returns its matches as [col, row, col, row] tuples. */ function run(t: OfficialTerminal): number[][] { From 2a55ad3323f78d407f01613f9187b4c54858dddc Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:27:22 -0400 Subject: [PATCH 128/295] feat(client): jump the view to OSC 133 prompts, and select a command's output on Ctrl/Cmd+triple-click, as Ghostty --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/selection-manager.ts | 13 +++-- src/client/vendor/ghostty-web/terminal.ts | 13 +++++ src/client/vendor/selection-manager.test.ts | 21 ++++++++ src/client/vendor/terminal.test.ts | 52 +++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 0233edc..e5410c9 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -44,4 +44,5 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `terminal.ts`: clear screen (ADR 054). The binding adds `cursorAtPrompt` (`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT`), and `Terminal` the new `clearScreen`, Ghostty's `clear_screen` written to the terminal as VT sequences: nothing on the alternate screen (it returns false); otherwise the selection and the scrollback are cleared, then at a prompt the screen (`CSI 2 J` before `CSI 3 J`) with FF sent through `onData`, and elsewhere the rows above the cursor (`CSI n S`, `CSI n A`). The binding adds `writeUntilGround` (`ghostty_terminal_vt_write_until_ground`; `write` and it share `feed`), and the clear is pending until a write reaches ground, so it never cuts a sequence the output left unfinished; `reset` drops it. Upstream had no clear-screen action (its `clear`, removed above, was unused). - `ghostty.ts`, `terminal.ts`, `renderer.ts`, `interfaces.ts`, `search.ts` (new): find in the scrollback (ADR 055). The binding adds `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch` and `searchMatches` on libghostty-vt's search (`ghostty_search_*`, with `SELECT_SCROLL` set to `NONE`: the view is webtty's own), freed with the terminal. `Terminal` adds `find`, `findNext`, `findPrevious`, `endFind`, `finding` and `onFindChange`; it steps the search in the render loop before each render, hands the renderer the matches when they changed, scrolls a selected match into view as Ghostty does (its first row at the top when none of its rows is in view), keeps a scrolled view on its rows during output while a match is selected (instead of xterm.js' scroll to the bottom; still to the bottom on the alternate screen), scrolls to the bottom on a key or a paste sent to the program as Ghostty's `scroll-to-bottom = keystroke` does (`handleInput` without a mouse report, `paste`), and sets the needle again on the engine `reset` makes. `search.ts` finds the matches over a range of rows by binary search on the newest-to-oldest list (`matchesInRows`, `highlightRows`) and holds the view arithmetic (`viewportYToShow`, `keptViewportY`). The renderer's new `setSearchHighlights` paints matches with the new theme colours `searchBackground` / `searchForeground` and the selected one with `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults), under the selection, and repaints a canvas row only when its matches change (`drawnSearch`, moved with the rows on a copied scroll). Upstream had no search. - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`, `index.ts`: desktop notifications (ADR 056). The binding installs Ghostty's `DESKTOP_NOTIFICATION` callback when the new `GhosttyTerminalConfig.desktopNotifications` is on (default true): it copies the request's `title` (`''` for OSC 9) and `body` out as UTF-8, reading only what the struct's `size` covers. `TerminalEffects` gains `notifications`, every request since `readEffects`, in order, and `DesktopNotification` is exported. `Terminal` gains the `desktopNotifications` option, passed to the engine, and `onDesktopNotification`, fired for each. Upstream ignored OSC 9 and OSC 777. +- `ghostty.ts`, `terminal.ts`, `selection-manager.ts`: OSC 133 prompt jumps and output selection (ADR 057). The binding adds `promptJumpRow` (Ghostty's viewport put on webtty's top row with `ROW`, moved with `GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`, read back with `GHOSTTY_TERMINAL_DATA_SCROLLBAR` and put back at the bottom; `scrollViewport` writes its value as an `isize`) and `selectOutput` (`ghostty_terminal_select_output`). `Terminal` adds `jumpToPrompt`, Ghostty's `jump_to_prompt`, scrolling the view with `scrollToLine`. A triple click with Ctrl or Cmd held selects the command's output instead of the line, as in Ghostty. Upstream had neither. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 41d564e..407e342 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -3,7 +3,8 @@ * * Features: * - Mouse drag selection - * - Double-click word and triple-click line selection (Ghostty's, ADR 050) + * - Double-click word and triple-click line selection (Ghostty's, ADR 050), and + * Ctrl/Cmd+triple-click command output (Ghostty's, webtty ADR 057) * - Text extraction (Ghostty's, ADR 050) * - Clipboard copy on select (the terminal's copyOnSelect option) * - Visual selection highlighting (integrated into CanvasRenderer cell rendering) @@ -457,7 +458,8 @@ export class SelectionManager { }; document.addEventListener('mouseup', this.boundMouseUpHandler); - // Double click selects a word and triple click a line, by Ghostty's rules (ADR 050). + // Double click selects a word and triple click a line, by Ghostty's rules (ADR 050); + // with Ctrl or Cmd, triple click selects the command's output (webtty, ADR 057). // event.detail is the browser's click count. // Not while the app takes the press (webtty, ADR 051). canvas.addEventListener('click', (e: MouseEvent) => { @@ -465,7 +467,12 @@ export class SelectionManager { if (this.pressIsApps(e)) return; const cell = this.pixelToCell(e.offsetX, e.offsetY); const at = { col: cell.col, row: this.viewportRowToAbsolute(cell.row) }; - const range = e.detail === 2 ? this.wasmTerm.selectWord(at) : this.wasmTerm.selectLine(at); + const range = + e.detail === 2 + ? this.wasmTerm.selectWord(at) + : e.ctrlKey || e.metaKey + ? this.wasmTerm.selectOutput(at) + : this.wasmTerm.selectLine(at); if (!range) return; this.selectionStart = { col: range.start.col, absoluteRow: range.start.row }; this.selectionEnd = { col: range.end.col, absoluteRow: range.end.row }; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index e6ca1a3..29e32b6 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -786,6 +786,19 @@ export class Terminal implements ITerminalCore { this.findAnchor = selected && { row: selected.start.row, scrollback }; } + /** + * Ghostty's `jump_to_prompt`: moves the view by `delta` OSC 133 prompts (negative is + * up), a prompt landing as the top row; nothing without marks or past the last one. + * (webtty, ADR 057) + */ + jumpToPrompt(delta: number): void { + if (!this.wasmTerm) return; + const scrollback = this.getScrollbackLength(); + const top = scrollback - Math.floor(this.viewportY); + const row = this.wasmTerm.promptJumpRow(top, delta); + if (row !== top) this.scrollToLine(scrollback - row); + } + /** Fires onFindChange if the count or the selected match changed. */ private reportFind(): void { const state = this.wasmTerm?.searchState() ?? { total: 0, selected: null }; diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index 2a7c10d..cc3d020 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -403,6 +403,27 @@ describe('SelectionManager gestures', () => { t.free(); }); + test('a triple click with Ctrl or Cmd selects the command output; nothing outside it', () => { + const t = ghostty.createTerminal(20, 6); + t.write('\x1b]133;A\x07$ \x1b]133;B\x07ls\r\n\x1b]133;C\x07a.txt\r\nb.txt\r\n'); + t.write('\x1b]133;D;0\x07\x1b]133;A\x07$ '); + const { m, canvas } = manager(t); + const output = (row: number, mods: object) => + canvas.dispatch('click', { detail: 3, offsetX: 1, offsetY: row * 16 + 4, ...mods }); + output(2, { ctrlKey: true }); + expect(m.getSelection()).toBe('a.txt\nb.txt'); + m.clearSelection(); + output(1, { metaKey: true }); + expect(m.getSelection()).toBe('a.txt\nb.txt'); + output(1, {}); + expect(m.getSelection()).toBe('a.txt'); + m.clearSelection(); + output(3, { ctrlKey: true }); + expect(m.hasSelection()).toBe(false); + expect(writes).toEqual(['a.txt\nb.txt', 'a.txt\nb.txt', 'a.txt']); + t.free(); + }); + // Ghostty's selection rules (ADR 050) test('a selection across a soft wrap copies one line; a hard newline stays', () => { diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index a13cf50..3b0312a 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -1079,3 +1079,55 @@ describe('Terminal right-click', () => { expect(new Terminal({ ghostty: {} as never }).options.rightClickBehavior).toBe('contextMenu'); }); }); + +describe('Terminal jumpToPrompt', () => { + // Ghostty's jump_to_prompt on libghostty-vt's DELTA_PROMPT, in webtty's view (ADR 057). + async function promptTerm() { + const ghostty = await Ghostty.load(WASM); + const term = new Terminal({ ghostty, cols: 20, rows: 5 }); + const internals = term as unknown as { + isOpen: boolean; + wasmTerm: ReturnType<typeof ghostty.createTerminal>; + showScrollbar: () => void; + }; + internals.wasmTerm = ghostty.createTerminal(20, 5); + internals.isOpen = true; + internals.showScrollbar = () => {}; + return { term, internals }; + } + const command = (name: string) => + `\x1b]133;A\x07$ \x1b]133;B\x07${name}\r\n\x1b]133;C\x07` + + Array.from({ length: 6 }, (_, i) => `${name} ${i}`).join('\r\n') + + '\r\n\x1b]133;D;0\x07'; + + test('scrolls a prompt to the top row, up and down, and back to the bottom', async () => { + const { term, internals } = await promptTerm(); + // Prompts on rows 0, 7 and 14; 17 rows of scrollback + term.write(`${command('one')}${command('two')}${command('three')}\x1b]133;A\x07$ `); + const scrolls: number[] = []; + term.onScroll((y) => scrolls.push(y)); + term.jumpToPrompt(-1); + expect(term.viewportY).toBe(17 - 14); + term.jumpToPrompt(-1); + expect(term.viewportY).toBe(17 - 7); + term.jumpToPrompt(-1); + term.jumpToPrompt(-1); + expect(term.viewportY).toBe(17); + term.jumpToPrompt(1); + expect(term.viewportY).toBe(10); + term.jumpToPrompt(2); + expect(term.viewportY).toBe(0); + expect(scrolls).toEqual([3, 10, 17, 10, 0]); + internals.wasmTerm.free(); + }); + + test('leaves a fractional view alone when there is nowhere to go', async () => { + const { term, internals } = await promptTerm(); + term.write(Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\r\n')); + term.viewportY = 2.5; + term.jumpToPrompt(-1); + term.jumpToPrompt(1); + expect(term.viewportY).toBe(2.5); + internals.wasmTerm.free(); + }); +}); From 2a0435b3d76922b74e188742ea5b1417166bf44d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:27:43 -0400 Subject: [PATCH 129/295] feat(client): Cmd+Up/Down (macOS) and Ctrl+Shift+Up/Down jump to the previous and next prompt, as Ghostty binds jump_to_prompt --- src/client/index.ts | 10 +++++++++- src/client/shortcuts.test.ts | 24 +++++++++++++++++++++++- src/client/shortcuts.ts | 25 ++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 0549f34..eb7d483 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -469,7 +469,8 @@ term.onPasteRequest(() => { pasteFromClipboard(); }); -// Copy, paste and clear-screen keys as Ghostty binds them for this platform (ADR 054). +// Copy, paste, clear-screen, find and jump-to-prompt keys as Ghostty binds them for this +// platform (ADR 054, ADR 055, ADR 057). // Ctrl+V never reaches us as a 'paste' event on macOS (see above), so the clipboard is // read on keydown to make it behave like Cmd+V. Both keydown listeners are capture // listeners on `container`, so they run in registration order - this one runs after the @@ -495,6 +496,13 @@ container.addEventListener( else findBar.go(shortcut.action === 'findNext' ? 'next' : 'previous'); return; } + if (shortcut.action === 'promptPrevious' || shortcut.action === 'promptNext') { + // Ghostty's jump_to_prompt is consumed with or without a prompt to go to (ADR 057) + term.jumpToPrompt(shortcut.action === 'promptPrevious' ? -1 : 1); + e.preventDefault(); + e.stopImmediatePropagation(); + return; + } if (shortcut.action === 'paste') { // Without the clipboard API the browser's own paste may still fire 'paste' if (pasteFromClipboard()) e.preventDefault(); diff --git a/src/client/shortcuts.test.ts b/src/client/shortcuts.test.ts index dca2e2e..f2f7a56 100644 --- a/src/client/shortcuts.test.ts +++ b/src/client/shortcuts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { isMacPlatform, shortcutFor } from './shortcuts'; +import { isMacPlatform, type Shortcut, shortcutFor } from './shortcuts'; type Mod = 'ctrl' | 'shift' | 'alt' | 'meta'; const key = (code: string, mods: Mod[] = []) => ({ @@ -118,3 +118,25 @@ describe('shortcutFor find keys (ADR 055)', () => { expect(at(false, 'KeyG', ['ctrl'])).toBeNull(); }); }); + +describe('shortcutFor jump-to-prompt keys (ADR 057)', () => { + const at = (mac: boolean, code: string, mods: Mod[] = []) => shortcutFor(key(code, mods), mac); + const previous: Shortcut = { action: 'promptPrevious', performable: false }; + const next: Shortcut = { action: 'promptNext', performable: false }; + + test('Ctrl+Shift+Up / Down outside macOS, consumed with or without a prompt', () => { + expect(at(false, 'ArrowUp', ['ctrl', 'shift'])).toEqual(previous); + expect(at(false, 'ArrowDown', ['ctrl', 'shift'])).toEqual(next); + expect(at(false, 'ArrowUp', ['ctrl'])).toBeNull(); + expect(at(false, 'ArrowUp', ['meta'])).toBeNull(); + }); + + test('Cmd+Up / Down and Cmd+Shift+Up / Down on macOS', () => { + expect(at(true, 'ArrowUp', ['meta'])).toEqual(previous); + expect(at(true, 'ArrowUp', ['meta', 'shift'])).toEqual(previous); + expect(at(true, 'ArrowDown', ['meta'])).toEqual(next); + expect(at(true, 'ArrowDown', ['meta', 'shift'])).toEqual(next); + expect(at(true, 'ArrowUp', ['ctrl', 'shift'])).toBeNull(); + expect(at(true, 'ArrowUp', ['meta', 'alt'])).toBeNull(); + }); +}); diff --git a/src/client/shortcuts.ts b/src/client/shortcuts.ts index b9a32c7..53483ca 100644 --- a/src/client/shortcuts.ts +++ b/src/client/shortcuts.ts @@ -1,13 +1,22 @@ /** - * A copy, paste, clear-screen (ADR 054) or find (ADR 055) key, as Ghostty binds them by - * default. `performable`: when the action cannot be done (no selection to copy, the + * A copy, paste, clear-screen (ADR 054), find (ADR 055) or jump-to-prompt (ADR 057) key, + * as Ghostty binds them by default. `performable`: when the action cannot be done (no selection to copy, the * alternate screen for a clear, no search open to close or move in) the key goes on to * the app, as Ghostty's `performable:` bindings do; otherwise it is consumed either way. * A paste is never the app's: without a clipboard API the browser's own paste is left to * run instead. */ export interface Shortcut { - action: 'copy' | 'paste' | 'clear' | 'find' | 'findClose' | 'findNext' | 'findPrevious'; + action: + | 'copy' + | 'paste' + | 'clear' + | 'find' + | 'findClose' + | 'findNext' + | 'findPrevious' + | 'promptPrevious' + | 'promptNext'; performable: boolean; } @@ -40,6 +49,12 @@ export function shortcutFor(e: Keys, mac: boolean): Shortcut | null { return { action: 'findNext', performable: true }; case 'shift+super+KeyG': return { action: 'findPrevious', performable: true }; + case 'super+ArrowUp': + case 'shift+super+ArrowUp': + return { action: 'promptPrevious', performable: false }; + case 'super+ArrowDown': + case 'shift+super+ArrowDown': + return { action: 'promptNext', performable: false }; } return null; } @@ -52,6 +67,10 @@ export function shortcutFor(e: Keys, mac: boolean): Shortcut | null { return { action: 'copy', performable: false }; case 'shift+Insert': return { action: 'paste', performable: false }; + case 'ctrl+shift+ArrowUp': + return { action: 'promptPrevious', performable: false }; + case 'ctrl+shift+ArrowDown': + return { action: 'promptNext', performable: false }; } return null; } From 69c217725477539af1bb1611bcdeb558182069fc Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:30:32 -0400 Subject: [PATCH 130/295] docs(client): record prompt jumps, output selection and getting OSC 133 marks in the specs --- docs/specs/client.md | 32 +++++++++++++++++++++++++++++++- docs/specs/key-bindings.md | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index ab7ac40..0f95817 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -170,7 +170,7 @@ Both can be active simultaneously. The canvas-based terminal has no DOM text sel The terminal has the one `contextmenu` listener (`handleContextMenu`). Keys, text and paste clear the selection; mouse reports do not, so a Shift selection survives the app's hover and clicks until Shift+right-click or the menu's Copy uses it. A menu not preceded by a right press (or macOS Ctrl+click) since the last key is a keyboard menu and follows `rightClickBehavior` even while the app tracks the mouse. While the terminal has focus, the Menu key and Shift+F10 are sent to the app as keys, so the browser opens no keyboard menu there. `"paste"` fires `Terminal.onPasteRequest`, which `index.ts` answers with the same clipboard read as Ctrl+V. -What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). +What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). A triple click with Ctrl or Cmd held selects the output of the command under the pointer instead (`ghostty_terminal_select_output`, from OSC 133 marks; nothing outside output), as in Ghostty ([ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md)). ## Copy, Paste and Clear Keys @@ -221,6 +221,35 @@ Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of th `Terminal` (vendored) keeps the needle (also across `reset`), runs the search for at most 4 ms a frame before each render, and reads the matches again when they changed, at most every ten times the last read took; the renderer finds the matches in view by binary search on the newest-to-oldest list, and repaints a row only when its matches change. Find has no side effects, so the replay on connect needs no special handling. +## Shell Prompts (OSC 133) + +Shells that mark their prompts with OSC 133 (`133;A` prompt, `133;B` command line, `133;C` output, `133;D;<status>` end) get Ghostty's `jump_to_prompt` and output selection ([ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md)): + +| Platform | Key | Action | +|---|---|---| +| macOS | Cmd+↑, Cmd+Shift+↑ | to the previous prompt | +| macOS | Cmd+↓, Cmd+Shift+↓ | to the next prompt | +| others | Ctrl+Shift+↑ / Ctrl+Shift+↓ | to the previous / next prompt | +| all | Ctrl/Cmd + triple click | select the command's output (see Copy Behavior) | + +- **The move is Ghostty's** (`GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`): the prompt becomes the view's top row; up from the bottom goes to the nearest prompt above the screen's top row; past the last prompt the view goes to the bottom. The binding's `promptJumpRow` puts Ghostty's viewport on webtty's top row, moves it, reads it back (`GHOSTTY_TERMINAL_DATA_SCROLLBAR`) and returns it to the bottom; `Terminal.jumpToPrompt` scrolls the view with `scrollToLine`, and output still pulls a scrolled view to the bottom. +- The keys are always consumed, as Ghostty's are not `performable:`: without marks, or on the alternate screen, they do nothing and do not reach the app. They go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win. +- Nothing is sent anywhere, so the replay needs no special handling; its marks come with it. + +**Getting the marks.** webtty does not inject shell integration (ADR 057 records it as a follow-up): + +- **fish 4.0 and later** send them with no setup. +- **zsh and bash** (bash 3.2 too) send none. Source Ghostty's integration script at the top of `~/.zshrc` / `~/.bashrc`, from a Ghostty install or a copy of [`src/shell-integration`](https://github.com/ghostty-org/ghostty/tree/main/src/shell-integration) (GPLv3); with `GHOSTTY_SHELL_FEATURES` unset it only sends the marks: + + ```sh + # ~/.zshrc (macOS app path; on Linux typically /usr/share/ghostty/shell-integration) + source /Applications/Ghostty.app/Contents/Resources/ghostty/shell-integration/zsh/ghostty-integration + # ~/.bashrc + source /Applications/Ghostty.app/Contents/Resources/ghostty/shell-integration/bash/ghostty.bash + ``` + +- starship (1.26) does not send them. + ## Program Clipboard Writes Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH ([ADR 052](../adrs/052.client.osc52-clipboard-write.md)). Ghostty parses and decodes it (also iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522) and calls the binding's `CLIPBOARD_WRITE` callback, installed only while `clipboardWrite` is on: @@ -291,6 +320,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Copy, paste and clear keys | Ghostty's defaults for the browser's platform: Ctrl+Shift+C and Ctrl+Insert copy and Shift+Insert pastes outside macOS (Ctrl+C stays the interrupt; Ctrl+Shift+C with no selection goes to the app), Cmd+K clears the screen and scrollback on macOS (at an OSC 133 prompt with FF for the shell to redraw; the app's key on the alternate screen); `keyboardBindings` win | [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) | ✅ | | Find in the scrollback | Cmd+F (macOS) or Ctrl+Shift+F opens a find bar; libghostty-vt's search over scrollback and screen (ASCII case-insensitive, across soft wraps), every match highlighted and the selected one distinct in Ghostty's colours, Enter / Shift+Enter (Cmd+G / Cmd+Shift+G) to the next (older) and previous match with Ghostty's `k/n` count, the view jumping to a match out of view and kept on it while output streams, Escape to close; `keyboardBindings` win | [ADR 055](../adrs/055.client.find-in-scrollback.md) | ✅ | +| Shell prompts (OSC 133) | Cmd+↑ / Cmd+↓ (also with Shift) on macOS, Ctrl+Shift+↑ / Ctrl+Shift+↓ elsewhere jump the view to the previous / next prompt, as Ghostty's `jump_to_prompt` (libghostty-vt's `DELTA_PROMPT`), consumed even with no prompt; Ctrl/Cmd+triple-click selects a command's output; marks from fish 4 natively, or zsh and bash with Ghostty's integration script sourced by hand; no injection yet; `keyboardBindings` win | [ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md) | ✅ | | Desktop notifications | OSC 9 and OSC 777 `notify`, parsed by Ghostty (ConEmu's `9;<n>` forms excluded), shown as browser notifications while the terminal is not focused, one a second and the same one once in five seconds; permission asked on the next key or click after the first one; the 🔔 title mark without permission; replayed ones ignored; `desktopNotifications: false` turns them off | [ADR 056](../adrs/056.client.desktop-notifications.md) | ✅ | | Focus reports, title and bell | Mode 1004 focus reports encoded by Ghostty, sent on each change and when the mode is set; the tab title is the program's OSC 0/2 title (`<title> \| webtty`, the session id while it has none); a bell puts 🔔 in front of it until the terminal is focused or a key is pressed, and bells replayed on connect are ignored; `bellTitle: false` turns the mark off | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | diff --git a/docs/specs/key-bindings.md b/docs/specs/key-bindings.md index acfb7e0..21862a5 100644 --- a/docs/specs/key-bindings.md +++ b/docs/specs/key-bindings.md @@ -28,7 +28,7 @@ User entries are **merged with defaults by `(key, mods)` identity**: - All other defaults are preserved. - To consume a key without sending anything, set `"chars": ""`. -Bindings also win over webtty's built-in copy, paste and clear-screen keys (Ctrl+Shift+C, Ctrl+Insert, Shift+Insert and Ctrl+V; Cmd+K on macOS) and find keys (Ctrl+Shift+F; Cmd+F, Cmd+Shift+F, Cmd+G and Cmd+Shift+G on macOS; Escape while the find bar is open): a binding for one of them sends its `chars` instead, and `"chars": ""` turns the key off. See [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) and [ADR 055](../adrs/055.client.find-in-scrollback.md). Bindings do not apply in the find bar's field. Font-size zoom keys are not overridable. +Bindings also win over webtty's built-in copy, paste and clear-screen keys (Ctrl+Shift+C, Ctrl+Insert, Shift+Insert and Ctrl+V; Cmd+K on macOS), find keys (Ctrl+Shift+F; Cmd+F, Cmd+Shift+F, Cmd+G and Cmd+Shift+G on macOS; Escape while the find bar is open) and jump-to-prompt keys (Ctrl+Shift+↑ / ↓; Cmd+↑ / ↓ and Cmd+Shift+↑ / ↓ on macOS): a binding for one of them sends its `chars` instead, and `"chars": ""` turns the key off. See [ADR 054](../adrs/054.client.copy-paste-clear-keys.md), [ADR 055](../adrs/055.client.find-in-scrollback.md) and [ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md). Bindings do not apply in the find bar's field. Font-size zoom keys are not overridable. ## Defining `chars` From 9bc0ff62f7b2fe8ac3106e7de12be02b3438001a Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:54:29 -0400 Subject: [PATCH 131/295] fix(client): take a window blur and a tab switch as lost focus, as Chrome fires no blur for one Chrome switching tabs only changes the page's visibility, so mode 1004 sent no CSI O, and the bell mark and notifications took the hidden tab as focused. ADR 053 updated. --- ...053.client.focus-reports-title-and-bell.md | 2 +- src/client/index.ts | 16 ++++++++-- src/client/vendor/ghostty-web/terminal.ts | 23 +++++++++++-- src/client/vendor/terminal.test.ts | 32 +++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/adrs/053.client.focus-reports-title-and-bell.md b/docs/adrs/053.client.focus-reports-title-and-bell.md index 6661644..305208c 100644 --- a/docs/adrs/053.client.focus-reports-title-and-bell.md +++ b/docs/adrs/053.client.focus-reports-title-and-bell.md @@ -33,7 +33,7 @@ webtty has one thing a native terminal does not: the server replays the session' The terminal has focus when the browser window has focus and keyboard focus is inside the terminal's container (its textarea, or the container on the way to it). That is Ghostty's surface focus: switching tabs, switching to another app or window, or clicking the address bar or devtools takes it away; coming back gives it back. A tab merely visible behind another window does not have it, as a Ghostty window that is not the key window does not. -`Terminal` follows it with `focusin` and `focusout` on the container. A `focusout` whose `relatedTarget` is inside the container (the padding click that `handleFocusIn` sends on to the textarea) is not a change; a window blur is a `focusout` with no `relatedTarget`, and a window focus a `focusin`. Repeated events with no change send nothing. +`Terminal` follows it with `focusin` and `focusout` on the container. A `focusout` whose `relatedTarget` is inside the container (the padding click that `handleFocusIn` sends on to the textarea) is not a change; a window blur is a `focusout` with no `relatedTarget`, and a window focus a `focusin`. Chrome switching tabs fires neither, only `visibilitychange`, so the terminal also takes a window `blur` as lost focus and, on `visibilitychange`, reads the focus again (shown, window focused, focus inside the container); the bell mark and notifications use the same check. Repeated events with no change send nothing. ### Focus reports are Ghostty's encoder, sent as Ghostty sends them diff --git a/src/client/index.ts b/src/client/index.ts index eb7d483..8949825 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -239,8 +239,15 @@ for (const type of ['keydown', 'mousedown', 'paste', 'drop'] as const) { window.addEventListener(type, () => clipboardWriter.userInput(), { capture: true }); } +/** The terminal has focus: its tab is shown, its window focused, and focus is inside it (ADR 053). */ +const terminalFocused = (): boolean => + document.visibilityState === 'visible' && + document.hasFocus() && + container.contains(document.activeElement); + // The title and the bell mark (ADR 053). Ghostty clears the mark when the terminal -// gains focus (a window focus reaches the container as a focusin too) or gets a key. +// gains focus (a window focus reaches the container as a focusin too; Chrome switching +// back to the tab only shows it) or gets a key. term.onTitleChange((title) => tabTitle.title(title)); term.onBell(() => tabTitle.bell()); container.addEventListener('focusin', () => tabTitle.attended()); @@ -251,7 +258,7 @@ window.addEventListener('keydown', () => tabTitle.attended(), { capture: true }) // next key or click after the first one that activates the page (not Esc), a gesture // browsers accept (ADR 056). const notifier = new DesktopNotifier(browserNotifications(), sessionId, replay, { - focused: () => document.hasFocus() && container.contains(document.activeElement), + focused: terminalFocused, name: () => tabTitle.name, mark: () => tabTitle.bell(), focus: () => { @@ -268,6 +275,11 @@ for (const type of ['keydown', 'mousedown'] as const) { ); } container.addEventListener('focusin', () => notifier.attended()); +document.addEventListener('visibilitychange', () => { + if (!terminalFocused()) return; + tabTitle.attended(); + notifier.attended(); +}); window.addEventListener('keydown', () => notifier.attended(), { capture: true }); function connect(): void { diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 29e32b6..785bd8f 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -540,10 +540,13 @@ export class Terminal implements ITerminalCore { // focus() on a background window moves activeElement without a focusin; catch // the container still holding focus when the window comes back. (webtty) window.addEventListener('focus', this.handleWindowFocus); - // Terminal focus for mode 1004: a window blur or focus reaches the focused element - // as focusout / focusin too. (webtty, ADR 053) + // Terminal focus for mode 1004: a window blur or focus usually reaches the focused + // element as focusout / focusin too, but Chrome switching tabs only changes the + // page's visibility, so blur and visibility are watched as well. (webtty, ADR 053) parent.addEventListener('focusin', this.handleFocusGained); parent.addEventListener('focusout', this.handleFocusLost); + window.addEventListener('blur', this.handleWindowBlur); + document.addEventListener('visibilitychange', this.handleVisibilityChange); // Add accessibility attributes for screen readers and extensions parent.setAttribute('role', 'textbox'); @@ -1268,6 +1271,20 @@ export class Terminal implements ITerminalCore { this.setFocused(false); }; + /** The window lost focus: the terminal has none, whatever element is active. (webtty, ADR 053) */ + private handleWindowBlur = (): void => { + this.setFocused(false); + }; + + /** + * The tab was hidden or shown. Chrome fires no blur or focus for a tab switch, so the + * focus is read again here. (webtty, ADR 053) + */ + private handleVisibilityChange = (): void => { + const shown = document.visibilityState === 'visible' && document.hasFocus(); + this.setFocused(shown && !!this.element?.contains(document.activeElement)); + }; + private setFocused(focused: boolean): void { if (focused === this.focused) return; this.focused = focused; @@ -1461,6 +1478,8 @@ export class Terminal implements ITerminalCore { if (this.isOpen && typeof document !== 'undefined') { document.removeEventListener('mouseup', this.handleMouseUp); window.removeEventListener('focus', this.handleWindowFocus); + window.removeEventListener('blur', this.handleWindowBlur); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); } // Clean up scrollbar timers diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/terminal.test.ts index 3b0312a..ccb70ad 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/terminal.test.ts @@ -685,6 +685,8 @@ describe('Terminal focus reports', () => { element: unknown; handleFocusGained(): void; handleFocusLost(e: { relatedTarget: unknown }): void; + handleWindowBlur(): void; + handleVisibilityChange(): void; writeInternal(data: string): void; }; internals.wasmTerm = wasmTerm; @@ -699,6 +701,8 @@ describe('Terminal focus reports', () => { write: (data: string) => internals.writeInternal(data), focusIn: () => internals.handleFocusGained(), focusOut: (relatedTarget: unknown = null) => internals.handleFocusLost({ relatedTarget }), + windowBlur: () => internals.handleWindowBlur(), + visibility: () => internals.handleVisibilityChange(), inside, }; } @@ -746,6 +750,34 @@ describe('Terminal focus reports', () => { wasmTerm.free(); }); + test('a window blur, or a tab hidden and shown with no blur (Chrome), is a change', async () => { + const { wasmTerm, sent, write, focusIn, windowBlur, visibility, inside } = await focusTerm(); + const doc = globalThis.document; + const page = { visibilityState: 'visible', hasFocus: () => true, activeElement: inside }; + globalThis.document = page as unknown as Document; + try { + focusIn(); + write('\x1b[?1004h'); + windowBlur(); + focusIn(); + page.visibilityState = 'hidden'; + visibility(); + page.visibilityState = 'visible'; + visibility(); + expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I', '\x1b[O', '\x1b[I']); + // Shown with focus elsewhere in the page, or in another window: still unfocused. + page.visibilityState = 'hidden'; + visibility(); + page.activeElement = {}; + page.visibilityState = 'visible'; + visibility(); + expect(sent).toHaveLength(6); + } finally { + globalThis.document = doc; + } + wasmTerm.free(); + }); + test('resyncFocus sends the current focus while the mode is on, for a new connection', async () => { const { term, wasmTerm, sent, write, focusIn, focusOut } = await focusTerm(); focusIn(); From 947b6e441b52538e301d87351ccbb1b25392cdfb Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 12:54:29 -0400 Subject: [PATCH 132/295] feat(client): clear a selection once copyOnSelect copies it, as Claude Code and pi do With copyOnSelect off the selection stays to copy by key or menu. ADR 051 and the specs updated. --- ...ouse-tracking-selection-and-right-click.md | 2 +- docs/specs/client.md | 2 +- docs/specs/config.md | 2 +- .../vendor/ghostty-web/selection-manager.ts | 6 ++-- src/client/vendor/selection-manager.test.ts | 34 +++++++++---------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md index 0298981..3600d91 100644 --- a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md +++ b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md @@ -55,7 +55,7 @@ A menu is from the keyboard when no right press (or macOS Ctrl+click, which open ### Config - `rightClickBehavior`: `"contextMenu"` (default), `"copyPaste"`, `"paste"`, `"none"`. `"default"`, the old name of `"contextMenu"`, is still accepted. Any other value falls back to `"contextMenu"`. The default is the same on every platform; Windows Terminal users may prefer `"copyPaste"`. -- `copyOnSelect` is unchanged, and now reads as: copy a selection made with the mouse when the mouse button is released. A drag the app receives is not a selection. +- `copyOnSelect` now reads as: copy a selection made with the mouse when the mouse button is released, then clear it, as Claude Code and pi clear their own selections once copied. A drag the app receives is not a selection. With `copyOnSelect` off the selection stays, to copy with Cmd+C, Ctrl+Shift+C (ADR 054) or the menu. ## Consequences diff --git a/docs/specs/client.md b/docs/specs/client.md index 0f95817..209268a 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -155,7 +155,7 @@ Controlled by two config keys from `GET /api/config`: | Config | Default | Behavior | |--------|---------|----------| -| `copyOnSelect` | `true` | Copies a mouse selection (drag, double or triple click) to the clipboard once, on mouseup, through the Terminal's `copyOnSelect` option; off, nothing is copied. | +| `copyOnSelect` | `true` | Copies a mouse selection (drag, double or triple click) to the clipboard once, on mouseup, through the Terminal's `copyOnSelect` option, and clears it; off, nothing is copied and the selection stays. | | `rightClickBehavior` | `"contextMenu"` | What a right-click the app does not take does: `"contextMenu"` the browser menu (over the textarea, so it offers Copy and Paste); `"copyPaste"` copy and clear a selection, else the menu; `"paste"` paste the clipboard (the Ctrl+V path, ADR 037); `"none"` nothing, no menu. `"default"` is read as `"contextMenu"`. | Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these and the copy keys (Cmd+C, Ctrl+Shift+C, Ctrl+Insert; see below) are the only reliable copy paths. diff --git a/docs/specs/config.md b/docs/specs/config.md index 62acc38..af64016 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -113,7 +113,7 @@ All keys are optional — omit any key to use the default value. | `rows` | number | `24` | Initial terminal height in rows | | `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. | | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. DEC mode 12 (`CSI ? 12 h/l`) is ignored, as in Ghostty when `cursor-style-blink` is set, so an app that turns blinking off that way (vim's `t_ve`) does not leave the cursor steady. | -| `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released (kitty / Windows Terminal style). While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | +| `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released, then clear the highlight, as Claude Code and pi do; off, the selection stays to copy with Cmd+C, Ctrl+Shift+C or the menu. While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `rightClickBehavior` | string | `"contextMenu"` | What a right-click does when the app does not take it: `"contextMenu"` shows the browser menu; `"copyPaste"` copies and clears an existing selection, otherwise shows the menu; `"paste"` pastes the clipboard as Ctrl+V does; `"none"` does nothing and shows no menu. While the app tracks the mouse, a right-click goes only to the app and no menu is shown; Shift+right-click applies this setting instead and sends nothing to the app. `"default"`, the old name of `"contextMenu"`, is still accepted; invalid values fall back to `"contextMenu"`. Windows Terminal users may prefer `"copyPaste"`. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `clipboardWrite` | boolean | `true` | Let programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL`), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH; iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522 writes too. Writes only: a query (`52;c;?`) is never answered. Only the clipboard (`c`, or an empty or unknown selection) is written; `s` and `p` are ignored, as in Ghostty on macOS. A write is held back after each connect until you press a key or click in the tab, so the replayed history never overwrites the clipboard, and the browser may still refuse it (Chrome needs focus, Firefox recent input, Safari refuses most): the console says so once. `false` ignores all program clipboard writes. See [ADR 052](../adrs/052.client.osc52-clipboard-write.md). | | `bellTitle` | boolean | `true` | When a program rings the bell (BEL), put a 🔔 in front of the tab title until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title` does; it shows in a background tab until you come back. Bells in the output replayed on reload or reconnect are ignored. The tab title itself is the program's title (OSC 0/2) as `<title> \| webtty`, or `<session> \| webtty` while it has none. `false` leaves the title alone on a bell. See [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md). | diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 407e342..dd88cab 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -615,13 +615,15 @@ export class SelectionManager { } /** - * A mouse selection is made: copy it when the terminal's copyOnSelect is on, building - * its text once, and report the change. (webtty) + * A mouse selection is made: with the terminal's copyOnSelect on, copy it, building + * its text once, and clear it, as Claude Code and pi do with their own selections; + * report the change. (webtty) */ private selectionMade(): void { if (this.terminal.options.copyOnSelect) { const text = this.getSelection(); if (text) this.copyToClipboard(text); + this.clearSelection(); } this.selectionChangedEmitter.fire(); } diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/selection-manager.test.ts index cc3d020..fda3445 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/selection-manager.test.ts @@ -147,29 +147,37 @@ describe('SelectionManager copy on select', () => { canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4, shiftKey: true }); canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20, shiftKey: true }); expect(writes).toEqual(['hello', 'world', 'second line']); - expect(m.getSelection()).toBe('second line'); + expect(m.hasSelection()).toBe(false); + }); + + test('a copied selection is cleared, as Claude Code and pi clear theirs', () => { + const { m, canvas, changes } = manager(true); + drag(canvas); + expect(writes).toEqual(['hello']); + expect(m.hasSelection()).toBe(false); + expect(m.getSelectionRange()).toBeNull(); + expect(changes()).toBe(1); }); test('a press the app takes leaves the current selection', () => { - const { m, canvas } = manager(true); + const { m, canvas } = manager(false); drag(canvas); wasmTerm.write('\x1b[?1000h'); canvas.dispatch('mousedown', { button: 0, offsetX: 8 * 7, offsetY: 20 }); documentTarget.dispatch('mouseup', {}); expect(m.getSelection()).toBe('hello'); - wasmTerm.write('\x1b[?1000l'); - drag(canvas); - expect(writes).toEqual(['hello', 'hello']); }); test('nothing is copied or built with copyOnSelect off, but the change is reported', () => { - const { canvas, builds, changes } = manager(false); + const { m, canvas, builds, changes } = manager(false); drag(canvas); canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); expect(writes).toEqual([]); expect(builds()).toBe(0); expect(changes()).toBe(3); + // The selection stays, to copy with Cmd+C, Ctrl+Shift+C or the menu. + expect(m.hasSelection()).toBe(true); }); }); @@ -283,7 +291,8 @@ describe('SelectionManager gestures', () => { const terminal = { cols: wasmTerm.cols, rows: wasmTerm.rows, - options: { copyOnSelect: true }, + // Off, so the selection a gesture makes stays to be read (on, it is copied and cleared) + options: { copyOnSelect: false }, getViewportY: viewportY, scrollLines: () => {}, } as unknown as Terminal; @@ -358,15 +367,6 @@ describe('SelectionManager gestures', () => { expect(word(43)).toBe('quoted'); expect(word(52)).toBe('paren'); expect(word(59)).toBe('HOME'); - expect(writes).toEqual([ - '/usr/local/bin/foo.txt', - 'a@b.c', - 'x+y', - '~/q', - 'quoted', - 'paren', - 'HOME', - ]); t.free(); }); @@ -390,7 +390,6 @@ describe('SelectionManager gestures', () => { expect(m.getSelection()).toBe('one two'); click(canvas, 3, 0, 2); expect(m.getSelection()).toBe('three'); - expect(writes).toEqual(['one two', 'three']); t.free(); }); @@ -420,7 +419,6 @@ describe('SelectionManager gestures', () => { m.clearSelection(); output(3, { ctrlKey: true }); expect(m.hasSelection()).toBe(false); - expect(writes).toEqual(['a.txt\nb.txt', 'a.txt\nb.txt', 'a.txt']); t.free(); }); From c9b7090e16cd0bcf9f5549cc043e5c57dccc1721 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:02:40 -0400 Subject: [PATCH 133/295] docs(client): ADR 058, webtty's own OSC 133 prompt-mark snippets for zsh and bash --- docs/adrs/058.client.zsh-bash-prompt-marks.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/adrs/058.client.zsh-bash-prompt-marks.md diff --git a/docs/adrs/058.client.zsh-bash-prompt-marks.md b/docs/adrs/058.client.zsh-bash-prompt-marks.md new file mode 100644 index 0000000..488dee6 --- /dev/null +++ b/docs/adrs/058.client.zsh-bash-prompt-marks.md @@ -0,0 +1,50 @@ +# ADR 058: client — OSC 133 prompt marks for zsh and bash + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +Three features read OSC 133 prompt marks: Cmd+K at a prompt (ADR 054), the prompt jumps and Ctrl/Cmd+triple-click output selection (ADR 057). fish 4 sends the marks itself; zsh and bash send none, so their users get none of the three. ADR 057 pointed them at Ghostty's integration scripts, sourced by hand, and left injection as a follow-up. + +Ghostty's zsh and bash scripts are GPLv3 (they started as kitty's) and do much more than the marks (cursor shape, title, `sudo` and `ssh` wrappers), gated by `GHOSTTY_SHELL_FEATURES`. A user without Ghostty installed has to fetch them from Ghostty's repository. The marks themselves take a few lines of shell: `133;A` before the prompt, `133;B` at its end, `133;C` before a command runs and `133;D;<status>` when it is done. + +## Decision + +webtty documents its own snippets, MIT like the rest of webtty, for users to paste at the end of `~/.zshrc` or `~/.bashrc`. It still injects nothing. + +**zsh**: a `precmd` hook sends `133;D;<status>` if a command ran since the last prompt, then `133;A`, and appends `133;B` to `PS1` if it is not there yet; a `preexec` hook sends `133;C`. Being last in `.zshrc` puts the hooks after a prompt theme's, so `PS1` is final when `B` is added, and the theme's own `$?` is untouched. + +**bash**: a function first in `PROMPT_COMMAND` sends `133;D;<status>` after the first prompt, then `133;A`, appends `133;B` to `PS1` once, and returns the status it was given so later `PROMPT_COMMAND` entries see the same `$?`. `PS0` (bash 4.4 and later) sends `133;C`. + +The snippets are in the client spec (Shell Prompts), next to the keys they enable. Ghostty's scripts stay listed as the alternative. + +### Limits + +- **bash 3.2** (macOS `/bin/bash`) has no `PS0`, so no `133;C`: prompt jumps and Cmd+K work, output selection does not. +- **bash** cannot tell an empty Enter from a command in `PROMPT_COMMAND` alone, so an empty line still gets a `133;D` (with the previous status). Nothing in webtty shows the status, and Ghostty's output selection is not affected. +- A theme that rewrites `PS1` from a later `PROMPT_COMMAND` entry (bash) drops the `B` mark; the jumps and output selection need only `A`, `C` and `D`. + +### Verified + +Through a PTY (40×12, `TERM=xterm-256color`) with the snippets as the only rc file: zsh 5.9 and bash 5.3 sent `A B C <output> D;0 A B …`, and zsh sent no `D` for an empty Enter; bash 3.2 sent everything but `C`. Written into libghostty-vt (the pinned wasm), both captures gave `cursorAtPrompt()` true at the last prompt, a prompt jump that moved, and `selectOutput` on a command's first output row returned exactly its output (`a.txt\nb.txt`). + +## Consequences + +- zsh and bash users get Cmd+K at a prompt, the prompt jumps and output selection with one paste, without Ghostty installed and without GPL code. +- webtty keeps two small snippets working; their bytes are the standard OSC 133 sequences fish 4 sends, so the terminal side is the same path ADR 057 tested with fish. +- Nothing changes for users who do not paste them; the injection follow-up of ADR 057 stays open. + +## Considered Options + +- **Inject the marks automatically** (ADR 057's follow-up: `shellIntegration`, `ZDOTDIR` and `--rcfile` tricks). Changes how every user's shell starts, for three features; left until someone asks. +- **Keep pointing at Ghostty's scripts only.** Needs a Ghostty install or a GPLv3 download, and brings features webtty does not use. +- **Ship the snippets as files in the npm package** (`source "$(webtty shell-integration zsh)"`). A CLI command and a file layout to keep for a dozen lines users can paste. + +## Related Decisions + +- [ADR 054 — Copy, paste and clear-screen keys](054.client.copy-paste-clear-keys.md): Cmd+K at an OSC 133 prompt +- [ADR 057 — Jump to shell prompts and select command output](057.client.shell-integration-prompt-jumps.md): the features the marks enable, and the injection follow-up From fc6896fe593b966d8d87234a8a9a42f69c102c36 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:02:40 -0400 Subject: [PATCH 134/295] docs(client): give the zsh and bash prompt-mark snippets in the client spec, ADR 057 updated --- ...7.client.shell-integration-prompt-jumps.md | 2 + docs/specs/client.md | 41 +++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/adrs/057.client.shell-integration-prompt-jumps.md b/docs/adrs/057.client.shell-integration-prompt-jumps.md index 832804d..59a5d67 100644 --- a/docs/adrs/057.client.shell-integration-prompt-jumps.md +++ b/docs/adrs/057.client.shell-integration-prompt-jumps.md @@ -65,6 +65,8 @@ webtty does not inject shell integration in this change: Instead the specs say how to get the marks: nothing to do for fish 4; for zsh and bash, source Ghostty's integration script at the top of `.zshrc` / `.bashrc` (from a Ghostty install, or a copy of `src/shell-integration` from Ghostty's repository). With `GHOSTTY_SHELL_FEATURES` unset it only emits the marks. +**Update (ADR 058):** the specs now give webtty's own MIT snippets for zsh and bash, so Ghostty's scripts are no longer needed for the marks. [ADR 058](058.client.zsh-bash-prompt-marks.md). + **Follow-up**: a `shellIntegration` config key (`"detect"` | `"none"`, default `"detect"` as Ghostty's `shell-integration`), injecting marks-only scripts for zsh and bash (and fish 3) by Ghostty's methods, after a decision on vendoring GPLv3 files or writing MIT ones; tested with fish, zsh and bash spawned through the server. ### Replay diff --git a/docs/specs/client.md b/docs/specs/client.md index 209268a..9425972 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -239,15 +239,42 @@ Shells that mark their prompts with OSC 133 (`133;A` prompt, `133;B` command lin **Getting the marks.** webtty does not inject shell integration (ADR 057 records it as a follow-up): - **fish 4.0 and later** send them with no setup. -- **zsh and bash** (bash 3.2 too) send none. Source Ghostty's integration script at the top of `~/.zshrc` / `~/.bashrc`, from a Ghostty install or a copy of [`src/shell-integration`](https://github.com/ghostty-org/ghostty/tree/main/src/shell-integration) (GPLv3); with `GHOSTTY_SHELL_FEATURES` unset it only sends the marks: +- **zsh and bash** send none. Paste webtty's snippet at the end of `~/.zshrc` or `~/.bashrc` ([ADR 058](../adrs/058.client.zsh-bash-prompt-marks.md)); bash 3.2 (macOS `/bin/bash`) has no `PS0`, so it gets the prompt jumps and Cmd+K but not output selection: + + ```zsh + # ~/.zshrc, at the end. webtty: OSC 133 prompt marks (ADR 058) + if [[ -o interactive ]]; then + autoload -Uz add-zsh-hook + _webtty_precmd() { + local ret=$? + (( ${+_webtty_ran} )) && printf '\e]133;D;%s\a' "$ret" + unset _webtty_ran + printf '\e]133;A\a' + [[ $PS1 == *'133;B'* ]] || PS1+=$'%{\e]133;B\a%}' + } + _webtty_preexec() { _webtty_ran=1; printf '\e]133;C\a'; } + add-zsh-hook precmd _webtty_precmd + add-zsh-hook preexec _webtty_preexec + fi + ``` - ```sh - # ~/.zshrc (macOS app path; on Linux typically /usr/share/ghostty/shell-integration) - source /Applications/Ghostty.app/Contents/Resources/ghostty/shell-integration/zsh/ghostty-integration - # ~/.bashrc - source /Applications/Ghostty.app/Contents/Resources/ghostty/shell-integration/bash/ghostty.bash + ```bash + # ~/.bashrc, at the end. webtty: OSC 133 prompt marks (ADR 058) + if [[ $- == *i* ]]; then + _webtty_prompt() { + local ret=$? + [[ -n ${_webtty_started-} ]] && printf '\e]133;D;%s\a' "$ret" + _webtty_started=1 + printf '\e]133;A\a' + [[ $PS1 == *'133;B'* ]] || PS1+='\[\e]133;B\a\]' + return "$ret" + } + PROMPT_COMMAND="_webtty_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + PS0+=$'\e]133;C\a' + fi ``` +- Ghostty's integration scripts also work when sourced by hand (GPLv3; from a Ghostty install or [`src/shell-integration`](https://github.com/ghostty-org/ghostty/tree/main/src/shell-integration)); with `GHOSTTY_SHELL_FEATURES` unset they only send the marks. - starship (1.26) does not send them. ## Program Clipboard Writes @@ -320,7 +347,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Program clipboard writes (OSC 52) | OSC 52 (and OSC 1337 Copy, OSC 5522) writes from programs reach the browser clipboard: writes only, the standard clipboard only, UTF-8 text, held back after each connect until the user acts in the tab so the replay never writes; `clipboardWrite: false` turns it off | [ADR 052](../adrs/052.client.osc52-clipboard-write.md) | ✅ | | Copy, paste and clear keys | Ghostty's defaults for the browser's platform: Ctrl+Shift+C and Ctrl+Insert copy and Shift+Insert pastes outside macOS (Ctrl+C stays the interrupt; Ctrl+Shift+C with no selection goes to the app), Cmd+K clears the screen and scrollback on macOS (at an OSC 133 prompt with FF for the shell to redraw; the app's key on the alternate screen); `keyboardBindings` win | [ADR 054](../adrs/054.client.copy-paste-clear-keys.md) | ✅ | | Find in the scrollback | Cmd+F (macOS) or Ctrl+Shift+F opens a find bar; libghostty-vt's search over scrollback and screen (ASCII case-insensitive, across soft wraps), every match highlighted and the selected one distinct in Ghostty's colours, Enter / Shift+Enter (Cmd+G / Cmd+Shift+G) to the next (older) and previous match with Ghostty's `k/n` count, the view jumping to a match out of view and kept on it while output streams, Escape to close; `keyboardBindings` win | [ADR 055](../adrs/055.client.find-in-scrollback.md) | ✅ | -| Shell prompts (OSC 133) | Cmd+↑ / Cmd+↓ (also with Shift) on macOS, Ctrl+Shift+↑ / Ctrl+Shift+↓ elsewhere jump the view to the previous / next prompt, as Ghostty's `jump_to_prompt` (libghostty-vt's `DELTA_PROMPT`), consumed even with no prompt; Ctrl/Cmd+triple-click selects a command's output; marks from fish 4 natively, or zsh and bash with Ghostty's integration script sourced by hand; no injection yet; `keyboardBindings` win | [ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md) | ✅ | +| Shell prompts (OSC 133) | Cmd+↑ / Cmd+↓ (also with Shift) on macOS, Ctrl+Shift+↑ / Ctrl+Shift+↓ elsewhere jump the view to the previous / next prompt, as Ghostty's `jump_to_prompt` (libghostty-vt's `DELTA_PROMPT`), consumed even with no prompt; Ctrl/Cmd+triple-click selects a command's output; marks from fish 4 natively, or zsh and bash with webtty's snippet ([ADR 058](../adrs/058.client.zsh-bash-prompt-marks.md)); no injection yet; `keyboardBindings` win | [ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md) | ✅ | | Desktop notifications | OSC 9 and OSC 777 `notify`, parsed by Ghostty (ConEmu's `9;<n>` forms excluded), shown as browser notifications while the terminal is not focused, one a second and the same one once in five seconds; permission asked on the next key or click after the first one; the 🔔 title mark without permission; replayed ones ignored; `desktopNotifications: false` turns them off | [ADR 056](../adrs/056.client.desktop-notifications.md) | ✅ | | Focus reports, title and bell | Mode 1004 focus reports encoded by Ghostty, sent on each change and when the mode is set; the tab title is the program's OSC 0/2 title (`<title> \| webtty`, the session id while it has none); a bell puts 🔔 in front of it until the terminal is focused or a key is pressed, and bells replayed on connect are ignored; `bellTitle: false` turns the mark off | [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md) | ✅ | | Mouse follows app tracking | While the app tracks the mouse, drags, double/triple clicks and right-clicks go only to the app: webtty draws no highlight, copies nothing and cancels the browser menu. Shift selects in webtty (and applies `rightClickBehavior`) and sends nothing to the app. `rightClickBehavior` is `"contextMenu"` (default), `"copyPaste"`, `"paste"` or `"none"`, all handled in one function | [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md) | ✅ | From d33306958e11b02e29af0218727e52f20a49706b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:33:34 -0400 Subject: [PATCH 135/295] docs(arch): architecture SPEC and ADRs 059-065 for a modular, testable code base Adds docs/specs/architecture.md (layers, folder tree, module kinds, dependency rules, size limits, naming, comments, testing and coverage targets) and the ADRs that drive the refactor: code organisation (059), client feature modules and composition root (060), the vendored ghostty-web split with provenance (061), server composition (062), CLI command modules (063), test strategy (064) and the listener teardown fix (065). The client spec's stale source layout is corrected, and the webtty, cli and config specs link to the new SPEC. --- .../059.architecture.code-organisation.md | 68 ++++ docs/adrs/060.client.feature-modules.md | 77 +++++ docs/adrs/061.client.vendor-split.md | 80 +++++ docs/adrs/062.server.composition.md | 54 ++++ docs/adrs/063.cli.command-modules.md | 29 ++ docs/adrs/064.architecture.test-strategy.md | 35 ++ docs/adrs/065.client.listener-teardown.md | 29 ++ docs/specs/architecture.md | 299 ++++++++++++++++++ docs/specs/cli.md | 6 +- docs/specs/client.md | 15 +- docs/specs/config.md | 4 + docs/specs/webtty.md | 6 +- 12 files changed, 695 insertions(+), 7 deletions(-) create mode 100644 docs/adrs/059.architecture.code-organisation.md create mode 100644 docs/adrs/060.client.feature-modules.md create mode 100644 docs/adrs/061.client.vendor-split.md create mode 100644 docs/adrs/062.server.composition.md create mode 100644 docs/adrs/063.cli.command-modules.md create mode 100644 docs/adrs/064.architecture.test-strategy.md create mode 100644 docs/adrs/065.client.listener-teardown.md create mode 100644 docs/specs/architecture.md diff --git a/docs/adrs/059.architecture.code-organisation.md b/docs/adrs/059.architecture.code-organisation.md new file mode 100644 index 0000000..b84c66d --- /dev/null +++ b/docs/adrs/059.architecture.code-organisation.md @@ -0,0 +1,68 @@ +# ADR 059: architecture — Code organisation and conventions + +**SPEC:** [architecture](../specs/architecture.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +webtty grew feature by feature, each ADR adding to whichever file was nearest. The result works, and much of it is well factored (`OutputPacer`, `ReplayWindow`, `OutputAck`, `search.ts`, `glide.ts` are small, pure and fully tested), but the structure no longer helps a reader: + +- **Large files.** Five vendored files hold about 8,300 lines (`ghostty.ts` 3061, `terminal.ts` 2147, `renderer.ts` 1508, `input-handler.ts` 856, `selection-manager.ts` 736); `graphics.ts` is 662, `index.ts` 565, `routes.ts` 485. Each mixes several unrelated jobs. +- **Untestable entries.** `src/client/index.ts`, `src/server/index.ts` and `src/cli/index.ts` run their logic at import time, so the client wiring has no tests at all, and the server routes and WebSocket layer are tested only through subprocesses and do not appear in coverage. +- **Duplicated contracts.** The client re-declares `Theme`, `KeyboardBinding` and `ClientConfig`; the server whitelists the same keys a third time. The WebSocket close codes are numbers in the client and constants in the server. Runtime detection (`isBun`) has three definitions with three meanings. +- **No stated rules.** Nothing says where a new module goes, what it may import, how big it may grow or how it is tested. `client.md`'s source layout is out of date (it lists `index.html`, and calls the vendored code unchanged). + +The user asked for a code base that is easy to read and maintain and ready to grow: short files with one purpose and a good name, the right pattern for each, clear comments, a folder structure that makes sense, and test coverage as high as possible, with testability a design driver. No framework and no new runtime dependency. + +## Decision + +Adopt the organisation in the [architecture SPEC](../specs/architecture.md) and refactor the code base to it in small, behaviour-preserving steps. + +### Layers and imports + +- Client, server and CLI are separate layers. The client imports nothing from the server, CLI or PTY, and nothing from Node; it imports **types only** from `src/config` (`import type`, erased from the bundle). The server and CLI never import the client; the CLI never imports the server. +- In the client: entry → composition root → features → connection, layout and app modules → the vendored layer through its barrel only → vendored internals. Features never import each other. +- The vendored ghostty-web layer imports nothing outside itself. + +### Contracts across the client/server line + +There is no `src/shared` folder. Config types are defined once in `src/config` and the client derives `ClientConfig` from them with `Pick<Config, …>`; the server's `/api/config` key list becomes one constant (`CLIENT_CONFIG_KEYS`) next to that type, pinned by a test. The three close codes the client acts on are mirrored in `src/client/connection/close-codes.ts` and pinned to the server's by a contract test. A shared value module would have to be kept free of Node by convention; a type import is erased, and a two-line constant mirror with a test is cheaper than a third top-level layer. + +### Module kinds, naming and comments + +- Each file is one kind (entry, composition root, feature, service, object, view, util, adapter, constants, types, facade), named in its header comment. +- Files are kebab-case and named after their main export. Folders name a domain (`connection/`, `sessions/`, `render/`), never a kind (`utils/`, `services/`). `utils.ts`, `helpers.ts`, `misc.ts`, `common/` and folder-root `types.ts` are banned. +- Each file starts with a one-to-three-sentence header (what, kind, ADRs). Each export has a JSDoc line. Comments say why; no section banners. + +### Size limits + +App, server, CLI, config, pty and script files aim at 200 lines and stop at 300. Vendored files aim at 400 and stop at 600; the facades have lower limits (SPEC). Two data tables and the per-cell decode hot path are exempt, each with its reason written next to it. Test files stop at 800. + +### Enforcement + +`scripts/check-architecture.ts` walks `src/` and `scripts/` and checks the size limits and the import rules; `scripts/check-architecture.test.ts` runs it under `bun test`, so CI's test job enforces it. While the refactor runs, files over their limit today are listed in a **ratchet allowlist** with their current line count: they may shrink or disappear, never grow, and each step removes the entries it fixes. No dependency is added: the checker reads files and import specifiers with plain string matching. + +### How the refactor is done + +- Moves use `git mv` and carry no logic change; a module's tests move in the same commit. +- Each commit passes `bun test`, `bun run lint` and `bun run build`. +- Behaviour, wire protocol, config keys, CLI output, HTTP routes and bodies, `dist/` names and the entries named by `scripts/build.ts` and `package.json` stay the same. The one deliberate behaviour change, releasing listeners that `dispose()` leaks today, is its own commit and its own record ([ADR 065](065.client.listener-teardown.md)). +- The server/CLI track and the client track touch disjoint files, so they can proceed in parallel. + +## Considered Options + +- **Keep the structure, document it.** Cheapest, but leaves the untestable entries and the 2,000-to-3,000-line files, which is what makes the code hard to change. +- **A framework or DI container** (for the client, React or a signals library; for the server, Express or Hono). Rejected: the client is one view and the server a dozen routes; a framework adds a runtime dependency and a second mental model, and none of the problems above is one a framework solves. +- **Split by kind** (`models/`, `services/`, `utils/`). Rejected: a feature's code ends up in four folders, and a folder name says nothing about what is inside. +- **A `src/shared` folder for contracts.** Rejected for now (above); revisit if the client needs more server values than three close codes. +- **A lint plugin for import rules** (`eslint-plugin-boundaries`, `dependency-cruiser`). Rejected: a new devDependency and a second linter for six rules a 150-line script checks. + +## Consequences + +- A reader can find any behaviour from the SPEC's tree, and each file states what it is and why. +- The client wiring, the server routes and the WebSocket layer become importable and testable in-process, so coverage measures them. +- The checker keeps the structure from eroding: an oversized file or a wrong import fails `bun test`. +- The refactor is large (about 12,000 lines move). Doing it in steps that each keep the suite green, with moves separate from logic changes, keeps any regression small and bisectable. `git blame` on moved lines goes through `git log --follow`. diff --git a/docs/adrs/060.client.feature-modules.md b/docs/adrs/060.client.feature-modules.md new file mode 100644 index 0000000..2b94aef --- /dev/null +++ b/docs/adrs/060.client.feature-modules.md @@ -0,0 +1,77 @@ +# ADR 060: client — A composition root and feature modules + +**SPEC:** [architecture](../specs/architecture.md), [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +`src/client/index.ts` (565 lines) is a top-level-await script. It loads the config and the wasm, builds the terminal, fits it, runs the WebSocket lifecycle and its close-code policy, and wires about fifteen features: replay, kitty graphics, OSC 52 clipboard writes, the tab title and bell, desktop notifications, the find bar, configured key bindings, Ghostty's shortcuts, font zoom, paste, clear screen, the wheel and smooth scroll. Its problems: + +- **Nothing in it can be imported**, so none of that logic is tested: the binding matcher, the zoom clamp, close-code handling and reconnect, the per-message pipeline order, the on-open sequence, paste routing, shortcut dispatch. +- **Order is behaviour, written only in comments.** The configured-binding `keydown` listener must be registered before the shortcut listener, both capture listeners on the container, and both before the InputHandler's; the shortcut listener calls `findBinding` again to skip claimed combos. The shortcut listener uses `findBar` and `clearScreen` declared below it; the graphics `send` closure reads `ws`, assigned later. +- **Six copies of the send guard** (`ws && ws.readyState === WebSocket.OPEN`) and no connection object. +- **Listeners with no teardown**: three `visibilitychange` listeners, five window capture listeners, and `Composer` and `KittyGraphics` install listeners and rAF loops in their constructors. +- **Neighbouring modules mix concerns**: `kitty.ts` (384 lines) holds a stream splitter, a protocol parser, reply encoding, PNG sniffing, cell geometry and a control scanner; `graphics.ts` (662) holds the image store, codecs, placements, command routing and the overlay renderer; `find-bar.ts` mixes pure helpers with its DOM widget. + +## Decision + +### Feature modules + +Each user-facing behaviour is a folder under `src/client/features/` holding its objects and one installer, `<name>-feature.ts`: + +```ts +export interface Disposable { dispose(): void } +export function installTabTitle(ctx: ClientContext, deps: TabTitleDeps): TabTitleFeature // extends Disposable +``` + +- `ClientContext` carries what every feature may use: `term`, `container`, `config`, `terminalTheme`, `mac`, `doc`, `win`. +- `deps` carries the handles of other parts the feature needs (the connection, the fitter, the find bar, `clearScreen`), passed explicitly by the root. A feature that others need returns a small API next to `dispose` (the layout feature returns `{ fit, schedule }`). +- An installer only wires: it registers listeners through `listen()` into a `DisposableStore` and calls objects that hold the logic. Its `dispose()` removes everything it added and is idempotent. +- Features never import each other. + +Infrastructure that is not a feature has its own folders: `app/` (the root, context, config loader, terminal options, platform helpers, the browser environment adapter), `connection/` and `layout/`. + +### The composition root + +`app/start-client.ts` exports `startClient(env)`, which runs the steps listed in the SPEC, in today's order, as straight-line code with numbered comments, and returns one disposer that disposes every feature in reverse order. `index.ts` keeps the top-level `await` (so a failure still leaves the page blank, as today) and does nothing but `await startClient(browserEnv())`. A test runs `startClient` on fakes and records each listener's target, type, phase and position, so a change of order fails a test, not a user. + +### One key dispatcher + +The binding listener and the shortcut listener become one container capture `keydown` listener (`key-dispatcher.ts`), registered where the first one is today. When a configured binding matches it does what the binding listener does (`preventDefault`, `stopPropagation`, scroll to the bottom and send its chars when the socket is open) and returns; otherwise it runs the shortcut commands with today's `stopImmediatePropagation` and `performable` rules. This is equivalent: `stopPropagation` never stopped the second listener on the same node, which is why that listener re-checked the binding. A case-by-case test compares the dispatcher with the old pair (binding, empty binding, each shortcut with the find bar open and closed, performable with nothing to do, paste with and without the clipboard API). Font zoom stays a separate window capture listener so it works without focus, and still calls `fit()` synchronously. + +Shortcut actions go through a **command map** (`shortcut-commands.ts`): action → handler returning whether it was done, so dispatch is a table lookup and each command is tested alone. + +### PtyConnection + +`connection/pty-connection.ts` owns the WebSocket: the URL (`cols`, `rows`, `flow=1`), connect, the close-code policy (`close-policy.ts`, pure: 4003 resets the terminal and reconnects at once, 4001 and 1001 close the tab, anything else retries after 2 s), status lines (`status-line.ts`, pure), and one `send` with the single open guard. It takes the `WebSocket` constructor and timers by injection. Hooks that must run before any message (`replay.connected()`, `clipboard.connected()`) are called synchronously when the socket is created; on open it calls `graphics.reset()`, sends the size and calls `resyncFocus()`, in that order. + +### The output pipeline stays a direct call sequence + +`output-pipeline.ts` builds the per-message handler once: `replay.output` → `graphics.feed` → `replay.parsed` → `ack.parsed` → `smoothScroll.onOutput`. No event bus, no array of subscribers to iterate, no allocation per message; a test pins the order. The ack still follows the parse (ADR 048). + +### Splits of the existing modules + +- `ack.ts` → `connection/output-ack.ts`, `replay.ts` → `connection/replay-window.ts`, `fit.ts` → `layout/fit-insets.ts`, `edge.ts` → `layout/edge-rects.ts` + `layout/edge-extender.ts`, `theme.ts` → `app/terminal-theme.ts`, `shortcuts.ts` → `features/keyboard/shortcut-map.ts`, `clipboard-write.ts` → `features/clipboard/clipboard-writer.ts`, `compose.ts` → `features/compose/composer.ts`. +- `notifications.ts` → `desktop-notifier.ts` (policy) + `browser-notifications.ts` (adapter and `activates`). +- `find-bar.ts` → `find-bar.ts` (view, takes `document`) + `find-format.ts` + `find-keys.ts`. +- `wheel-stream.ts` → `wheel-classifier.ts` (with the VS Code MIT notice) + `wheel-stream.ts`; `smooth-scroll.ts` → `smooth-scroll.ts` + `scroll-split.ts`. +- `kitty.ts` → `features/kitty/protocol/` (seven files by concern); `graphics.ts` → `features/kitty/graphics/` (`image-store`, `image-decoder`, `placements`, `kitty-limits`, `overlay-canvas`, and the `kitty-graphics` facade whose `feed` hot path is unchanged). +- The terminal-canvas selectors are named once in `layout/terminal-canvas.ts`, keeping each call site's exact string (unifying them would change which canvas some sites find). +- `Composer`, `EdgeExtender` and the kitty overlay gain an idempotent `dispose()`. Nothing calls it at runtime except the root's disposer, which a page never runs, so behaviour does not change. + +## Considered Options + +- **An event bus between features.** Rejected: it hides who reacts to what, and on the per-message path it adds a fan-out and allocations. +- **A command registry resolved by name at run time.** Rejected in favour of explicit handles: the root already knows every feature, and passing handles keeps every dependency visible in one file and checked by `tsc`. +- **Keep two key listeners, ordered by registration.** Workable, but the ordering contract stays implicit; one dispatcher states it in code, and the equivalence test proves nothing changed. +- **A UI framework.** Rejected (ADR 059). + +## Consequences + +- `index.ts` drops to about 15 lines; the wiring is testable and tested. +- Each feature can be read, tested and removed alone; a new feature is a folder and one line in the root. +- The order that matters is in one file and pinned by tests. +- About 2,500 lines move; the moved modules' tests move with them, and `index.ts` imports are updated in each step. diff --git a/docs/adrs/061.client.vendor-split.md b/docs/adrs/061.client.vendor-split.md new file mode 100644 index 0000000..8306e5b --- /dev/null +++ b/docs/adrs/061.client.vendor-split.md @@ -0,0 +1,80 @@ +# ADR 061: client — Splitting the vendored ghostty-web layer, provenance kept + +**SPEC:** [architecture](../specs/architecture.md), [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +`src/client/vendor/ghostty-web/` started as Coder's ghostty-web `lib/` at `1858a59` (MIT, ADR 041) and has since been rewritten around Ghostty's official libghostty-vt (ADR 042, ADR 047) and twenty-odd later ADRs. Five files hold most of it: + +- `ghostty.ts` (3061): every wasm call — engine types, the export typing, the manifest reader, about 320 lines of layout tables, the loader, the scratch allocator and `OfficialTerminal` (about 1,770 lines covering render state and row decoding, the scrollback page cache, selection, search, the mouse, focus and paste encoders, the wasm callbacks and effects), and `OfficialKeyEncoder`. +- `terminal.ts` (2147): the `Terminal` facade with about 45 private fields and 9 emitters, doing options, DOM setup, focus tracking, the write pipeline, find, clear screen, the viewport, wheel routing, mouse and links, the scrollbar animation, the render loop and teardown. +- `renderer.ts` (1508): contracts, the default theme, DPR helpers, band planning, row repainting, cell painting, the cursor and its blink timer, and the scrollbar. +- `input-handler.ts` (856): the key table, key encoding, IME de-duplication, mouse reports; a seven-argument constructor. +- `selection-manager.ts` (736): selection state, gestures, auto-scroll, the context-menu textarea and a three-way clipboard fallback. + +Collaborators reach each other through `any` (`(terminal as any).wasmTerm`, `.renderer`, `.getViewportY`), type-only import cycles (`buffer` ↔ `terminal`, `selection-manager` ↔ `renderer` ↔ `terminal`), and duplicated logic (the scrollbar geometry in both the renderer and the terminal's hit test; the inclusive cell-range test three times). Tests reach private members through 54 `as unknown as {…}` casts. The licence trail is `PROVENANCE.md`, whose "Local changes" log is keyed by file name. + +The layer has hot paths: per-cell decode (`decodeRow`, `readStyle`, `readGrapheme`) and per-cell paint (`renderCellText`) run for every dirty cell every frame, and the binding has a memory rule: no view of wasm memory outlives an export call. + +## Decision + +### Facades stay at their paths, parts move into subfolders + +`ghostty.ts`, `terminal.ts`, `renderer.ts`, `input-handler.ts` and `selection-manager.ts` stay where they are as facades: their public classes and re-exports, under the limits in the SPEC. Their parts move into `engine/`, `core/`, `render/`, `input/` and `selection/` (the SPEC lists every file). Keeping the facades in place means the barrel, every deep import in tests and `git blame` on the facade keep working, and the PROVENANCE log stays keyed by names that still exist. The root `LICENSE`, `LICENSE.ghostty-vt-js`, `PROVENANCE.md` and `fn-table.ts` do not move, so `scripts/build.ts` reads the same paths. `vendor/ghostty-vt/` does not move. + +The webtty-original modules already in the tree (`glide.ts`, `wheel.ts`, `mouse-route.ts`, `search.ts`) stay in it: the vendored code uses them, and it must not import app code. The app reaches them, and `watchDevicePixelRatio`, through the barrel instead of deep paths. + +### Shared engine state is explicit + +`OfficialTerminal` becomes a facade over per-feature bindings (`render-state`, `scrollback-pages`, `selection-binding`, `search-binding`, `mouse-encoder`, `paste-focus-encoders`, `engine-effects`, `key-encoder`) that share one `EngineContext` (`ex`, the layout, the scratch allocator, the terminal handle, the generation counter, and `dv()` / `u8()`, which build memory views per call and are never stored in a field). Each binding declares its own scratch slots, so the 40-slot table is no longer written twice. The wasm callbacks are still installed through `FnTable` and uninstalled in `free()`, and still never re-enter `vt_write`. `resolveOfficialLayout` returns typed per-feature sub-objects instead of a `ReturnType<…>` blob. + +### Narrow host interfaces break the cycles + +Collaborators depend on interfaces in `core/host-types.ts`, `engine/engine-types.ts` and `render/renderable.ts` (`IViewportHost`, `IBufferSource`, `SelectionHost`, `SelectionSource`), never on the facade class as a value. `buffer.ts`, `selection-manager.ts` and `addons/fit.ts` stop using `any` to reach into `Terminal`. + +### Duplicates become one module + +`render/scrollbar-geometry.ts` serves both the scrollbar painter and the terminal's hit test; `render/cell-range.ts` is the one range shape and inclusive test, used by the renderer and `LinkDetector`; the two link providers share one `openLinkOnModifier`; the three IME de-duplicators become one `EchoGuard` with an injected clock. + +### Hot paths are kept as they are + +`render-state.ts` keeps the hoisted locals, the reused `Uint32Array`, the per-row style cache and the generation-gated refresh in one unit (the one file allowed 700 lines). The painters take the target context as a parameter instead of swapping `this.ctx` for the scratch canvas, and every painter bumps the one draw counter that `visibleArea.version` (and so the edge strips) depends on. Painter methods stay monomorphic and allocate nothing per cell. The steps that touch these paths record `official-replay.test.ts` timings before and after in the commit message. + +### Order inside the layer is kept + +Listener targets, phases, `capture` flags and registration order in `open()` are unchanged (webtty's own capture listeners depend on them); the render loop keeps `beforeRender` → find step → render → drain replies → anchor the textarea; `writeInternal` keeps clear-at-ground → write → drain replies → focus-mode edge → effects → link cache → auto-scroll and echo render. Timer values (16 ms mouse throttle, 530 ms cursor blink, 50 ms auto-scroll, 100 ms de-duplication window, 10 ms context-menu delay) move to named constants with the same values. + +### Provenance + +- **Headers.** Every file holding Coder-derived code starts with `// Derived from coder/ghostty-web lib/<original>.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/).` Every webtty-original file starts with `// webtty (ADR NNN), not from upstream.` `(webtty, ADR NNN)` tags move with their lines. +- **`PROVENANCE.md`** gains a **Moves and splits** table (original file and symbol group → new file), appended by each split commit; a **webtty-original files** list; and a **Layout** note per subfolder. The "Local changes" bullets stay, with the new file names added. The note "diff against the commit that added them" is extended: moved lines are followed with `git log --follow` on the facade and the Moves table. +- `fn-table.ts` keeps its ghostty-vt-js header and licence and is not merged into Coder-derived files. + +### Lint scope and tests + +The vendored tree stays excluded from Biome (one rule; moved Coder code is not reformatted) and stays under `tsc --strict`; the architecture checker applies the size and import rules to it anyway. Its tests move to `src/client/vendor/tests/`, mirroring the subfolders, with a shared `support/wasm.ts` (one wasm path, one cached compiled module), and stay Biome-linted. + +### Small cleanups that come with the split + +- `InputHandler` takes one options object (its own commit; the call sites in `terminal.ts` and the tests change with it). +- Dead code goes: `keypressListener`, `SelectionManager.requestRender` (a no-op called six times), the unused `rect` in `prepareContextMenu`, the empty `handleCompositionUpdate`. +- `Ghostty.load(path)` keeps its signature; loading moves to `engine/wasm-loader.ts` with its default URL corrected for the new depth (`../../ghostty-vt/…`). +- The barrel's stale header (`@cmux/ghostty-terminal`) and the usage comment in `terminal.ts` are fixed. + +## Considered Options + +- **Move everything into subfolders, facades included** (`terminal/terminal.ts`, `render/canvas-renderer.ts`). Cleaner tree, but breaks every deep import and blame on the most edited files at once, and the PROVENANCE log would name files that no longer exist. Rejected for the facades; adopted for their parts. +- **Move the webtty-original modules out of `vendor/`.** Makes ownership plain, but the vendored code would then import app code, inverting the layer. Rejected; the headers and PROVENANCE mark them instead. +- **Lint the whole vendored tree with Biome.** Would reformat every Coder line and bury the real diffs. Rejected. +- **Leave the vendored layer as it is** and refactor only the app. Rejected: these five files are most of the client and most of its untested code. + +## Consequences + +- No vendored file is over 600 lines (700 for the decode hot path, data tables exempt), and each has one job. +- Controllers with injected rAF, timers and DOM can be tested without a browser; `terminal.ts` coverage (53% of lines today) can rise to the layer's target. +- The licence boundary stays readable file by file, and the Moves table keeps the upstream diff traceable. +- The split is the riskiest part of the refactor, so it comes after the app-layer work and is done one facade at a time, each step leaving the replay, raster and terminal tests green. diff --git a/docs/adrs/062.server.composition.md b/docs/adrs/062.server.composition.md new file mode 100644 index 0000000..984f65f --- /dev/null +++ b/docs/adrs/062.server.composition.md @@ -0,0 +1,54 @@ +# ADR 062: server — createServer, a session store and a route table + +**SPEC:** [architecture](../specs/architecture.md), [webtty](../specs/webtty.md), [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +- `src/server/index.ts` does its work at import time (config, port, wasm lookup, servers, signals, listen), so tests can only run the server as a subprocess. `routes.ts`, `websocket.ts` and `index.ts` therefore never appear in coverage, and paths such as a spawn error in `/execute` are not tested deterministically. +- `routes.ts` (485 lines) is one 400-line `handleRequest` if-chain over ten route groups. The 413/400 body-error mapping is written three times, JSON responses are hand-written about 25 times, and the `/execute` child-process streaming and the `/publish` NDJSON splitter sit inside it. +- State is module-global: `sessionRegistry`, an exported `let lastUsedId` mutated through a setter, and `onLastSessionClosed` in `websocket.ts`. `routes.ts` renames and deletes by mutating the map directly. +- `websocket.ts` mixes close codes, session close and broadcast, the ANSI banner and runner detection, PTY spawn and exit, and the client message protocol; the size clamp is written twice. +- `src/config.ts` (369) mixes schema, defaults, file I/O, binding merge rules and a long validator; adding a key touches five places, two of them outside the file. +- `src/pty/index.ts` takes seven positional arguments, reordered by a wrapper. `isBun` has three definitions. +- `src/utils.test.ts` is a helper module that `bun test` also runs as a test file. + +## Decision + +### createServer + +`src/server/app.ts` exports `createServer({ loadConfig, store, spawnPty, paths, onStop })`, returning `{ httpServer, wss, shutdown }`. `src/server/index.ts` stays the entry (so `dist/server/index.js` and every script that names it are unchanged) and only reads the environment, calls `createServer`, listens and installs the signal handlers. `server-paths.ts` is the one module that knows how deep `src/server` and `dist/server` sit, replacing three separate path calculations; it is tested for both layouts. + +`loadConfig` stays a function called per request and per spawn, injected rather than imported, so editing the config file still takes effect on the next tab reload (ADR 009) and tests pass a fixed config. + +### SessionStore + +`sessions/session-store.ts` is a class that owns the map, `lastUsedId`, `create`, `get`, `rename`, `remove`, `findByPid`, `list`, `size` and an `onEmpty` callback (the old "last session closed" handler). One instance is created in `index.ts` and passed in; every reader of `lastUsedId` switches over in the same commit. `sessions/session.ts` holds a session's PTY lifecycle (spawn on first connect, the scrollback ring, exit handling, close, broadcast), moved out of the WebSocket handler. Its order is kept exactly: on connect, the banner, then the scrollback, then `pty.write('\n')`; a reconnecting client gets the replay before the resize; `output.add` precedes the spawn; on exit, the registry entry is removed, then `output.end`, then `onEmpty` after the flush. + +### Routes + +- `http/router.ts` is a small ordered table (method, pattern, handler); the first match wins, in the old if-chain's order, so precedence is unchanged. An unsupported method still falls through to 404, and CORS headers are on every response. +- Handlers are grouped by resource in `routes/` (`server-routes`, `config-routes`, `session-routes`, `page-routes`, `asset-routes`, `publish-route`, `execute-route`), with `run-command.ts` taking `spawn` by injection. +- `http/respond.ts` and `http/request-body.ts` hold the JSON, text and CORS helpers and the single 413/400 mapping. `src/ndjson.ts` (next to `runtime.ts` and `version.ts`, the root modules the server and CLI share) holds a `LineSplitter` used by `/publish` and by the CLI's `run` (ADR 063). +- Before any route moves, golden tests pin every route's status, body and headers (CORS on a 404, `X-Sessions-Remaining`). + +### WebSocket layer + +`ws/close-codes.ts` (the `WS_CLOSE` constants), `ws/ws-upgrade.ts` (the `noServer` upgrade router), `ws/pty-socket.ts`, `ws/events-socket.ts` and `ws/client-message.ts` (pure: `parseClientMessage`, which keeps the `startsWith('{')` check before `JSON.parse`, and `clampSize`, the one copy of the 1..1000 × 1..500 clamp with the 80 × 24 default). The banner is the pure `sessions/session-banner.ts`. The per-chunk path (PTY `onData` → scrollback → `OutputPacer.broadcast`) gains no emitter, spread or allocation. + +### Config and runtime + +- `src/config.ts` becomes `src/config/` with `index.ts` re-exporting what it exports today, so `../config` imports resolve unchanged: `config-schema.ts` (types, `ClientConfig`, `CLIENT_CONFIG_KEYS`, including the legacy `scrollback` key the server still sends), `config-paths.ts`, `defaults.ts`, `parse-config.ts` (pure, one table-driven test per key), `keyboard-bindings.ts` and `client-view.ts` (`toClientConfig`). +- `src/runtime.ts` replaces the three `isBun` definitions with honestly named predicates, one per existing meaning, so no call site changes behaviour. +- `src/pty` takes one options object (`spawnPty(options)`), keeps its `pty: …` log line, and the node-pty backend takes the module by injection so its mapping is tested. +- `flow.ts` becomes `output-pacer.ts` (a `git mv`; it is already clean); `static.ts` becomes `http/static-files.ts`. +- `src/utils.test.ts` becomes `src/test-support/server-harness.ts`. + +## Consequences + +- Routes, WebSocket channels and sessions run in-process on port 0 in tests, so they count in coverage and edge cases become deterministic; two or three subprocess tests remain as end-to-end checks. +- A new route is a handler and a line in the table; a new config key is the schema, the default, its parse rule and (if the client needs it) the key list, all in `src/config/`. +- Golden tests written before the move make any change in a response visible. diff --git a/docs/adrs/063.cli.command-modules.md b/docs/adrs/063.cli.command-modules.md new file mode 100644 index 0000000..eacf3d3 --- /dev/null +++ b/docs/adrs/063.cli.command-modules.md @@ -0,0 +1,29 @@ +# ADR 063: cli — Command modules with injected dependencies + +**SPEC:** [architecture](../specs/architecture.md), [cli](../specs/cli.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +- `src/cli/index.ts` parses `argv` and dispatches at import time; `--dir` handling and the `go` aliases are not tested in-process. +- `src/cli/commands.ts` (369) holds nine commands and the byte formatters for `key`. Each calls `fetch`, `console` and `process.exit` directly; `go` and `run` repeat "ensure the server, then GET or POST the session". `run` is about 110 lines covering two modes (warm-up and exec) and re-implements NDJSON line splitting. +- `src/cli/http.ts` is misnamed: it holds the config-derived URL helpers, server process management (with its own src/dist path arithmetic) and the browser opener. +- `commands.test.ts` (1020 lines) spies on module namespaces (`spyOn(httpModule, …)`), which depends on ESM live bindings and breaks when a module is split. + +## Decision + +- **`main.ts`** (`main(argv, deps)`) is the CLI's composition root; `index.ts` stays the entry (`dist/cli/index.js`, the `bin`) and only calls it. `cli-args.ts` (`parseArgs`, pure, including `--dir` at the end of `argv`) and `help-text.ts` are pure. +- **`CliDeps`** (`cli-deps.ts`): `{ client, out, err, exit, spawn, config }`, built once from the real globals in `index.ts`. Every command takes `(args, deps)`, so tests capture output and the exit code without subprocesses or namespace spies. +- **`api-client.ts`**: `WebttyClient(baseUrl, fetch)` with `isRunning`, `getSession`, `ensureSession` (the one copy of ensure-or-create), `listSessions`, `removeSession`, `renameSession`, `stop` and `execute` (an async iterator of NDJSON messages over a `LineSplitter`). +- **`server-process.ts`**: `resolveServerEntry({ runningFromSrc, useNode })` (pure, four cases tested), `startServer`, `stopServer`. **`browser-opener.ts`**: `openBrowser`, `toBrowserHost`. +- **`commands/`**: one file per command (`go`, `list`, `remove`, `rename`, `start-stop`, `edit-config`, `run`, `key`) plus `key-format.ts`; `run.ts` is two functions, `warmUp` and `exec`. +- `commands.test.ts` and `http.test.ts` split by command and migrate from namespace spies to injected deps in the same commits as the split. +- Output text, exit codes, flags and aliases stay byte-identical. Tests that start a server keep a temporary `HOME` so `getPort()` never falls back to the user's configured port. + +## Consequences + +- Each command is a short file testable in-process, and coverage counts the CLI. +- The CLI's HTTP calls are in one client, so a route change on the server has one place to follow on the CLI side. diff --git a/docs/adrs/064.architecture.test-strategy.md b/docs/adrs/064.architecture.test-strategy.md new file mode 100644 index 0000000..c7317ec --- /dev/null +++ b/docs/adrs/064.architecture.test-strategy.md @@ -0,0 +1,35 @@ +# ADR 064: architecture — Test strategy and coverage targets + +**SPEC:** [architecture](../specs/architecture.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +`bun test --coverage` reports 89.6% of lines and 82.5% of functions, but only over the files loaded in-process. The client wiring (`index.ts`), `compose.ts`, the server routes, WebSocket layer and entry, the CLI entry and the node-pty backend are not in the table at all. DOM code is the weak spot: `find-bar.ts` 27% of lines, `edge.ts` 52%, vendored `terminal.ts` 53%, `selection-manager.ts` 61%, `url-regex-provider.ts` 8%. There is no DOM in the test runner; tests stub `globalThis.document` and `window` by hand, each vendor test file repeats the wasm path and compiles the wasm again, and 54 casts reach private members. + +## Decision + +- **Design for tests.** Globals come in through constructors (ADR 059); entries are thin (ADR 060, 062, 063); pure logic is split from DOM and I/O. +- **Placement.** Tests sit next to their module as `x.test.ts`; vendor tests live in `src/client/vendor/tests/`, mirroring the vendored subfolders, with `fixtures/` beside them. Shared helpers live in `test-support/` folders (`src/test-support/`, `src/client/test-support/`, `src/client/vendor/tests/support/`) and are never named `*.test.ts`. +- **Fakes**: `FakeSocket`, `ManualClock` (timers and animation frames), `FakeTerminal` (the slice of `Terminal` features use), `RecordingTarget` (records `addEventListener` and `removeEventListener` with their options), a fake canvas and a fake event target for the vendored layer, a fake `fetch` and a fake PTY for the server and CLI. +- **DOM: `happy-dom`**, a pinned devDependency (`20.14.5`, exact), used only as an explicit `new Window()` per test whose `document` (and, where needed, `window`) is injected into the code under test. It is never registered globally or preloaded, so the existing tests that stub globals, and the wasm tests, run exactly as before. It is a devDependency only; nothing in `dist/` changes. If a case needs real layout (sizes), the test injects the measurements instead. +- **Server and CLI in-process.** Routes, WebSocket channels and sessions are tested through `createServer()` on port 0 with a temporary `HOME`; commands through injected `CliDeps`. Two or three subprocess tests remain to check the entries. No test reads the user's configuration or uses a fixed port. +- **Vendor.** One cached `loadGhostty()` and one wasm path in `vendor/tests/support/wasm.ts`. New tests use the collaborators' public seams instead of private-member casts; existing casts are rewritten as the state they reach moves. +- **Required tests by kind.** Pure modules: table-driven, all branches. Disposables: `dispose()` removes everything it added, and a second call does nothing. Order-sensitive wiring: an order test (client install order, the per-message pipeline, the on-open sequence, the key dispatcher's equivalence with the old listeners, the server's route precedence). +- **Coverage targets**, enforced at the end of the refactor through `coverageThreshold` in `bunfig.toml` for `bun run test:coverage`: app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. +- **Structure** is tested too: `scripts/check-architecture.test.ts` (ADR 059). + +## Considered Options + +- **Hand-written DOM fakes only.** No dependency, but a fake rich enough for `FindBar`, `Composer`, `Terminal.open` and the selection gestures would be a small DOM of our own to maintain. Kept for the few cases where a fake is simpler (event targets, canvases). +- **happy-dom as a global preload** (upstream ghostty-web's setup). Rejected: it changes what every test sees, including those that stub globals today. +- **A browser runner** (Playwright). Rejected for unit tests: slow, a large dependency, and the logic under test does not need a real browser. + +## Consequences + +- Coverage measures the whole code base, and the targets are concrete. +- DOM views and features get real tests without changing how the existing suite runs. +- The suite stays fast: one wasm compile per process for the vendor tests, in-process servers instead of subprocesses. diff --git a/docs/adrs/065.client.listener-teardown.md b/docs/adrs/065.client.listener-teardown.md new file mode 100644 index 0000000..c9fa377 --- /dev/null +++ b/docs/adrs/065.client.listener-teardown.md @@ -0,0 +1,29 @@ +# ADR 065: client — Release every listener on dispose + +**SPEC:** [architecture](../specs/architecture.md), [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +The vendored `Terminal` and `SelectionManager` add and remove their listeners by hand, as pairs of nullable fields, and some pairs are broken: + +- `Terminal.dispose()` sets `isOpen = false` before calling `cleanupComponents()`, which removes the document and window listeners only `if (this.isOpen && …)`. After `dispose()`, the document `mouseup`, the window `focus` and `blur` and the document `visibilitychange` listeners stay attached and keep the terminal alive. +- The anonymous `beforeinput` listener on the container and the canvas `mousedown` and `touchend` listeners are never removed. +- `SelectionManager` adds a document `mousedown` listener that is never removed, and its canvas listeners are anonymous, so `dispose()` cannot remove them. +- Timers are cleared in two places (`dispose` and `cleanupComponents`). + +webtty never disposes its terminal today (a page lives as long as its tab), so nothing user-visible is affected. But the refactor (ADR 060, ADR 061) makes teardown a contract that every module keeps and tests, and these leaks would make the vendored layer the exception. + +## Decision + +`Terminal`, its controllers, `InputHandler` and `SelectionManager` register every listener, timer, interval and animation frame in a `DisposableStore` (`lifecycle/disposable-store.ts`), and `dispose()` disposes the store. The targets, event types, `capture` flags and registration order stay as they are; only removal changes. A spy test checks that after `dispose()` every listener added to the container, the canvas, the textarea, the document and the window has been removed with the same options, and that a second `dispose()` does nothing. + +This is the refactor's one deliberate behaviour change, so it is its own commit (`fix(client): …`), separate from the moves. + +## Consequences + +- A disposed terminal leaves no listener behind, so a future multi-terminal page or a test can create and dispose terminals freely. +- Teardown is uniform across the app and vendored layers and tested the same way. diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md new file mode 100644 index 0000000..bb76a6c --- /dev/null +++ b/docs/specs/architecture.md @@ -0,0 +1,299 @@ +# SPEC: Architecture + +**Last Updated:** 2026-10-14 + +--- + +## Description + +How webtty's code is organised: the layers, the folders, what kind of module each file is, who may import whom, how big a file may grow, how files are named and commented, and how they are tested. The behaviour of each part is in its own SPEC ([webtty](webtty.md), [client](client.md), [cli](cli.md), [config](config.md), [kitty-graphics](kitty-graphics.md), [key-bindings](key-bindings.md)); this SPEC is the map that tells a reader where that behaviour lives in the code. + +**Why a SPEC of its own, not a section of `webtty.md`?** `webtty.md` describes the product (sessions, the REST API, flow control). The code organisation is a repo-level concern that every module shares and every change has to follow, so it gets its own living SPEC, as [development.md](../development.md) asks for a repo-level concept. + +The rules here are decided in [ADR 059](../adrs/059.architecture.code-organisation.md) (organisation and conventions), [ADR 060](../adrs/060.client.feature-modules.md) (the client composition root and feature modules), [ADR 061](../adrs/061.client.vendor-split.md) (splitting the vendored ghostty-web layer), [ADR 062](../adrs/062.server.composition.md) (server composition), [ADR 063](../adrs/063.cli.command-modules.md) (CLI command modules), [ADR 064](../adrs/064.architecture.test-strategy.md) (testing) and [ADR 065](../adrs/065.client.listener-teardown.md) (the listener teardown fix, the one deliberate behaviour change). + +## Principles + +1. **One file, one job, one name.** A file holds one class, one service, one group of pure functions on one subject, one constants table or one set of types, and is named after it. +2. **Folders name a domain, not a kind.** `connection/`, `layout/`, `sessions/`, `routes/`, `engine/`, `render/`. There is no `utils/`, `services/` or `models/` folder; a module's kind is in its header comment. +3. **Dependencies point one way** (below). Nothing imports a layer above it, and sibling features never import each other. +4. **The composition root shows the order.** Every place where order is behaviour (listener registration, install order, the per-message pipeline) is written once, as straight-line code with numbered comments, in the root that wires it: `src/client/app/start-client.ts`, `src/server/app.ts`, `src/cli/main.ts`. +5. **Globals come in through the constructor.** A class or service takes the browser or Node API it touches (`WebSocket`, timers, `requestAnimationFrame`, `document`, `navigator`, the clipboard, `fetch`, `spawn`) as a `deps` or `host` argument with a production default, so a test passes a fake. +6. **Hot paths stay direct calls.** No event bus, emitter fan-out, closure or allocation is added on: the WebSocket `onmessage` pipeline, the render loop, the per-cell decode and paint, the server's PTY `onData` and `OutputPacer`. +7. **Behaviour is frozen by a refactor.** Wire protocol, config keys, CLI output, HTTP routes and bodies, listener targets, phases and order stay byte-identical. A behaviour change is its own commit with its own ADR. +8. **No framework, no new runtime dependency.** Plain TypeScript classes and functions; one pinned devDependency (`happy-dom`, ADR 064) for DOM tests. + +## Layers and dependency rules + +``` +src/cli ──────────────┐ +src/server ──► src/pty│──► src/config (node) ──► (types only) ◄── src/client + └──────► src/runtime.ts, src/version.ts, src/ndjson.ts (shared by server and CLI) + +src/client: + index.ts (entry) + └► app/start-client.ts (composition root) + └► features/* ──► connection/, layout/, app/ (context, platform, client-config) + └► vendor/ghostty-web (the barrel index.ts only) + └► vendor/ghostty-web/** internals ──► vendor/ghostty-vt (wasm, by URL) +``` + +| Rule | Checked by | +|---|---| +| `src/client/**` imports no `node:*` module and nothing from `src/server`, `src/cli` or `src/pty`. It may import **types only** from `src/config` (`import type`, erased from the bundle by `verbatimModuleSyntax`). | `scripts/check-architecture.ts` | +| `src/server`, `src/cli`, `src/pty`, `src/config` never import `src/client`. `src/cli` never imports `src/server` (it starts it as a process). | same | +| `src/client/vendor/ghostty-web/**` imports nothing outside itself. | same | +| App code (`src/client/**` outside `vendor/`) imports vendor code only through `vendor/ghostty-web` (the barrel), never a deep path. Tests may deep-import. | same | +| `src/client/features/<a>/**` never imports `src/client/features/<b>/**`. Features reach each other only through handles the composition root passes in. | same | +| Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`), never the facade class as a value, so no runtime import cycle can form. | review + `tsc` | +| A file under a `test-support/` folder is never named `*.test.ts`. | same | + +**Contracts across the client/server line.** The config types are defined once, in `src/config`: the client derives `ClientConfig` from them (`Pick<Config, …>`) with `import type`, so the three copies of today (client type, server whitelist, server type) become one type and one key list (`CLIENT_CONFIG_KEYS`, server side) pinned by a test. Wire constants that the client needs as values (the WebSocket close codes 1001, 4001, 4003) are mirrored in `src/client/connection/close-codes.ts` and pinned to the server's by a contract test that imports both (tests may cross the line; code may not). + +## Folder tree + +The target tree. Files marked *(entry)* keep their path because `scripts/build.ts`, `package.json` and the tests name them; `dist/` names (`client-browser.js`, `client.css`, `client.html`, `server/index.js`, `cli/index.js`, `ghostty-vt-small.wasm`, `THIRD_PARTY_LICENSES`) never change. + +``` +src/ + version.ts util: package version lookup + runtime.ts util: isBun, isWindows, bunTerminalAvailable, packageRunner (one definition per meaning) + ndjson.ts object: LineSplitter (server /publish and CLI run) + test-support/server-harness.ts test util: getFreePort, waitForServer*, makeTmpHome (was src/utils.test.ts) + config/ node; server + cli; the client imports types only + index.ts service: loadConfig, initConfig, re-exports the types (keeps `../config` imports working) + config-schema.ts types: Config, Theme, KeyboardBinding, RightClickBehavior, ClientConfig, CLIENT_CONFIG_KEYS + config-paths.ts util: configDir, getConfigPath + defaults.ts constants: DEFAULT_THEME, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_CONFIG + parse-config.ts util (pure): parseConfig(raw), parseRightClickBehavior + keyboard-bindings.ts util (pure): isValidBinding, normalizeBinding, mergeKeyboardBindings + client-view.ts util (pure): toClientConfig(config) over CLIENT_CONFIG_KEYS + pty/ + index.ts facade: spawnPty(options), backend choice (keeps the 'pty: …' log line) + pty-process.ts types: PtyProcess, PtySpawnOptions (was types.ts) + bun-backend.ts node-backend.ts node-pty.d.ts + server/ + index.ts (entry) main(): env and PORT, createServer, listen, signals + app.ts factory: createServer({ loadConfig, store, spawnPty, paths, onStop }) → { httpServer, wss, shutdown } + server-paths.ts util: projectRoot, clientRoot, wasmPath (the one place that knows src vs dist depth) + output-pacer.ts object: OutputPacer, splitChunks, DEFAULT_FLOW_LIMITS (was flow.ts) + http/ + router.ts util: ordered method + pattern table (declaration order = the old if-chain order) + respond.ts util: sendJson, sendText, CORS_HEADERS + request-body.ts util: readJson (64 KB) and its single 413/400 mapping, decodeId + static-files.ts service: mimeType, serveFile + routes/ + route-table.ts wiring: the ordered route list + server-routes.ts config-routes.ts session-routes.ts page-routes.ts asset-routes.ts publish-route.ts execute-route.ts + run-command.ts service: child process → NDJSON (spawn injected) + sessions/ + session-store.ts object: SessionStore (map, lastUsedId, rename, remove, findByPid, onEmpty) + session.ts object: Session (PTY lifecycle, scrollback ring, close, broadcast) + session-id.ts util: ID_RE, isValidId, generateId + session-banner.ts util (pure): sessionBanner(runner) + ws/ + close-codes.ts constants: WS_CLOSE + ws-upgrade.ts wiring: noServer upgrade router + pty-socket.ts handler: a client socket attached to a Session + events-socket.ts handler: event subscribers + client-message.ts util (pure): parseClientMessage (keeps the startsWith('{') fast path), clampSize + cli/ + index.ts (entry) await main(process.argv) + main.ts composition root: parse args, build deps, dispatch + cli-args.ts help-text.ts util (pure) + cli-deps.ts types + factory: CliDeps { client, out, err, exit, spawn, config } + api-client.ts service: WebttyClient(baseUrl, fetch) + server-process.ts service: resolveServerEntry (pure), startServer, stopServer + browser-opener.ts util: openBrowser, toBrowserHost + commands/ one file per command: go, list, remove, rename, start-stop, edit-config, run, key; key-format.ts + client/ + index.ts (entry) ~15 lines: await startClient(browserEnv()) + client.html index.css (paths fixed: copied by scripts/build.ts) + app/ + start-client.ts composition root: bootstrap, install features in the documented order, connect + browser-env.ts adapter: the real window, document, navigator, fetch, WebSocket, timers, rAF + client-context.ts types: ClientContext, Feature, Disposable + client-config.ts service + type: ClientConfig (from src/config types), loadClientConfig(fetch) + terminal-options.ts util (pure): toTerminalOptions(config, theme) + terminal-theme.ts util (pure): toTerminalTheme (was theme.ts) + platform.ts util: detectMac(navigator), isTerminalFocused(doc, container) + connection/ + pty-connection.ts service: PtyConnection (URL, connect, reconnect, send, sendResize, isOpen; WebSocket ctor and timers injected) + close-codes.ts constants: the close codes the client acts on (pinned to the server's) + close-policy.ts util (pure): closeActionFor(code) + status-line.ts util (pure): the "[ webtty ]" ANSI status line + output-pipeline.ts util: createOutputHandler(…) – the fixed per-message call order + output-ack.ts object: OutputAck (was ack.ts) + replay-window.ts object: ReplayWindow (was replay.ts) + layout/ + fitter.ts service: Fitter { fit(), schedule(), onFitted } (rAF coalesced) + fit-insets.ts util (pure): computeInsets (was fit.ts) + layout-feature.ts feature: ResizeObserver, window resize, fonts.ready, DPR watch, padding colour + repaint-on-visible.ts feature: the Chromium canvas repaint on visibility (ADR 031) + terminal-canvas.ts constants + util: the canvas selectors (today's exact strings) and terminalCanvas() + edge-rects.ts util (pure): computeEdgeRects + edge-extender.ts view: EdgeExtender (+ dispose) + features/ + tab-title/ tab-title.ts (object), tab-title-feature.ts + notifications/ desktop-notifier.ts (object), browser-notifications.ts (adapter + activates), notifications-feature.ts + clipboard/ clipboard-writer.ts (object, was clipboard-write.ts), clipboard-feature.ts + paste/ non-text-paste.ts (util), paste-service.ts (service), paste-feature.ts + find/ find-bar.ts (view), find-format.ts (util), find-keys.ts (util), find-feature.ts + keyboard/ key-bindings.ts, shortcut-map.ts (was shortcuts.ts), shortcut-commands.ts, font-zoom.ts, key-dispatcher.ts (feature), font-zoom-feature.ts + compose/ composer.ts (view + dispose), composer-geometry.ts (util) + scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, scroll-feature.ts + kitty/ + protocol/ apc-splitter.ts, apc-command.ts, chunk-assembler.ts, kitty-reply.ts, png-size.ts, cell-geometry.ts, control-scanner.ts + graphics/ image-store.ts, image-decoder.ts, placements.ts, kitty-limits.ts, overlay-canvas.ts (+ dispose), kitty-graphics.ts (facade) + kitty-feature.ts + test-support/ fake-terminal.ts, fake-socket.ts, manual-clock.ts, recording-target.ts, dom-window.ts + vendor/ + ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md + tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/) + support/ wasm.ts (the one WASM path, a cached loadGhostty()), fake-canvas.ts, fake-event-target.ts + fixtures/ PTY captures (was vendor/fixtures/) + ghostty-web/ Biome-excluded; see "The vendored layer" +scripts/ + build.ts unchanged outputs + check-architecture.ts size and import rules (library) + check-architecture.test.ts + update-ghostty-vt.ts CLI only (--commit, --verify) + ghostty-vt/ minisign.ts, sidecar.ts, inspect-wasm.ts, release-fetch.ts (fetch injected) +``` + +### The vendored layer + +`src/client/vendor/ghostty-web/` keeps its root, its `LICENSE`, `LICENSE.ghostty-vt-js`, `PROVENANCE.md` and `fn-table.ts`, so `scripts/build.ts` and the licence trail do not move. The five large files stay at their paths as slim **facades** (their public classes and re-exports), so the barrel, every deep import and `git blame` on the facade keep working; their parts move into subfolders (ADR 061): + +``` +ghostty-web/ + index.ts public barrel: the only path app code imports (adds Glide, Pace, watchDevicePixelRatio, DisposableStore, listen) + ghostty.ts facade: Ghostty factory class + re-exports of engine types (≤ 300) + terminal.ts facade: Terminal (xterm-compatible API, emitters, OpenedParts) (≤ 600) + renderer.ts facade: CanvasRenderer orchestration (≤ 500) + input-handler.ts facade: InputHandler (options object) (≤ 300) + selection-manager.ts facade: SelectionManager state + API (SelectionHost) (≤ 300) + interfaces.ts buffer.ts (IBufferSource) event-emitter.ts types.ts (re-export shim until removed) + search.ts glide.ts wheel.ts mouse-route.ts webtty-original, kept in place + link-detector.ts providers/ addons/fit.ts fn-table.ts + lifecycle/ disposable-store.ts (DisposableStore, listen) – webtty-original + engine/ every wasm call; no DOM + engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts + wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts scrollback-pages.ts + selection-binding.ts search-binding.ts mouse-encoder.ts paste-focus-encoders.ts engine-effects.ts + cursor-blink.ts text-mime.ts key-encoder.ts + core/ Terminal's collaborators; import core/host-types.ts, never terminal.ts + host-types.ts terminal-constants.ts terminal-geometry.ts terminal-options.ts dom-setup.ts + viewport.ts focus-tracker.ts find-controller.ts write-pipeline.ts render-loop.ts + mouse-controller.ts wheel-controller.ts scrollbar-controller.ts + render/ renderable.ts default-theme.ts device-pixel-ratio.ts frame-lines.ts cell-range.ts row-band.ts + font-metrics.ts cell-painter.ts row-repainter.ts cursor-painter.ts scrollbar-geometry.ts scrollbar-painter.ts + input/ key-types.ts key-map.ts key-encoding.ts text-input.ts (EchoGuard) mouse-report.ts + selection/ selection-coords.ts selection-gestures.ts auto-scroll.ts context-menu-textarea.ts clipboard-copy.ts +``` + +The wasm stays at `vendor/ghostty-vt/`; `engine/wasm-loader.ts` resolves its default URL as `../../ghostty-vt/ghostty-vt-small.wasm`. The client always passes `/ghostty-vt-small.wasm` explicitly. + +## Module kinds and patterns + +Every file is one of these kinds, named in its header comment. + +| Kind | What it is | Pattern | Example | +|---|---|---|---| +| **entry** | Process or bundle entry; no logic | calls the composition root | `src/client/index.ts`, `src/server/index.ts` | +| **composition root** | Builds the objects, wires them in the documented order, returns one disposer | explicit construction, no container | `app/start-client.ts`, `server/app.ts`, `cli/main.ts` | +| **feature** | Wires DOM and terminal events to objects; holds no logic beyond the wiring | `install<Name>(ctx, deps): <Name>Feature`, where `<Name>Feature extends Disposable` and adds the small API others need | `tab-title-feature.ts` | +| **service** | A class doing I/O through injected collaborators | constructor injection with production defaults | `PtyConnection`, `SessionStore`, `WebttyClient` | +| **object** | Pure state machine or value object | class, no I/O | `ReplayWindow`, `OutputAck`, `Glide` | +| **view** | A DOM widget; takes its `document` | builds its DOM in the constructor, `dispose()` removes it | `FindBar`, `Composer`, `EdgeExtender` | +| **util** | Pure functions on one subject | functions only | `close-policy.ts`, `fit-insets.ts` | +| **adapter** | Wraps one browser or Node API behind a narrow interface | function returning the interface | `browser-notifications.ts`, `browser-env.ts` | +| **constants** | Named values with their ADR | `const` exports | `close-codes.ts`, `terminal-constants.ts` | +| **types** | Interfaces and type aliases only | `export type` / `interface` | `client-context.ts`, `engine-types.ts` | +| **facade** | A public class that composes collaborators and keeps a stable API | delegation; collaborators created in one place | vendor `Terminal`, `OfficialTerminal`, `CanvasRenderer` | + +Other patterns used, each in one named place: **Command map** for shortcut dispatch (`shortcut-commands.ts`), **Strategy** for link providers and the clipboard copy fallback chain (`clipboard-copy.ts`), **ordered route table** for HTTP (`route-table.ts`), **Disposable store** for listener and timer teardown (`lifecycle/disposable-store.ts`). + +### Disposables + +`listen(target, type, fn, options)` adds a listener and returns a `Disposable` that removes it with the same `capture` flag. `DisposableStore` collects disposables and disposes them once, in reverse order; disposing twice is a no-op. Every class that adds a listener, a timer, an interval or a `requestAnimationFrame` loop owns a store and releases it in `dispose()`. + +### The client composition root + +`startClient(env)` runs these steps, in this order, written once in the file with numbered comments and pinned by a test that installs everything on fakes and records each listener's target, type, phase and order: + +1. Load the config (`/api/config`), `Ghostty.load('/ghostty-vt-small.wasm')`, `new Terminal(toTerminalOptions(…))`, colour the padding, `term.open(container)`. +2. Layout: Fitter, edge strips, DPR watch, `fonts.ready`, repaint on visibility. +3. Scroll: SmoothScroll, the wheel handler, wheel pace, `attachBeforeRender`. +4. Kitty graphics (the output writer). +5. Window capture listeners in today's order: clipboard arming, tab title and notifier (user input, attended), then font zoom (a window listener so it works without focus). +6. The key dispatcher: **one** container capture `keydown` listener. A configured binding wins (`preventDefault`, `stopPropagation`, send its chars, return); otherwise Ghostty's shortcuts run with today's `stopImmediatePropagation` and `performable` rules. It is registered after `term.open`, so it runs before the InputHandler. +7. Paste: `onPasteRequest` and the container capture `paste` listener. +8. The find bar. +9. The connection: `clipboard.connected()` and `replay.connected()` run synchronously when the socket is created; on open, `graphics.reset()`, the resize message, `resyncFocus()`. + +Features get what they need from `ClientContext` (`term`, `container`, `config`, `terminalTheme`, `mac`, `doc`, `win`) and from handles the root passes explicitly (the connection, the fitter, the find bar, `clearScreen`). There is no service locator and no event bus. + +**The per-message pipeline** is a direct call sequence, with no allocation: `replay.output(len, now)` → `graphics.feed(data)` (which writes to the terminal) → `replay.parsed(now)` → `ack.parsed(len)` (after the parse, ADR 048) → `smoothScroll.onOutput()`. + +### The server composition root + +`createServer(deps)` builds the HTTP server and the WebSocket server around one `SessionStore`, an injected `loadConfig` (still called per request and per spawn, so editing the config file takes effect on the next tab reload, ADR 009) and an injected `spawnPty`. Routes are an ordered table evaluated in the old if-chain's order; CORS headers are on every response, 404s included. + +## Size limits + +Counted in lines, comments and blank lines included. Checked by `scripts/check-architecture.ts` under `bun test`. + +| Files | Aim | Hard limit | +|---|---|---| +| App, server, CLI, config, pty, scripts source | ≤ 200 | 300 | +| Vendored ghostty-web source | ≤ 400 | 600 (facade limits in the tree above) | +| `engine/render-state.ts` (the per-cell decode hot path, kept as one unit) | — | 700 | +| Data tables: `input/key-map.ts`, `input/key-types.ts` (the `Key` enum), `engine/official-layout.ts` | — | exempt, listed with the reason | +| Test files | ≤ 600 | 800 | +| Functions | ≤ 60 lines | not checked; hot paths (`decodeRow`, `render`) say why when longer | + +While the refactor runs, the checker carries a **ratchet allowlist**: each file over its limit today, with its current line count. A listed file may shrink or disappear, never grow; each step removes the entries it fixes, and the list is empty at the end. + +## Naming + +- **Files** are kebab-case and named after their main export: `class OutputAck` → `output-ack.ts`, `findBinding` → `key-bindings.ts`. Classes and services are nouns (`SessionStore`); pure helper files are named for their subject (`close-policy.ts`, `fit-insets.ts`). +- **Feature installers** are `<name>-feature.ts` exporting `install<Name>`. +- **Narrow collaborator interfaces** are `<Thing>Host` (what an object needs from its owner) or `<Thing>Source` (what it reads). +- **Constants** sit at the top of the one module that uses them; a `*-constants.ts` file exists only when several modules share them. +- **Banned:** `utils.ts`, `helpers.ts`, `misc.ts`, `common/`, and a bare `types.ts` at a folder root (the vendor `types.ts` is a temporary re-export shim). `index.ts` is only an entry or the vendor barrel. +- **Basenames are unique** within `src/client` and within `src/server` where practical, so editor tabs and stack traces read without the path. + +## Comments + +- **File header:** one to three plain sentences: what the module is, its kind, and the ADRs behind it. Example: `// Sends ack messages as output is parsed, so the server paces a slow tab (object, ADR 048).` +- **Vendored files derived from Coder's code** start with: `// Derived from coder/ghostty-web lib/<original>.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/).` **webtty-original vendored files** start with `// webtty (ADR NNN), not from upstream.` +- **`(webtty, ADR NNN)` tags** move with the lines they describe. +- **Every export** has a JSDoc line saying what it is for, not restating its signature. +- **Inline comments** say why, in the repo's plain style with ADR references. No section banners (`// ---- Private ----`), no comments restating the code. Stale upstream text is fixed as files are touched (`@cmux/ghostty-terminal`, `Phase 2`, `Ghostty.load()` without a path). + +## Testing + +- **Placement.** A test sits next to its module as `x.test.ts`. Vendor tests live in `src/client/vendor/tests/`, mirroring `ghostty-web/` (the vendored tree is Biome-excluded, its tests are not). Shared fakes live in `test-support/` folders. +- **Pure modules:** table-driven tests, 100% lines. +- **Objects and services:** tested through their public API with fakes from `test-support/` (`FakeSocket`, `ManualClock` for timers and rAF, `FakeTerminal`, `RecordingTarget` for listeners). +- **Views and features:** `happy-dom` (pinned devDependency), used as an explicit `new Window()` whose `document` is injected; never registered globally, so tests that stub globals are not disturbed (ADR 064). +- **Teardown:** every `dispose()` has a test that it removes each listener, timer and frame it added, and that a second `dispose()` does nothing. +- **Order:** the client root has an install-order test; the per-message pipeline and the on-open sequence have call-order tests; the key dispatcher has a case-by-case equivalence test against the old two-listener behaviour. +- **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. +- **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. +- **Coverage targets** (`bun run test:coverage`, thresholds in `bunfig.toml` at the end of the refactor): app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. + +## Features + +| Feature | Description | ADR | Done? | +|---|---|---|---| +| Architecture SPEC and conventions | Layers, folders, module kinds, naming, size limits, comments | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | +| Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ⬜ | +| Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | +| Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | +| Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | +| Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | +| Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ⬜ | +| Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ⬜ | +| CLI command modules | `main.ts`, WebttyClient, one file per command with injected deps | [ADR 063](../adrs/063.cli.command-modules.md) | ⬜ | +| Test strategy | test-support fakes, injected happy-dom, in-process server tests, vendor test mirror, coverage thresholds | [ADR 064](../adrs/064.architecture.test-strategy.md) | ⬜ | diff --git a/docs/specs/cli.md b/docs/specs/cli.md index d1c8a9e..93819f9 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -1,6 +1,6 @@ # SPEC: CLI -**Last Updated:** 2026-09-20 (amended: --version flag, help formatting, ls filter, restart removed, at/mv commands, isServerRunning validation, stop-on-last-rm) +**Last Updated:** 2026-10-14 (amended: code structure section, ADR 063) --- @@ -54,6 +54,10 @@ This makes the welcome banner's call-to-action (`Run \`bunx webtty help\` for mo The command exits when the editor exits. +## Code structure + +`src/cli/index.ts` is the entry and `main.ts` the composition root; each command is a module under `src/cli/commands/` that takes its dependencies (`CliDeps`: the API client, output, exit, spawn, config) as an argument ([ADR 063](../adrs/063.cli.command-modules.md)). See the [architecture SPEC](architecture.md) for the repo-wide layout and conventions. + ## Features | Feature | Description | ADR | Done? | diff --git a/docs/specs/client.md b/docs/specs/client.md index 9425972..89734b6 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -31,13 +31,18 @@ dist/ ## Source layout +The client's folders, module kinds, dependency rules, size limits and test conventions are in the [architecture SPEC](architecture.md) ([ADR 059](../adrs/059.architecture.code-organisation.md), [ADR 060](../adrs/060.client.feature-modules.md), [ADR 061](../adrs/061.client.vendor-split.md)). In short: + ``` src/client/ - index.ts ← browser TypeScript entry point - index.html ← HTML shell (no inline script) - index.css ← terminal styles + index.ts ← browser entry, bundled to dist/client-browser.js + client.html ← HTML shell (no inline script), copied to dist/client.html + index.css ← terminal and find-bar styles, copied to dist/client.css + *.ts ← app modules: the connection, layout and features (moving into app/, connection/, + layout/ and features/ as ADR 060 lands) vendor/ - ghostty-web/ ← coder/ghostty-web lib/ at 1858a59 (non-test TS, unchanged), MIT LICENSE, PROVENANCE.md; + ghostty-web/ ← derived from coder/ghostty-web lib/ at 1858a59 and heavily modified by webtty, MIT LICENSE, + PROVENANCE.md (upstream pin, local changes, moves and splits); ghostty.ts holds every wasm call, the binding to the official wasm (ADR 042, ADR 047); fn-table.ts is the function-table trampoline borrowed from ghostty-vt-js (LICENSE.ghostty-vt-js) fixtures/ ← PTY captures replayed in 1-byte and 512-byte writes by vendor/official-replay.test.ts @@ -47,7 +52,7 @@ src/client/ scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step); --verify re-checks offline ``` -The vendored TS is excluded from Biome so it stays byte-identical to upstream until webtty edits it, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git from the commit that added it. `vendor/ghostty-vt/` is excluded from Biome too, since the update script writes its sidecar; nothing in it is edited. +The vendored TS is excluded from Biome so Coder's lines are not reformatted and webtty's edits stay readable as diffs, and is type-checked with the rest of `src/`. webtty's edits to it are tracked in git and listed in its `PROVENANCE.md`. `vendor/ghostty-vt/` is excluded from Biome too, since the update script writes its sidecar; nothing in it is edited. ## Page diff --git a/docs/specs/config.md b/docs/specs/config.md index af64016..b52a023 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -219,6 +219,10 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter } ``` +## Code structure + +`src/config/` holds the schema types (including `ClientConfig` and `CLIENT_CONFIG_KEYS`, the keys `GET /api/config` sends), the defaults, the pure parser, the binding merge rules and the file I/O; `src/config/index.ts` keeps the `../config` import path. The client imports these types with `import type` only ([ADR 059](../adrs/059.architecture.code-organisation.md), [ADR 062](../adrs/062.server.composition.md), [architecture SPEC](architecture.md)). + ## Features | Feature | Description | ADR | Done? | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index cc0875b..07f716b 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -1,6 +1,6 @@ # SPEC: webtty -**Last Updated:** 2026-10-13 +**Last Updated:** 2026-10-14 --- @@ -86,6 +86,10 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | `GET` | `/api/server/status` | Server status; responds `200` with `{ version }` — the `package.json` version (`0.0.0-development` in dev checkouts) | | `POST` | `/api/server/stop` | Graceful shutdown — kills all PTYs, closes WebSocket server, exits | +## Code structure + +The server, PTY, CLI and config code is organised as the [architecture SPEC](architecture.md) describes: `src/server/app.ts` (`createServer`) wires a `SessionStore`, an ordered route table and the WebSocket channels ([ADR 062](../adrs/062.server.composition.md)); the layers, dependency rules, size limits and test conventions for the whole repo are there ([ADR 059](../adrs/059.architecture.code-organisation.md)). + ## Features | Feature | Description | ADR | Done? | From 45737bc788b4ccdce324a69d661c6ca58f269388 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:43:42 -0400 Subject: [PATCH 136/295] docs(arch): revise the architecture SPEC and ADRs 060-062, 064, 065 after review Write the client composition root order as it is in index.ts, with the two moves and why each is equivalent, and the listener order per target. Add ClientEnv's full list of globals, the per-socket ack, the per-layer coverage check (bun's coverageThreshold is global), the vendor listener snapshot that every split step keeps, the ClientConfig type defined once from CLIENT_CONFIG_KEYS, the flow.ts shim, and the engine binding homes and names. --- docs/adrs/060.client.feature-modules.md | 10 +-- docs/adrs/061.client.vendor-split.md | 2 +- docs/adrs/062.server.composition.md | 2 +- docs/adrs/064.architecture.test-strategy.md | 6 +- docs/adrs/065.client.listener-teardown.md | 4 +- docs/specs/architecture.md | 71 ++++++++++++++------- 6 files changed, 62 insertions(+), 33 deletions(-) diff --git a/docs/adrs/060.client.feature-modules.md b/docs/adrs/060.client.feature-modules.md index 2b94aef..3532e3c 100644 --- a/docs/adrs/060.client.feature-modules.md +++ b/docs/adrs/060.client.feature-modules.md @@ -2,7 +2,7 @@ **SPEC:** [architecture](../specs/architecture.md), [client](../specs/client.md) **Status:** Accepted -**Date:** 2026-10-14 +**Date:** 2026-10-14 (root order, ClientEnv and per-socket ack revised after review) --- @@ -27,7 +27,7 @@ export interface Disposable { dispose(): void } export function installTabTitle(ctx: ClientContext, deps: TabTitleDeps): TabTitleFeature // extends Disposable ``` -- `ClientContext` carries what every feature may use: `term`, `container`, `config`, `terminalTheme`, `mac`, `doc`, `win`. +- `ClientContext` carries what every feature may use: `term`, `container`, `config`, `terminalTheme`, `mac`, `doc`, `win`. `startClient` takes a `ClientEnv` (`app/browser-env.ts`) holding every global it touches, including the engine loader and the terminal constructor as factories, so the whole root runs on fakes. - `deps` carries the handles of other parts the feature needs (the connection, the fitter, the find bar, `clearScreen`), passed explicitly by the root. A feature that others need returns a small API next to `dispose` (the layout feature returns `{ fit, schedule }`). - An installer only wires: it registers listeners through `listen()` into a `DisposableStore` and calls objects that hold the logic. Its `dispose()` removes everything it added and is idempotent. - Features never import each other. @@ -36,17 +36,17 @@ Infrastructure that is not a feature has its own folders: `app/` (the root, cont ### The composition root -`app/start-client.ts` exports `startClient(env)`, which runs the steps listed in the SPEC, in today's order, as straight-line code with numbered comments, and returns one disposer that disposes every feature in reverse order. `index.ts` keeps the top-level `await` (so a failure still leaves the page blank, as today) and does nothing but `await startClient(browserEnv())`. A test runs `startClient` on fakes and records each listener's target, type, phase and position, so a change of order fails a test, not a user. +`app/start-client.ts` exports `startClient(env)`, which runs the thirteen steps listed in the SPEC, in today's order (the SPEC lists every listener per target and phase, and the two moves with why each is equivalent), as straight-line code with numbered comments, and returns one disposer that disposes every feature in reverse order. `index.ts` keeps the top-level `await` (so a failure still leaves the page blank, as today) and does nothing but `await startClient(browserEnv())`. A test runs `startClient` on fakes and records each listener's target, type, phase and position, so a change of order fails a test, not a user. ### One key dispatcher -The binding listener and the shortcut listener become one container capture `keydown` listener (`key-dispatcher.ts`), registered where the first one is today. When a configured binding matches it does what the binding listener does (`preventDefault`, `stopPropagation`, scroll to the bottom and send its chars when the socket is open) and returns; otherwise it runs the shortcut commands with today's `stopImmediatePropagation` and `performable` rules. This is equivalent: `stopPropagation` never stopped the second listener on the same node, which is why that listener re-checked the binding. A case-by-case test compares the dispatcher with the old pair (binding, empty binding, each shortcut with the find bar open and closed, performable with nothing to do, paste with and without the clipboard API). Font zoom stays a separate window capture listener so it works without focus, and still calls `fit()` synchronously. +The binding listener and the shortcut listener become one container capture `keydown` listener (`key-dispatcher.ts`), registered where the first one is today. As a capture listener it runs after the vendored `Terminal`'s own container capture `keydown` (added in `open()`) and before the InputHandler's bubble listener, as both do today. When a configured binding matches it does what the binding listener does (`preventDefault`, `stopPropagation`, scroll to the bottom and send its chars when the socket is open) and returns; otherwise it runs the shortcut commands with today's `stopImmediatePropagation` and `performable` rules. This is equivalent: `stopPropagation` never stopped the second listener on the same node, which is why that listener re-checked the binding. A case-by-case test compares the dispatcher with the old pair (binding, empty binding, each shortcut with the find bar open and closed, performable with nothing to do, paste with and without the clipboard API). Font zoom stays a separate window capture listener so it works without focus, and still calls `fit()` synchronously. Shortcut actions go through a **command map** (`shortcut-commands.ts`): action → handler returning whether it was done, so dispatch is a table lookup and each command is tested alone. ### PtyConnection -`connection/pty-connection.ts` owns the WebSocket: the URL (`cols`, `rows`, `flow=1`), connect, the close-code policy (`close-policy.ts`, pure: 4003 resets the terminal and reconnects at once, 4001 and 1001 close the tab, anything else retries after 2 s), status lines (`status-line.ts`, pure), and one `send` with the single open guard. It takes the `WebSocket` constructor and timers by injection. Hooks that must run before any message (`replay.connected()`, `clipboard.connected()`) are called synchronously when the socket is created; on open it calls `graphics.reset()`, sends the size and calls `resyncFocus()`, in that order. +`connection/pty-connection.ts` owns the WebSocket: the URL (`cols`, `rows`, `flow=1`), connect, the close-code policy (`close-policy.ts`, pure: 4003 resets the terminal and reconnects at once, 4001 and 1001 close the tab, anything else retries after 2 s), status lines (`status-line.ts`, pure), and one `send` with the single open guard. It takes the `WebSocket` constructor and timers by injection. Hooks that must run before any message (`replay.connected()`, `clipboard.connected()`) are called synchronously when the socket is created; on open it calls `graphics.reset()`, sends the size and calls `resyncFocus()`, in that order. Each socket gets its own `OutputAck`, bound to that socket's `send` with today's per-socket open check, and disposed in that socket's `onclose` before any reconnect (the immediate 4003 one included), so an ack queued for a closed socket is never sent on its successor. ### The output pipeline stays a direct call sequence diff --git a/docs/adrs/061.client.vendor-split.md b/docs/adrs/061.client.vendor-split.md index 8306e5b..d1c2344 100644 --- a/docs/adrs/061.client.vendor-split.md +++ b/docs/adrs/061.client.vendor-split.md @@ -30,7 +30,7 @@ The webtty-original modules already in the tree (`glide.ts`, `wheel.ts`, `mouse- ### Shared engine state is explicit -`OfficialTerminal` becomes a facade over per-feature bindings (`render-state`, `scrollback-pages`, `selection-binding`, `search-binding`, `mouse-encoder`, `paste-focus-encoders`, `engine-effects`, `key-encoder`) that share one `EngineContext` (`ex`, the layout, the scratch allocator, the terminal handle, the generation counter, and `dv()` / `u8()`, which build memory views per call and are never stored in a field). Each binding declares its own scratch slots, so the 40-slot table is no longer written twice. The wasm callbacks are still installed through `FnTable` and uninstalled in `free()`, and still never re-enter `vt_write`. `resolveOfficialLayout` returns typed per-feature sub-objects instead of a `ReturnType<…>` blob. +`OfficialTerminal` becomes a facade over per-feature bindings (`render-state`, `scrollback-pages`, `selection-binding`, `search-binding`, `mouse-encoder-binding`, `paste-focus-binding`, `key-encoder-binding`, `engine-effects` for the `FnTable` callbacks, the clipboard write and `readTitle`, `grid-refs` for the grid and screen refs, `hyperlinkAt` and graphemes, and `engine-config` for `applyConfig` and the colour reads and writes) that share one `EngineContext` (`ex`, the layout, the scratch allocator, the terminal handle, the generation counter, and `dv()` / `u8()`, which build memory views per call and are never stored in a field). Each binding declares its own scratch slots, so the 40-slot table is no longer written twice. The wasm callbacks are still installed through `FnTable` and uninstalled in `free()`, and still never re-enter `vt_write`. `resolveOfficialLayout` returns typed per-feature sub-objects instead of a `ReturnType<…>` blob. What stays in the facade is the constructor and fields (budget about 150 lines: the bindings are created there and own their state), the public API as one-line delegations, the small mode and viewport calls (`getMode`, `scrollViewport`, `promptJumpRow`) and `free()`; `info()` and `allocView` move into `EngineContext`. The `*-binding` suffix names every file that wraps one libghostty-vt object, so `engine/key-encoder-binding.ts` (the wasm key encoder) is not confused with `input/key-encoding.ts` (the DOM key table lookup), nor `engine/mouse-encoder-binding.ts` with `input/mouse-report.ts`, `mouse-route.ts` and `core/mouse-controller.ts`. The vendored options store is `core/options-proxy.ts`, so the app's `app/terminal-options.ts` keeps a unique basename. ### Narrow host interfaces break the cycles diff --git a/docs/adrs/062.server.composition.md b/docs/adrs/062.server.composition.md index 984f65f..a38aef3 100644 --- a/docs/adrs/062.server.composition.md +++ b/docs/adrs/062.server.composition.md @@ -44,7 +44,7 @@ - `src/config.ts` becomes `src/config/` with `index.ts` re-exporting what it exports today, so `../config` imports resolve unchanged: `config-schema.ts` (types, `ClientConfig`, `CLIENT_CONFIG_KEYS`, including the legacy `scrollback` key the server still sends), `config-paths.ts`, `defaults.ts`, `parse-config.ts` (pure, one table-driven test per key), `keyboard-bindings.ts` and `client-view.ts` (`toClientConfig`). - `src/runtime.ts` replaces the three `isBun` definitions with honestly named predicates, one per existing meaning, so no call site changes behaviour. - `src/pty` takes one options object (`spawnPty(options)`), keeps its `pty: …` log line, and the node-pty backend takes the module by injection so its mapping is tested. -- `flow.ts` becomes `output-pacer.ts` (a `git mv`; it is already clean); `static.ts` becomes `http/static-files.ts`. +- `flow.ts` becomes `output-pacer.ts` (a `git mv`; it is already clean), and `flow.ts` stays as a one-line re-export until the client's `replay-window.test.ts`, which imports `DEFAULT_FLOW_LIMITS` as a contract, is repointed after the client and server branches merge; `static.ts` becomes `http/static-files.ts`. - `src/utils.test.ts` becomes `src/test-support/server-harness.ts`. ## Consequences diff --git a/docs/adrs/064.architecture.test-strategy.md b/docs/adrs/064.architecture.test-strategy.md index c7317ec..c9e0ddb 100644 --- a/docs/adrs/064.architecture.test-strategy.md +++ b/docs/adrs/064.architecture.test-strategy.md @@ -2,7 +2,7 @@ **SPEC:** [architecture](../specs/architecture.md) **Status:** Accepted -**Date:** 2026-10-14 +**Date:** 2026-10-14 (coverage enforcement and listener snapshots revised after review) --- @@ -19,7 +19,9 @@ - **Server and CLI in-process.** Routes, WebSocket channels and sessions are tested through `createServer()` on port 0 with a temporary `HOME`; commands through injected `CliDeps`. Two or three subprocess tests remain to check the entries. No test reads the user's configuration or uses a fixed port. - **Vendor.** One cached `loadGhostty()` and one wasm path in `vendor/tests/support/wasm.ts`. New tests use the collaborators' public seams instead of private-member casts; existing casts are rewritten as the state they reach moves. - **Required tests by kind.** Pure modules: table-driven, all branches. Disposables: `dispose()` removes everything it added, and a second call does nothing. Order-sensitive wiring: an order test (client install order, the per-message pipeline, the on-open sequence, the key dispatcher's equivalence with the old listeners, the server's route precedence). -- **Coverage targets**, enforced at the end of the refactor through `coverageThreshold` in `bunfig.toml` for `bun run test:coverage`: app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. +- **Coverage targets**, enforced at the end of the refactor by `bun run test:coverage`: app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. Bun's `coverageThreshold` in `bunfig.toml` is a single global setting and cannot vary by folder, so it holds the common floor (85% lines, 80% functions), and `scripts/check-coverage.ts` reads the lcov report (`--coverage-reporter=lcov`) and checks each layer's target; `test:coverage` runs both. +- **Listener snapshots.** Before the vendored facades are split (ADR 061), a test records every listener `Terminal.open()` adds (target, type, capture, order) and which of them `dispose()` removes. Each split step must leave the snapshot unchanged; only the ADR 065 commit changes it. +- **Cross-branch contracts.** Tests may import across the client/server line; source may not. The close codes are pinned by a contract test that imports both sides; the client config type is checked by `tsc` (it is the server's type, imported with `import type`). - **Structure** is tested too: `scripts/check-architecture.test.ts` (ADR 059). ## Considered Options diff --git a/docs/adrs/065.client.listener-teardown.md b/docs/adrs/065.client.listener-teardown.md index c9fa377..da7979c 100644 --- a/docs/adrs/065.client.listener-teardown.md +++ b/docs/adrs/065.client.listener-teardown.md @@ -21,7 +21,9 @@ webtty never disposes its terminal today (a page lives as long as its tab), so n `Terminal`, its controllers, `InputHandler` and `SelectionManager` register every listener, timer, interval and animation frame in a `DisposableStore` (`lifecycle/disposable-store.ts`), and `dispose()` disposes the store. The targets, event types, `capture` flags and registration order stay as they are; only removal changes. A spy test checks that after `dispose()` every listener added to the container, the canvas, the textarea, the document and the window has been removed with the same options, and that a second `dispose()` does nothing. -This is the refactor's one deliberate behaviour change, so it is its own commit (`fix(client): …`), separate from the moves. +The `InputHandler`'s `compositionupdate` listener, whose handler does nothing, is removed in the same commit; it is a registered listener, so removing it is a change of the listener set and belongs here, not in a move. + +This is the refactor's one deliberate behaviour change, so it is its own commit (`fix(client): …`), separate from the moves. Until that commit the split steps keep today's removal behaviour exactly: a `DisposableStore` introduced by a split holds only the listeners that are removed today, and the leaking ones stay registered the old way with a comment pointing here. The listener snapshot test (ADR 064) proves it: it is unchanged by every split step and changes only in this commit. ## Consequences diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index bb76a6c..84106f8 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -1,6 +1,6 @@ # SPEC: Architecture -**Last Updated:** 2026-10-14 +**Last Updated:** 2026-10-14 (revised after review) --- @@ -46,9 +46,11 @@ src/client: | App code (`src/client/**` outside `vendor/`) imports vendor code only through `vendor/ghostty-web` (the barrel), never a deep path. Tests may deep-import. | same | | `src/client/features/<a>/**` never imports `src/client/features/<b>/**`. Features reach each other only through handles the composition root passes in. | same | | Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`), never the facade class as a value, so no runtime import cycle can form. | review + `tsc` | -| A file under a `test-support/` folder is never named `*.test.ts`. | same | +| A file under a `test-support/` folder is never named `*.test.ts`, and no file imports a `*.test.ts` file. | same | +| Test files (`*.test.ts`) may cross the client/server line (contract tests); the import rules above apply to source files only. | same | +| The checker counts `.ts` files only, excluding `*.d.ts`; fixtures, the wasm, JSON, Markdown, HTML and CSS are not source. | same | -**Contracts across the client/server line.** The config types are defined once, in `src/config`: the client derives `ClientConfig` from them (`Pick<Config, …>`) with `import type`, so the three copies of today (client type, server whitelist, server type) become one type and one key list (`CLIENT_CONFIG_KEYS`, server side) pinned by a test. Wire constants that the client needs as values (the WebSocket close codes 1001, 4001, 4003) are mirrored in `src/client/connection/close-codes.ts` and pinned to the server's by a contract test that imports both (tests may cross the line; code may not). +**Contracts across the client/server line.** The config types are defined once, in `src/config/config-schema.ts`: `CLIENT_CONFIG_KEYS` (a `const` tuple, the one key list, legacy `scrollback` included) and `ClientConfig = Pick<Config, (typeof CLIENT_CONFIG_KEYS)[number]>`. The server builds `/api/config` from that list (`client-view.ts`, whose test pins the exact keys and order), and the client imports the `ClientConfig` type with `import type`, so the three copies of today (client type, server whitelist, server type) become one type and one key list. A type has no runtime keys, so there is no runtime contract test for it: `tsc` checks the client against the type. While the server and client branches of the refactor are apart, the client keeps a local `Pick<Config, …>` alias marked temporary, replaced by the import when they merge. Wire constants that the client needs as values (the WebSocket close codes 1001, 4001, 4003) are mirrored in `src/client/connection/close-codes.ts` and pinned to the server's by a contract test that imports both (tests may cross the line; code may not). ## Folder tree @@ -57,7 +59,8 @@ The target tree. Files marked *(entry)* keep their path because `scripts/build.t ``` src/ version.ts util: package version lookup - runtime.ts util: isBun, isWindows, bunTerminalAvailable, packageRunner (one definition per meaning) + runtime.ts util: bunTerminalAvailable, launchedFromBunExecutable, runningUnderBun, isWindows, packageRunner + (one predicate per meaning of the three old isBun definitions, each taking an env with a production default) ndjson.ts object: LineSplitter (server /publish and CLI run) test-support/server-harness.ts test util: getFreePort, waitForServer*, makeTmpHome (was src/utils.test.ts) config/ node; server + cli; the client imports types only @@ -77,6 +80,8 @@ src/ app.ts factory: createServer({ loadConfig, store, spawnPty, paths, onStop }) → { httpServer, wss, shutdown } server-paths.ts util: projectRoot, clientRoot, wasmPath (the one place that knows src vs dist depth) output-pacer.ts object: OutputPacer, splitChunks, DEFAULT_FLOW_LIMITS (was flow.ts) + flow.ts temporary one-line re-export of output-pacer.ts, kept only until the client's + replay-window.test.ts is repointed after the two branches merge, then deleted http/ router.ts util: ordered method + pattern table (declaration order = the old if-chain order) respond.ts util: sendJson, sendText, CORS_HEADERS @@ -111,7 +116,7 @@ src/ client.html index.css (paths fixed: copied by scripts/build.ts) app/ start-client.ts composition root: bootstrap, install features in the documented order, connect - browser-env.ts adapter: the real window, document, navigator, fetch, WebSocket, timers, rAF + browser-env.ts adapter: ClientEnv, everything start-client touches outside its own objects (see below) client-context.ts types: ClientContext, Feature, Disposable client-config.ts service + type: ClientConfig (from src/config types), loadClientConfig(fetch) terminal-options.ts util (pure): toTerminalOptions(config, theme) @@ -155,7 +160,9 @@ src/ ghostty-web/ Biome-excluded; see "The vendored layer" scripts/ build.ts unchanged outputs - check-architecture.ts size and import rules (library) + check-architecture.test.ts + check-architecture.ts size, naming and import rules with the ratchet allowlist + check-architecture.test.ts + check-coverage.ts per-layer coverage targets over bun's lcov report (run by test:coverage) + check-pkg-manager.ts clean-pkg-scripts.ts package-manager guard and publish-time script cleanup (headers; pure parts tested) update-ghostty-vt.ts CLI only (--commit, --verify) ghostty-vt/ minisign.ts, sidecar.ts, inspect-wasm.ts, release-fetch.ts (fetch injected) ``` @@ -179,10 +186,11 @@ ghostty-web/ engine/ every wasm call; no DOM engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts scrollback-pages.ts - selection-binding.ts search-binding.ts mouse-encoder.ts paste-focus-encoders.ts engine-effects.ts - cursor-blink.ts text-mime.ts key-encoder.ts + selection-binding.ts search-binding.ts mouse-encoder-binding.ts paste-focus-binding.ts key-encoder-binding.ts + engine-effects.ts (FnTable callbacks, clipboard write, readTitle) grid-refs.ts (grid and screen refs, hyperlinkAt, graphemes) + engine-config.ts (applyConfig, colour reads and writes) cursor-blink.ts text-mime.ts core/ Terminal's collaborators; import core/host-types.ts, never terminal.ts - host-types.ts terminal-constants.ts terminal-geometry.ts terminal-options.ts dom-setup.ts + host-types.ts terminal-constants.ts terminal-geometry.ts options-proxy.ts dom-setup.ts viewport.ts focus-tracker.ts find-controller.ts write-pipeline.ts render-loop.ts mouse-controller.ts wheel-controller.ts scrollbar-controller.ts render/ renderable.ts default-theme.ts device-pixel-ratio.ts frame-lines.ts cell-range.ts row-band.ts @@ -219,17 +227,33 @@ Other patterns used, each in one named place: **Command map** for shortcut dispa ### The client composition root -`startClient(env)` runs these steps, in this order, written once in the file with numbered comments and pinned by a test that installs everything on fakes and records each listener's target, type, phase and order: +`startClient(env)` runs these steps, in this order, written once in the file with numbered comments. The order is today's `index.ts` (at commit d333069) with two moves, each stated with why it is equivalent; it is pinned by a test that installs everything on fakes and records each listener's target, type, phase and position and each terminal emitter subscription. + +1. **Bootstrap.** The session id from `location`; the config (`/api/config`); `ReplayWindow`; the `TabTitle` object (it sets `document.title`, so it stays before the wasm load); `Ghostty.load('/ghostty-vt-small.wasm')`; the terminal theme; `new Terminal(toTerminalOptions(…))`; the padding colour; `await term.open(container)`. +2. **Layout and views.** `EdgeExtender`; `Composer` (container `compositionstart`, `compositionupdate`, `compositionend`, `focusout`); `WheelStream` and `SmoothScroll`; the FitAddon and the `Fitter`, whose first `schedule()` runs here; the `ResizeObserver`; window `resize`; `fonts.ready`; repaint on visible (document `visibilitychange`); the DPR watch. +3. **Kitty graphics.** `KittyGraphics` (its overlay into the container, a document `visibilitychange`); its replies go out through the connection handle, which is created at step 7 and read only when a reply is sent. +4. **Clipboard.** `onClipboardWrite`; window capture `keydown`, `mousedown`, `paste`, `drop` arming the writer. +5. **Tab title.** `onTitleChange`, `onBell`; container `focusin`; window capture `keydown`. +6. **Notifications.** `DesktopNotifier`; `onDesktopNotification`; window capture `keydown` and `mousedown` (user input); container `focusin`; document `visibilitychange`; window capture `keydown` (attended). +7. **The connection.** `connect()`: when the socket is created, `clipboard.connected()` and `replay.connected()` run synchronously; on open, `graphics.reset()`, the resize message, `resyncFocus()`. It stays at today's position (it was never last). +8. **Scroll hooks.** `attachCustomWheelEventHandler`, `attachWheelPace`, `attachBeforeRender`. +9. **The find bar** and its `onFindChange` subscription. *Moved up* from after the shortcut listener so the dispatcher gets a handle instead of a forward reference. Equivalent: the bar appends its root to `document.body` (the only append to the body) and listens only on its own elements, and it is the only `onFindChange` subscriber. +10. **The key dispatcher:** **one** container capture `keydown` listener, at the binding listener's position. A configured binding wins (`preventDefault`, `stopPropagation`, scroll to the bottom, send its chars, return); otherwise Ghostty's shortcuts run with today's `stopImmediatePropagation` and `performable` rules. *The shortcut listener's position merges into it*: the listeners registered between the two were a window listener and emitter subscriptions, none on the container. Being a capture listener it runs after `Terminal`'s own container capture `keydown` (the context-menu key, added in `open()`) and before the InputHandler's bubble listener. +11. **Font zoom:** window capture `keydown` (a window listener so it works without focus), calling `fit()` synchronously. +12. **Input and size:** `term.onData` → `connection.send`, `term.onResize` → `connection.sendResize`. +13. **Paste:** `onPasteRequest`, then the container capture `paste` listener. + +Everything between these steps runs synchronously after the awaits of step 1, so no event can be dispatched part-way; the order matters only among listeners on the same target and phase and among subscribers of the same emitter, and those orders are the ones above: + +| Target, phase | Listeners in order (app only; the vendored ones come first, from `term.open`) | +|---|---| +| window capture `keydown` | clipboard arming → tab title attended → notifier user input → notifier attended → font zoom | +| window capture `mousedown` | clipboard arming → notifier user input | +| container `focusin` | tab title attended → notifier attended | +| container capture `keydown` | key dispatcher | +| document `visibilitychange` | repaint on visible → kitty overlay → title and notifier attended | -1. Load the config (`/api/config`), `Ghostty.load('/ghostty-vt-small.wasm')`, `new Terminal(toTerminalOptions(…))`, colour the padding, `term.open(container)`. -2. Layout: Fitter, edge strips, DPR watch, `fonts.ready`, repaint on visibility. -3. Scroll: SmoothScroll, the wheel handler, wheel pace, `attachBeforeRender`. -4. Kitty graphics (the output writer). -5. Window capture listeners in today's order: clipboard arming, tab title and notifier (user input, attended), then font zoom (a window listener so it works without focus). -6. The key dispatcher: **one** container capture `keydown` listener. A configured binding wins (`preventDefault`, `stopPropagation`, send its chars, return); otherwise Ghostty's shortcuts run with today's `stopImmediatePropagation` and `performable` rules. It is registered after `term.open`, so it runs before the InputHandler. -7. Paste: `onPasteRequest` and the container capture `paste` listener. -8. The find bar. -9. The connection: `clipboard.connected()` and `replay.connected()` run synchronously when the socket is created; on open, `graphics.reset()`, the resize message, `resyncFocus()`. +`ClientEnv` (`app/browser-env.ts`) carries every global the root touches, each with the browser's own as the production value, so the root runs on fakes: `window` (`location`, `devicePixelRatio`, `matchMedia`, `close`, `focus`), `document` (including `fonts.ready`), `navigator`, `fetch`, `WebSocket`, `ResizeObserver`, `Notification`, `performance.now`, `setTimeout`/`clearTimeout`, `requestAnimationFrame`/`cancelAnimationFrame`, and two factories, `loadEngine(url)` (`Ghostty.load`) and `createTerminal(options)` (`new Terminal`). Features get what they need from `ClientContext` (`term`, `container`, `config`, `terminalTheme`, `mac`, `doc`, `win`) and from handles the root passes explicitly (the connection, the fitter, the find bar, `clearScreen`). There is no service locator and no event bus. @@ -241,7 +265,7 @@ Features get what they need from `ClientContext` (`term`, `container`, `config`, ## Size limits -Counted in lines, comments and blank lines included. Checked by `scripts/check-architecture.ts` under `bun test`. +Counted in lines of `.ts` source (not `*.d.ts`), comments and blank lines included. Checked by `scripts/check-architecture.ts` under `bun test`, on both refactor branches: the checker lands first on the client branch and the server branch starts from that commit, so every server step runs it too. | Files | Aim | Hard limit | |---|---|---| @@ -252,7 +276,7 @@ Counted in lines, comments and blank lines included. Checked by `scripts/check-a | Test files | ≤ 600 | 800 | | Functions | ≤ 60 lines | not checked; hot paths (`decodeRow`, `render`) say why when longer | -While the refactor runs, the checker carries a **ratchet allowlist**: each file over its limit today, with its current line count. A listed file may shrink or disappear, never grow; each step removes the entries it fixes, and the list is empty at the end. +While the refactor runs, the checker carries a **ratchet allowlist**: each file over its limit today, with its current line count. A listed file may shrink or disappear, never grow; each step removes the entries it fixes, and the list is empty at the end. While both branches run, a missing (deleted or renamed) entry is tolerated so the server branch never edits the checker; once they merge the list is emptied and a stale entry fails. ## Naming @@ -281,7 +305,8 @@ While the refactor runs, the checker carries a **ratchet allowlist**: each file - **Order:** the client root has an install-order test; the per-message pipeline and the on-open sequence have call-order tests; the key dispatcher has a case-by-case equivalence test against the old two-listener behaviour. - **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. - **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. -- **Coverage targets** (`bun run test:coverage`, thresholds in `bunfig.toml` at the end of the refactor): app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. +- **Listener snapshots.** Before the vendored facades are split, a test opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. +- **Coverage targets** (`bun run test:coverage`): app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov`, enforces the per-layer targets from `coverage/lcov.info`. ## Features @@ -296,4 +321,4 @@ While the refactor runs, the checker carries a **ratchet allowlist**: each file | Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ⬜ | | Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ⬜ | | CLI command modules | `main.ts`, WebttyClient, one file per command with injected deps | [ADR 063](../adrs/063.cli.command-modules.md) | ⬜ | -| Test strategy | test-support fakes, injected happy-dom, in-process server tests, vendor test mirror, coverage thresholds | [ADR 064](../adrs/064.architecture.test-strategy.md) | ⬜ | +| Test strategy | test-support fakes, injected happy-dom, in-process server tests, vendor test mirror, listener snapshots, per-layer coverage check | [ADR 064](../adrs/064.architecture.test-strategy.md) | ⬜ | From 672a4b12a2a3c0bc67d42c4ed77c31db2907abd0 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:49:49 -0400 Subject: [PATCH 137/295] test(server): move the shared test helpers out of a *.test.ts file src/utils.test.ts was a helper module that bun also collected as a test file, and four test files imported it. It becomes src/test-support/server-harness.ts (ADR 062, ADR 064) with a header and a JSDoc line per export; its two self-tests move to server-harness.test.ts beside it. The server, routes, CLI and version tests import the new path. No file imports a *.test.ts file any more. No behaviour change. --- src/cli/commands.test.ts | 2 +- src/server/routes.test.ts | 2 +- src/server/websocket.test.ts | 7 ++++- src/test-support/server-harness.test.ts | 19 ++++++++++++++ .../server-harness.ts} | 26 ++++++------------- src/version.test.ts | 7 ++++- 6 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 src/test-support/server-harness.test.ts rename src/{utils.test.ts => test-support/server-harness.ts} (69%) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index b953362..e87ba73 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -10,7 +10,7 @@ import { makeTmpHome, waitForServerDown, waitForServerReady, -} from '../utils.test'; +} from '../test-support/server-harness'; import { bytesToChars, bytesToDisplay, toBrowserHost } from './commands'; import * as httpModule from './http'; diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index e4569fd..d145bac 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -10,7 +10,7 @@ import { makeTmpHome, waitForServer, waitForServerDown, -} from '../utils.test'; +} from '../test-support/server-harness'; import { getVersion } from '../version'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 3d1a051..3e830c6 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -3,7 +3,12 @@ import { type ChildProcess, spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocket } from 'ws'; -import { cleanupTmpHome, getFreePort, makeTmpHome, waitForServer } from '../utils.test'; +import { + cleanupTmpHome, + getFreePort, + makeTmpHome, + waitForServer, +} from '../test-support/server-harness'; const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); const stripAnsi = (s: string) => s.replace(ANSI_RE, ''); diff --git a/src/test-support/server-harness.test.ts b/src/test-support/server-harness.test.ts new file mode 100644 index 0000000..d8c0782 --- /dev/null +++ b/src/test-support/server-harness.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test'; +import { waitForServer, waitForServerDown } from './server-harness'; + +describe('test-helpers', () => { + test('waitForServer throws when server never responds', async () => { + await expect(waitForServer('http://127.0.0.1:1', 50)).rejects.toThrow( + 'Server did not start in time', + ); + }); + + test('waitForServerDown throws when server keeps responding', async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response('[]', { status: 200 })) as unknown as typeof fetch; + await expect(waitForServerDown('http://127.0.0.1:1', 50)).rejects.toThrow( + 'Server did not shut down in time', + ); + globalThis.fetch = realFetch; + }); +}); diff --git a/src/utils.test.ts b/src/test-support/server-harness.ts similarity index 69% rename from src/utils.test.ts rename to src/test-support/server-harness.ts index 17c1780..944bd3d 100644 --- a/src/utils.test.ts +++ b/src/test-support/server-harness.ts @@ -1,9 +1,11 @@ -import { describe, expect, test } from 'bun:test'; +// Test harness shared by the server, CLI and version tests: free ports, readiness polling and +// throwaway HOME directories. A test-support utility module, never collected as a test (ADR 062, ADR 064). import fs from 'node:fs'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; +/** Asks the OS for an unused loopback port so server tests never collide with a running webtty. */ export function getFreePort(): Promise<number> { return new Promise((resolve, reject) => { const srv = net.createServer(); @@ -14,6 +16,7 @@ export function getFreePort(): Promise<number> { }); } +/** Polls /api/sessions until the server answers at all; throws after `timeout` ms. */ export async function waitForServer(baseUrl: string, timeout = 5000): Promise<void> { const deadline = Date.now() + timeout; while (Date.now() < deadline) { @@ -27,6 +30,7 @@ export async function waitForServer(baseUrl: string, timeout = 5000): Promise<vo throw new Error('Server did not start in time'); } +/** Polls /api/sessions until the connection fails, proving the server has stopped; throws after `timeout` ms. */ export async function waitForServerDown(baseUrl: string, timeout = 3000): Promise<void> { const deadline = Date.now() + timeout; while (Date.now() < deadline) { @@ -40,6 +44,7 @@ export async function waitForServerDown(baseUrl: string, timeout = 3000): Promis throw new Error('Server did not shut down in time'); } +/** Polls until /api/sessions returns a JSON array; resolves false (never throws) on timeout, for CLI start checks. */ export async function waitForServerReady(baseUrl: string, timeout = 5000): Promise<boolean> { const deadline = Date.now() + timeout; while (Date.now() < deadline) { @@ -54,27 +59,12 @@ export async function waitForServerReady(baseUrl: string, timeout = 5000): Promi return false; } +/** Creates a fresh temp directory to use as HOME, keeping tests away from the user's real config. */ export function makeTmpHome(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `webtty-${prefix}-`)); } +/** Removes a temp HOME made by makeTmpHome. */ export function cleanupTmpHome(tmpHome: string): void { fs.rmSync(tmpHome, { recursive: true, force: true }); } - -describe('test-helpers', () => { - test('waitForServer throws when server never responds', async () => { - await expect(waitForServer('http://127.0.0.1:1', 50)).rejects.toThrow( - 'Server did not start in time', - ); - }); - - test('waitForServerDown throws when server keeps responding', async () => { - const realFetch = globalThis.fetch; - globalThis.fetch = (async () => new Response('[]', { status: 200 })) as unknown as typeof fetch; - await expect(waitForServerDown('http://127.0.0.1:1', 50)).rejects.toThrow( - 'Server did not shut down in time', - ); - globalThis.fetch = realFetch; - }); -}); diff --git a/src/version.test.ts b/src/version.test.ts index 4667cf5..33d2979 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -2,7 +2,12 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { cleanupTmpHome, getFreePort, makeTmpHome, waitForServer } from './utils.test'; +import { + cleanupTmpHome, + getFreePort, + makeTmpHome, + waitForServer, +} from './test-support/server-harness'; import { getVersion } from './version'; const REPO_ROOT = path.resolve(import.meta.dir, '..'); From ce460da0feb6583090f8a97c9e6cf178597a4c09 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:52:49 -0400 Subject: [PATCH 138/295] refactor(server): one runtime-detection module src/runtime.ts replaces the three isBun definitions with named predicates, one per meaning, each a pure function of a RuntimeEnv defaulting to the live process (ADR 062): - pty/index.ts: bunTerminalAvailable() (versions.bun and not win32) - server/websocket.ts: packageRunner() via launchedFromBunExecutable() (execPath includes 'bun') - cli/http.ts: isWindows() && runningUnderBun() (Bun global defined) Each keeps the identical expression; the 'pty: ...' log line is unchanged. The websocket and pty tests use the same predicates instead of local copies. --- src/cli/http.ts | 5 +-- src/pty/index.test.ts | 5 +-- src/pty/index.ts | 8 ++-- src/runtime.test.ts | 80 ++++++++++++++++++++++++++++++++++++ src/runtime.ts | 56 +++++++++++++++++++++++++ src/server/websocket.test.ts | 4 +- src/server/websocket.ts | 4 +- 7 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 src/runtime.test.ts create mode 100644 src/runtime.ts diff --git a/src/cli/http.ts b/src/cli/http.ts index 861c3e3..815c17a 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { configDir, loadConfig } from '../config'; +import { isWindows, runningUnderBun } from '../runtime'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -42,13 +43,11 @@ export async function isServerRunning(): Promise<boolean> { * @throws Exits the process with code 1 if the server entry is not found or fails to start in time. */ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn): Promise<void> { - const isBun = typeof (globalThis as Record<string, unknown>).Bun !== 'undefined'; - // On Windows, Bun's net.Socket doesn't support the fd-based named-pipe // wrapping that node-pty's ConPTY backend requires, so node-pty fails under // Bun on Windows. bunx works because its #!/usr/bin/env node shim runs the // server with Node.js instead. Mirror that here explicitly. - const useNode = process.platform === 'win32' && isBun; + const useNode = isWindows() && runningUnderBun(); const serverExec = useNode ? 'node' : process.execPath; // Resolve the server entry. When useNode is true the executor is Node.js, diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 6f975d3..0cf8358 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { isWindows, runningUnderBun } from '../runtime'; import { spawnForSession } from './index'; function waitForData(received: string[], content: string, timeout = 3000): Promise<void> { @@ -25,9 +26,7 @@ const TEST_SHELL = process.platform === 'win32' ? (process.env.COMSPEC ?? 'cmd.e // the first PTY write. The server always runs under Node on Windows (see http.ts), // so skip the interactive data test under Bun on Windows — it passes in CI (Linux) // and under Node on Windows. -const isBunOnWindows = - process.platform === 'win32' && - typeof (globalThis as Record<string, unknown>).Bun !== 'undefined'; +const isBunOnWindows = isWindows() && runningUnderBun(); describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { diff --git a/src/pty/index.ts b/src/pty/index.ts index 746583a..637994e 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,12 +1,14 @@ +import { bunTerminalAvailable } from '../runtime'; + export type { PtyProcess } from './types'; // Bun.Terminal does not implement PTY on Windows (Bun.spawn({ terminal }) // is a no-op there), so fall back to node-pty on that platform. // On all other platforms, prefer Bun.Terminal when running under Bun. -const isBun = !!process.versions.bun && process.platform !== 'win32'; -console.log(`pty: ${isBun ? 'Bun.Terminal' : 'node-pty'}`); +const useBunTerminal = bunTerminalAvailable(); +console.log(`pty: ${useBunTerminal ? 'Bun.Terminal' : 'node-pty'}`); -const { spawn: _spawn } = await (isBun ? import('./bun') : import('./node')); +const { spawn: _spawn } = await (useBunTerminal ? import('./bun') : import('./node')); export const spawn = _spawn; diff --git a/src/runtime.test.ts b/src/runtime.test.ts new file mode 100644 index 0000000..ff880eb --- /dev/null +++ b/src/runtime.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { + bunTerminalAvailable, + isWindows, + launchedFromBunExecutable, + packageRunner, + processRuntime, + type RuntimeEnv, + runningUnderBun, +} from './runtime'; + +function env(overrides: Partial<RuntimeEnv> = {}): RuntimeEnv { + return { + platform: 'linux', + execPath: '/usr/bin/node', + bunVersion: undefined, + bunGlobal: undefined, + ...overrides, + }; +} + +describe('processRuntime', () => { + test('reflects the live process', () => { + const live = processRuntime(); + expect(live.platform).toBe(process.platform); + expect(live.execPath).toBe(process.execPath); + expect(live.bunVersion).toBe(process.versions.bun); + expect(live.bunGlobal).toBe((globalThis as Record<string, unknown>).Bun); + }); + + test('predicates default to the live process', () => { + expect(isWindows()).toBe(process.platform === 'win32'); + expect(runningUnderBun()).toBe(true); // the test runner is Bun + expect(launchedFromBunExecutable()).toBe(process.execPath.includes('bun')); + expect(bunTerminalAvailable()).toBe(process.platform !== 'win32'); + expect(packageRunner()).toBe(process.execPath.includes('bun') ? 'bunx' : 'npx'); + }); +}); + +describe('isWindows', () => { + test.each([ + ['win32', true], + ['linux', false], + ['darwin', false], + ])('%s → %p', (platform, expected) => { + expect(isWindows(env({ platform }))).toBe(expected); + }); +}); + +describe('bunTerminalAvailable', () => { + test.each([ + ['bun on linux', { bunVersion: '1.3.0', platform: 'linux' }, true], + ['bun on darwin', { bunVersion: '1.3.0', platform: 'darwin' }, true], + ['bun on windows', { bunVersion: '1.3.0', platform: 'win32' }, false], + ['node on linux', { bunVersion: undefined, platform: 'linux' }, false], + ['empty bun version', { bunVersion: '', platform: 'linux' }, false], + ])('%s', (_name, overrides, expected) => { + expect(bunTerminalAvailable(env(overrides))).toBe(expected); + }); +}); + +describe('launchedFromBunExecutable / packageRunner', () => { + test.each([ + ['/home/u/.bun/bin/bun', true, 'bunx'], + ['C:\\Users\\u\\.bun\\bin\\bun.exe', true, 'bunx'], + ['/usr/local/bin/node', false, 'npx'], + ])('%s', (execPath, fromBun, runner) => { + expect(launchedFromBunExecutable(env({ execPath }))).toBe(fromBun); + expect<string>(packageRunner(env({ execPath }))).toBe(runner); + }); +}); + +describe('runningUnderBun', () => { + test.each([ + ['Bun global present', {}, true], + ['Bun global absent', undefined, false], + ])('%s', (_name, bunGlobal, expected) => { + expect(runningUnderBun(env({ bunGlobal }))).toBe(expected); + }); +}); diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..e3c61eb --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,56 @@ +// Runtime detection shared by the server, CLI and PTY layers: pure predicates over a +// RuntimeEnv snapshot, each defaulting to the live process (ADR 062). One predicate per +// question the code actually asks, because "is this Bun?" had three different meanings. + +/** The facts about the running process that the predicates below read. */ +export interface RuntimeEnv { + /** `process.platform`, e.g. `darwin`, `linux`, `win32`. */ + platform: string; + /** `process.execPath`, the path of the executable running this process. */ + execPath: string; + /** `process.versions.bun`, set only when Bun is the runtime. */ + bunVersion: string | undefined; + /** `globalThis.Bun`, defined only when Bun is the runtime. */ + bunGlobal: unknown; +} + +/** Reads a fresh RuntimeEnv from the live process (read per call so tests can stub `process.platform`). */ +export function processRuntime(): RuntimeEnv { + return { + platform: process.platform, + execPath: process.execPath, + bunVersion: process.versions.bun, + bunGlobal: (globalThis as Record<string, unknown>).Bun, + }; +} + +/** True on Windows, where several spawn and PTY paths differ. */ +export function isWindows(env: RuntimeEnv = processRuntime()): boolean { + return env.platform === 'win32'; +} + +/** + * True when the PTY layer can use `Bun.Terminal`: running under Bun, and not on Windows, + * where `Bun.spawn({ terminal })` is a no-op so node-pty is used instead. + */ +export function bunTerminalAvailable(env: RuntimeEnv = processRuntime()): boolean { + return !!env.bunVersion && env.platform !== 'win32'; +} + +/** + * True when the executable path names Bun. It tells how the user launched webtty + * (`bunx` versus `npx`), which is what the session banner's help hint echoes back. + */ +export function launchedFromBunExecutable(env: RuntimeEnv = processRuntime()): boolean { + return env.execPath.includes('bun'); +} + +/** True when the `Bun` global exists, i.e. this code is executing inside the Bun runtime. */ +export function runningUnderBun(env: RuntimeEnv = processRuntime()): boolean { + return typeof env.bunGlobal !== 'undefined'; +} + +/** The package runner to suggest in user-facing hints: `bunx` when launched from Bun, else `npx`. */ +export function packageRunner(env: RuntimeEnv = processRuntime()): 'bunx' | 'npx' { + return launchedFromBunExecutable(env) ? 'bunx' : 'npx'; +} diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 3e830c6..85f4127 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -3,6 +3,7 @@ import { type ChildProcess, spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocket } from 'ws'; +import { isWindows, runningUnderBun } from '../runtime'; import { cleanupTmpHome, getFreePort, @@ -20,8 +21,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // On Windows, Bun's ConPTY/net.Socket integration doesn't support the pipe // handles node-pty requires. Mirror the same workaround as http.ts: run the // server under Node when on Windows+Bun so node-pty works correctly. -const isBun = typeof (globalThis as Record<string, unknown>).Bun !== 'undefined'; -const useNode = process.platform === 'win32' && isBun; +const useNode = isWindows() && runningUnderBun(); const SERVER_EXEC = useNode ? 'node' : process.execPath; const SERVER_ENTRY = useNode ? path.resolve(__dirname, '../../dist/server/index.js') diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 0c5b5cf..1009bfb 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -3,6 +3,7 @@ import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; import { loadConfig } from '../config'; import { spawnForSession } from '../pty'; +import { packageRunner } from '../runtime'; import { FELL_BEHIND } from './flow'; import type { Session } from './session'; import { sessionRegistry, setLastUsedId } from './session'; @@ -68,8 +69,7 @@ const ITALIC = '\x1b[3m'; const YELLOW = '\x1b[1;33m'; const CYAN = '\x1b[1;36m'; -const isBun = process.execPath.includes('bun'); -const PKG_RUNNER = isBun ? 'bunx' : 'npx'; +const PKG_RUNNER = packageRunner(); function sessionBanner(): string { const W = 48; From dabc17d35f264727170976b8f6dd3087390497da Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:55:17 -0400 Subject: [PATCH 139/295] refactor(config): move src/config.ts to src/config/index.ts A pure move (git mv) so the split that follows keeps the file's history. Every '../config' import resolves to the folder's index unchanged (ADR 062). --- src/{config.test.ts => config/index.test.ts} | 0 src/{config.ts => config/index.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{config.test.ts => config/index.test.ts} (100%) rename src/{config.ts => config/index.ts} (100%) diff --git a/src/config.test.ts b/src/config/index.test.ts similarity index 100% rename from src/config.test.ts rename to src/config/index.test.ts diff --git a/src/config.ts b/src/config/index.ts similarity index 100% rename from src/config.ts rename to src/config/index.ts From c4fae00e606dd5ea1d588cf8c0b780a500397b6f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:55:29 -0400 Subject: [PATCH 140/295] test(config): import the moved config tests from the folder index The previous move left the test importing './config', which no longer exists. --- src/config/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/index.test.ts b/src/config/index.test.ts index d3f0f1b..0d2073f 100644 --- a/src/config/index.test.ts +++ b/src/config/index.test.ts @@ -9,7 +9,7 @@ import { initConfig, loadConfig, mergeKeyboardBindings, -} from './config'; +} from '.'; let tmpDir: string; let configPath: string; From 6bd0b99c5e89ddc68da4c9bd710df5b76d112356 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:56:06 -0400 Subject: [PATCH 141/295] test(arch): architecture checker with a ratchet allowlist scripts/check-architecture.ts walks src/ and scripts/ (.ts only, no .d.ts) and checks the rules of docs/specs/architecture.md under bun test: file size limits (source 300, vendored 600, facades their own, tests 800, three data tables exempt with their reasons), the import rules on source files (no node or server in the client, config types only, no client in the server, CLI or PTY, nothing outside the vendored tree, the vendor barrel only, no feature-to-feature imports, no facade value imports from split parts, no runtime cycles), no file importing a test, test helpers never named *.test.ts, and the naming rules (kebab-case, banned names, index.ts only as an entry or barrel, unique client basenames). The ratchet allowlist lists each file over its limit today with its line count and each rule broken today with the step that removes it. A listed file may shrink, never grow; while the server and client branches are apart a missing entry is tolerated, and strict mode, set when they merge, fails stale entries. ADR 059. --- scripts/architecture/import-cycles.test.ts | 79 ++++++++ scripts/architecture/import-cycles.ts | 72 ++++++++ scripts/architecture/import-rules.test.ts | 200 +++++++++++++++++++++ scripts/architecture/import-rules.ts | 172 ++++++++++++++++++ scripts/architecture/import-scan.test.ts | 144 +++++++++++++++ scripts/architecture/import-scan.ts | 94 ++++++++++ scripts/architecture/naming-rules.test.ts | 79 ++++++++ scripts/architecture/naming-rules.ts | 80 +++++++++ scripts/architecture/ratchet-allowlist.ts | 72 ++++++++ scripts/architecture/ratchet.test.ts | 117 ++++++++++++ scripts/architecture/ratchet.ts | 92 ++++++++++ scripts/architecture/size-limits.test.ts | 72 ++++++++ scripts/architecture/size-limits.ts | 66 +++++++ scripts/architecture/source-file.test.ts | 78 ++++++++ scripts/architecture/source-file.ts | 111 ++++++++++++ scripts/check-architecture.test.ts | 126 +++++++++++++ scripts/check-architecture.ts | 73 ++++++++ 17 files changed, 1727 insertions(+) create mode 100644 scripts/architecture/import-cycles.test.ts create mode 100644 scripts/architecture/import-cycles.ts create mode 100644 scripts/architecture/import-rules.test.ts create mode 100644 scripts/architecture/import-rules.ts create mode 100644 scripts/architecture/import-scan.test.ts create mode 100644 scripts/architecture/import-scan.ts create mode 100644 scripts/architecture/naming-rules.test.ts create mode 100644 scripts/architecture/naming-rules.ts create mode 100644 scripts/architecture/ratchet-allowlist.ts create mode 100644 scripts/architecture/ratchet.test.ts create mode 100644 scripts/architecture/ratchet.ts create mode 100644 scripts/architecture/size-limits.test.ts create mode 100644 scripts/architecture/size-limits.ts create mode 100644 scripts/architecture/source-file.test.ts create mode 100644 scripts/architecture/source-file.ts create mode 100644 scripts/check-architecture.test.ts create mode 100644 scripts/check-architecture.ts diff --git a/scripts/architecture/import-cycles.test.ts b/scripts/architecture/import-cycles.test.ts new file mode 100644 index 0000000..115a1ca --- /dev/null +++ b/scripts/architecture/import-cycles.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { checkCycles, findCycles, runtimeImportGraph } from './import-cycles'; +import type { SourceFile } from './source-file'; + +const file = (path: string, ...lines: string[]): SourceFile => ({ + path, + text: `${lines.join('\n')}\n`, +}); + +describe('runtimeImportGraph', () => { + test('keeps value imports of files in the tree only', () => { + const graph = runtimeImportGraph([ + file( + 'src/a.ts', + "import { b } from './b';", + "import type { C } from './c';", + "const d = await import('./d');", + "import fs from 'node:fs';", + "import { e } from './e.test';", + ), + file('src/b.ts'), + file('src/c.ts'), + file('src/d.ts'), + file('src/e.test.ts', "import { a } from './a';"), + ]); + expect(graph.get('src/a.ts')).toEqual(['src/b.ts']); + expect(graph.has('src/e.test.ts')).toBe(false); + }); +}); + +describe('findCycles', () => { + const cases: [string, Record<string, string[]>, string[][]][] = [ + ['no cycle', { a: ['b'], b: ['c'], c: [] }, []], + ['two files', { a: ['b'], b: ['a'] }, [['a', 'b']]], + ['three files', { a: ['b'], b: ['c'], c: ['a'] }, [['a', 'b', 'c']]], + ['self import', { a: ['a'] }, [['a']]], + [ + 'two separate cycles', + { a: ['b'], b: ['a'], c: ['d'], d: ['c'] }, + [ + ['a', 'b'], + ['c', 'd'], + ], + ], + ['diamond', { a: ['b', 'c'], b: ['d'], c: ['d'], d: [] }, []], + ['edge to a file outside the graph', { a: ['z'] }, []], + ]; + for (const [name, edges, expected] of cases) { + test(name, () => { + const found = findCycles(new Map(Object.entries(edges))); + expect(found.sort((x, y) => x[0].localeCompare(y[0]))).toEqual(expected); + }); + } +}); + +describe('checkCycles', () => { + test('reports a runtime cycle once, keyed by its sorted members', () => { + const violations = checkCycles([ + file('src/b.ts', "import { a } from './a';"), + file('src/a.ts', "import { b } from './b';"), + ]); + expect(violations).toEqual([ + { + rule: 'cycle', + file: 'src/a.ts', + key: 'cycle src/a.ts src/b.ts', + message: 'runtime import cycle: src/a.ts → src/b.ts', + }, + ]); + }); + + test('a cycle through a type import is not a runtime cycle', () => { + const violations = checkCycles([ + file('src/a.ts', "import { b } from './b';"), + file('src/b.ts', "import type { A } from './a';"), + ]); + expect(violations).toEqual([]); + }); +}); diff --git a/scripts/architecture/import-cycles.ts b/scripts/architecture/import-cycles.ts new file mode 100644 index 0000000..ed86ae9 --- /dev/null +++ b/scripts/architecture/import-cycles.ts @@ -0,0 +1,72 @@ +// Finds runtime import cycles among source files (util, ADR 059, ADR 061). Type-only +// and dynamic imports are left out: they are erased or load lazily, so they cannot +// leave a module half-initialised. + +import { resolveImport, scanImports } from './import-scan'; +import { isTestCode, type SourceFile, type Violation } from './source-file'; + +/** Each file's runtime imports of other files in the tree. */ +export function runtimeImportGraph(files: readonly SourceFile[]): Map<string, string[]> { + const known = new Set(files.map((file) => file.path)); + const graph = new Map<string, string[]>(); + for (const file of files) { + if (isTestCode(file.path)) continue; + const edges = new Set<string>(); + for (const ref of scanImports(file.text)) { + if (ref.typeOnly || ref.dynamic) continue; + const target = resolveImport(file.path, ref.specifier, known); + if (target !== null && known.has(target) && !isTestCode(target)) edges.add(target); + } + graph.set(file.path, [...edges]); + } + return graph; +} + +/** + * The graph's strongly connected components with more than one file, or a file that + * imports itself (Tarjan's algorithm), each sorted by path. + */ +export function findCycles(graph: ReadonlyMap<string, readonly string[]>): string[][] { + const index = new Map<string, number>(); + const low = new Map<string, number>(); + const stack: string[] = []; + const onStack = new Set<string>(); + const cycles: string[][] = []; + + const visit = (node: string): void => { + index.set(node, index.size); + low.set(node, index.get(node) as number); + stack.push(node); + onStack.add(node); + for (const next of graph.get(node) ?? []) { + if (!index.has(next)) { + visit(next); + low.set(node, Math.min(low.get(node) as number, low.get(next) as number)); + } else if (onStack.has(next)) { + low.set(node, Math.min(low.get(node) as number, index.get(next) as number)); + } + } + if (low.get(node) !== index.get(node)) return; + const component: string[] = []; + let member: string; + do { + member = stack.pop() as string; + onStack.delete(member); + component.push(member); + } while (member !== node); + if (component.length > 1 || graph.get(node)?.includes(node)) cycles.push(component.sort()); + }; + + for (const node of graph.keys()) if (!index.has(node)) visit(node); + return cycles; +} + +/** One violation per runtime import cycle, keyed by its sorted members. */ +export function checkCycles(files: readonly SourceFile[]): Violation[] { + return findCycles(runtimeImportGraph(files)).map((members) => ({ + rule: 'cycle', + file: members[0], + key: `cycle ${members.join(' ')}`, + message: `runtime import cycle: ${members.join(' → ')}`, + })); +} diff --git a/scripts/architecture/import-rules.test.ts b/scripts/architecture/import-rules.test.ts new file mode 100644 index 0000000..4b396ab --- /dev/null +++ b/scripts/architecture/import-rules.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from 'bun:test'; +import { checkImports, IMPORT_RULES } from './import-rules'; +import type { SourceFile } from './source-file'; + +const file = (path: string, ...lines: string[]): SourceFile => ({ + path, + text: `${lines.join('\n')}\n`, +}); + +// Files the fixtures import, so relative specifiers resolve as they do in the tree. +const TREE = [ + file('src/server/flow.ts'), + file('src/cli/http.ts'), + file('src/pty/index.ts'), + file('src/config.ts'), + file('src/client/ack.ts'), + file('src/client/vendor/ghostty-web/index.ts'), + file('src/client/vendor/ghostty-web/glide.ts'), + file('src/client/vendor/ghostty-web/terminal.ts'), + file('src/client/vendor/ghostty-web/core/host-types.ts'), + file('src/client/features/find/find-bar.ts'), + file('src/helpers.test.ts'), +]; + +function rulesBroken(path: string, line: string): string[] { + return checkImports([...TREE, file(path, line)]) + .filter((violation) => violation.file === path) + .map((violation) => violation.rule); +} + +describe('checkImports', () => { + const cases: [string, string, string, string[]][] = [ + ['node in the client', 'src/client/a.ts', "import fs from 'node:fs';", ['client-node']], + [ + 'bare node built-in in the vendor', + 'src/client/vendor/ghostty-web/a.ts', + "const fs = await import('fs/promises');", + ['client-node'], + ], + [ + 'client imports the server', + 'src/client/a.ts', + "import { x } from '../server/flow';", + ['client-server'], + ], + [ + 'client imports the CLI', + 'src/client/a.ts', + "import { x } from '../cli/http';", + ['client-server'], + ], + [ + 'client imports the PTY', + 'src/client/a.ts', + "import type { P } from '../pty';", + ['client-server'], + ], + [ + 'client imports config values', + 'src/client/a.ts', + "import { loadConfig } from '../config';", + ['client-config-value'], + ], + [ + 'client imports config types', + 'src/client/a.ts', + "import type { Config } from '../config';", + [], + ], + [ + 'server imports the client', + 'src/server/a.ts', + "import { OutputAck } from '../client/ack';", + ['server-client'], + ], + [ + 'config imports the vendor', + 'src/config/a.ts', + "import type { T } from '../client/vendor/ghostty-web';", + ['server-client'], + ], + [ + 'cli imports the server', + 'src/cli/a.ts', + "import { x } from '../server/flow';", + ['cli-server'], + ], + ['server imports the pty', 'src/server/a.ts', "import { spawnPty } from '../pty';", []], + [ + 'vendor imports app code', + 'src/client/vendor/ghostty-web/a.ts', + "import { OutputAck } from '../../ack';", + ['vendor-outside'], + ], + [ + 'vendor imports a package', + 'src/client/vendor/ghostty-web/a.ts', + "import x from 'left-pad';", + ['vendor-outside'], + ], + [ + 'vendor imports itself', + 'src/client/vendor/ghostty-web/a.ts', + "import { Glide } from './glide';", + [], + ], + [ + 'deep vendor import', + 'src/client/a.ts', + "import { Glide } from './vendor/ghostty-web/glide';", + ['vendor-deep'], + ], + [ + 'deep vendor type import', + 'src/client/a.ts', + "import type { Glide } from './vendor/ghostty-web/glide';", + ['vendor-deep'], + ], + ['barrel import', 'src/client/a.ts', "import { Terminal } from './vendor/ghostty-web';", []], + [ + 'feature imports a feature', + 'src/client/features/scroll/a.ts', + "import { FindBar } from '../find/find-bar';", + ['feature-feature'], + ], + [ + 'feature imports its own folder', + 'src/client/features/find/a.ts', + "import { FindBar } from './find-bar';", + [], + ], + [ + 'split part imports a facade', + 'src/client/vendor/ghostty-web/core/a.ts', + "import { Terminal } from '../terminal';", + ['facade-value-import'], + ], + [ + 'split part imports a facade type', + 'src/client/vendor/ghostty-web/core/a.ts', + "import type { Terminal } from '../terminal';", + [], + ], + [ + 'split part imports a host type', + 'src/client/vendor/ghostty-web/core/a.ts', + "import type { TerminalHost } from './host-types';", + [], + ], + ['source imports a test', 'src/a.ts', "import { h } from './helpers.test';", ['test-import']], + [ + 'test imports a test', + 'src/a.test.ts', + "import { h } from './helpers.test';", + ['test-import'], + ], + [ + 'client test imports the server', + 'src/client/a.test.ts', + "import { x } from '../server/flow';", + [], + ], + [ + 'vendor test deep-imports', + 'src/client/vendor/tests/a.test.ts', + "import { Glide } from '../ghostty-web/glide';", + [], + ], + [ + 'client test-support uses node', + 'src/client/test-support/a.ts', + "import fs from 'node:fs';", + [], + ], + [ + 'vendor test support uses node', + 'src/client/vendor/tests/support/wasm.ts', + "import fs from 'node:fs';", + [], + ], + ['server uses node', 'src/server/a.ts', "import fs from 'node:fs';", []], + ]; + for (const [name, path, line, expected] of cases) { + test(name, () => expect(rulesBroken(path, line)).toEqual(expected)); + } + + test('keys a violation by rule, file and target, and says where it is', () => { + const [violation] = checkImports([ + ...TREE, + file('src/client/a.ts', '', "import { x } from '../server/flow';"), + ]); + expect(violation.key).toBe('client-server src/client/a.ts -> src/server/flow.ts'); + expect(violation.message).toStartWith('src/client/a.ts:2 imports src/server/flow.ts'); + }); + + test('every rule has a distinct id', () => { + const ids = IMPORT_RULES.map((rule) => rule.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/scripts/architecture/import-rules.ts b/scripts/architecture/import-rules.ts new file mode 100644 index 0000000..37f64ea --- /dev/null +++ b/scripts/architecture/import-rules.ts @@ -0,0 +1,172 @@ +// The SPEC's import rules, one entry per rule in a table, checked against every import +// in the tree (util, ADR 059; the rules are in docs/specs/architecture.md). + +import { + type ImportRef, + isNodeBuiltin, + isRelative, + resolveImport, + scanImports, +} from './import-scan'; +import { + isTestCode, + isTestFile, + type Layer, + layerOf, + type RuleId, + type SourceFile, + VENDOR_BARREL, + VENDOR_ROOT, + type Violation, +} from './source-file'; + +/** What a rule sees of one import: who imports, the import, and where it points. */ +export interface ImportSite { + from: string; + ref: ImportRef; + /** The resolved repo path for a relative import, else the specifier itself. */ + target: string; +} + +interface ImportRule { + id: RuleId; + /** Test code is exempt from every rule but the test-import one (contract tests). */ + appliesToTests: boolean; + /** A message when the import breaks the rule, else null. */ + broken(site: ImportSite): string | null; +} + +/** The facades a split vendor part must not import as a value (ADR 061: no runtime cycle). */ +export const VENDOR_FACADES = new Set( + ['ghostty.ts', 'terminal.ts', 'renderer.ts', 'input-handler.ts', 'selection-manager.ts'].map( + (name) => `${VENDOR_ROOT}${name}`, + ), +); + +const VENDOR_PART_RE = /^src\/client\/vendor\/ghostty-web\/(engine|core|render|input|selection)\//; +const FEATURE_RE = /^src\/client\/features\/([^/]+)\//; +const BROWSER_SIDE: Layer[] = ['client', 'vendor']; +const NODE_SIDE: Layer[] = ['server', 'cli', 'pty', 'config']; + +function isBrowserSide(file: string): boolean { + return BROWSER_SIDE.includes(layerOf(file)); +} + +/** The import rules, in the order the SPEC lists them. */ +export const IMPORT_RULES: ImportRule[] = [ + { + id: 'client-node', + appliesToTests: false, + broken: ({ from, ref }) => + isBrowserSide(from) && isNodeBuiltin(ref.specifier) + ? `imports the Node module '${ref.specifier}'` + : null, + }, + { + id: 'client-server', + appliesToTests: false, + broken: ({ from, ref, target }) => + isBrowserSide(from) && + isRelative(ref.specifier) && + ['server', 'cli', 'pty'].includes(layerOf(target)) + ? `imports ${target}; the client imports nothing from the server, CLI or PTY` + : null, + }, + { + id: 'client-config-value', + appliesToTests: false, + broken: ({ from, ref, target }) => + isBrowserSide(from) && + isRelative(ref.specifier) && + layerOf(target) === 'config' && + !ref.typeOnly + ? `imports ${target} as a value; the client takes config types only (import type)` + : null, + }, + { + id: 'server-client', + appliesToTests: false, + broken: ({ from, ref, target }) => + NODE_SIDE.includes(layerOf(from)) && isRelative(ref.specifier) && isBrowserSide(target) + ? `imports the client file ${target}` + : null, + }, + { + id: 'cli-server', + appliesToTests: false, + broken: ({ from, ref, target }) => + layerOf(from) === 'cli' && isRelative(ref.specifier) && layerOf(target) === 'server' + ? `imports ${target}; the CLI starts the server as a process` + : null, + }, + { + id: 'vendor-outside', + appliesToTests: false, + broken: ({ from, ref, target }) => { + if (layerOf(from) !== 'vendor' || isNodeBuiltin(ref.specifier)) return null; + if (isRelative(ref.specifier) && target.startsWith(VENDOR_ROOT)) return null; + return `imports ${target} from outside the vendored ghostty-web tree`; + }, + }, + { + id: 'vendor-deep', + appliesToTests: false, + broken: ({ from, ref, target }) => + layerOf(from) === 'client' && + isRelative(ref.specifier) && + target.startsWith('src/client/vendor/') && + target !== VENDOR_BARREL + ? `imports ${target}; app code imports vendor code through the barrel only` + : null, + }, + { + id: 'feature-feature', + appliesToTests: false, + broken: ({ from, target }) => { + const own = FEATURE_RE.exec(from)?.[1]; + const other = FEATURE_RE.exec(target)?.[1]; + return own && other && own !== other ? `feature '${own}' imports feature '${other}'` : null; + }, + }, + { + id: 'facade-value-import', + appliesToTests: false, + broken: ({ from, ref, target }) => + VENDOR_PART_RE.test(from) && !ref.typeOnly && VENDOR_FACADES.has(target) + ? `imports the facade ${target} as a value; split parts import the narrow interfaces` + : null, + }, + { + id: 'test-import', + appliesToTests: true, + broken: ({ target }) => + isTestFile(target) || target.endsWith('.test') + ? `imports the test file ${target}; shared test code lives in test-support/` + : null, + }, +]; + +/** Every import that breaks an import rule, keyed `<rule> <file> -> <target>`. */ +export function checkImports(files: readonly SourceFile[]): Violation[] { + const known = new Set(files.map((file) => file.path)); + const violations: Violation[] = []; + for (const file of files) { + const testCode = isTestCode(file.path); + for (const ref of scanImports(file.text)) { + const target = resolveImport(file.path, ref.specifier, known) ?? ref.specifier; + const site = { from: file.path, ref, target }; + for (const rule of IMPORT_RULES) { + if (testCode && !rule.appliesToTests) continue; + const message = rule.broken(site); + if (message === null) continue; + violations.push({ + rule: rule.id, + file: file.path, + key: `${rule.id} ${file.path} -> ${target}`, + message: `${file.path}:${ref.line} ${message}`, + }); + } + } + } + return violations; +} diff --git a/scripts/architecture/import-scan.test.ts b/scripts/architecture/import-scan.test.ts new file mode 100644 index 0000000..31146b1 --- /dev/null +++ b/scripts/architecture/import-scan.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test'; +import { isNodeBuiltin, isRelative, resolveImport, scanImports } from './import-scan'; + +describe('scanImports', () => { + const cases: [string, string, { specifier: string; typeOnly: boolean; dynamic: boolean }[]][] = [ + [ + 'default import', + "import fs from 'node:fs';", + [{ specifier: 'node:fs', typeOnly: false, dynamic: false }], + ], + [ + 'named import', + "import { a, b } from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'import type', + "import type { A } from './x';", + [{ specifier: './x', typeOnly: true, dynamic: false }], + ], + [ + 'all inline types', + "import { type A, type B } from './x';", + [{ specifier: './x', typeOnly: true, dynamic: false }], + ], + [ + 'mixed inline types', + "import { type A, b } from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'default plus types', + "import X, { type A } from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'namespace', + 'import * as ns from "./x";', + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 're-export', + "export { a } from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'export star', + "export * from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'export type', + "export type { A } from './x';", + [{ specifier: './x', typeOnly: true, dynamic: false }], + ], + [ + 'multi-line braces', + "import {\n a,\n b,\n} from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'side effect', + "import './x.css';", + [{ specifier: './x.css', typeOnly: false, dynamic: false }], + ], + [ + 'dynamic', + "const m = await import('./x');", + [{ specifier: './x', typeOnly: false, dynamic: true }], + ], + [ + 'typeof import', + "let m: typeof import('./x');", + [{ specifier: './x', typeOnly: true, dynamic: true }], + ], + [ + 'require', + "const m = require('./x');", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + ['prose is not an import', '// copied from the upstream file', []], + ['code is not an import head', "export const from = 'x';", []], + ]; + for (const [name, text, expected] of cases) { + test(name, () => { + const found = scanImports(text).map(({ specifier, typeOnly, dynamic }) => ({ + specifier, + typeOnly, + dynamic, + })); + expect(found).toEqual(expected); + }); + } + + test('reports the line of each import', () => { + const text = "// header\n\nimport a from './a';\nimport b from './b';\n"; + expect(scanImports(text).map((ref) => ref.line)).toEqual([3, 4]); + }); +}); + +describe('isNodeBuiltin', () => { + const cases: [string, boolean][] = [ + ['node:fs', true], + ['fs', true], + ['fs/promises', true], + ['path', true], + ['ws', false], + ['bun:test', false], + ['./fs', false], + ['@lydell/node-pty', false], + ]; + for (const [specifier, expected] of cases) { + test(`${specifier} → ${expected}`, () => expect(isNodeBuiltin(specifier)).toBe(expected)); + } +}); + +describe('isRelative', () => { + test('only ./ and ../ are relative', () => { + expect(['./a', '../a', 'a', '/a', 'node:a'].map(isRelative)).toEqual([ + true, + true, + false, + false, + false, + ]); + }); +}); + +describe('resolveImport', () => { + const known = new Set(['src/a.ts', 'src/b/index.ts', 'src/c.ts', 'src/d.test.ts']); + const cases: [string, string | null][] = [ + ['./a', 'src/a.ts'], + ['./b', 'src/b/index.ts'], + ['./c.ts', 'src/c.ts'], + ['./c.js', 'src/c.ts'], + ['./d.test', 'src/d.test.ts'], + ['../outside/x', 'outside/x'], + ['ws', null], + ]; + for (const [specifier, expected] of cases) { + test(`${specifier} from src/z.ts`, () => + expect(resolveImport('src/z.ts', specifier, known)).toBe(expected)); + } +}); diff --git a/scripts/architecture/import-scan.ts b/scripts/architecture/import-scan.ts new file mode 100644 index 0000000..c1db898 --- /dev/null +++ b/scripts/architecture/import-scan.ts @@ -0,0 +1,94 @@ +// Finds the import specifiers in a TypeScript file with plain pattern matching and +// resolves relative ones against the tree (util, ADR 059: no parser dependency). + +import { builtinModules } from 'node:module'; +import path from 'node:path'; + +/** One import, re-export, dynamic `import()` or `require()` found in a file. */ +export interface ImportRef { + specifier: string; + /** 1-based line of the statement, for messages. */ + line: number; + /** Erased at build time: `import type`, `export type`, all-`type` braces, `typeof import()`. */ + typeOnly: boolean; + /** A dynamic `import()`, which loads lazily and so cannot form an init-order cycle. */ + dynamic: boolean; +} + +// An import or re-export head holds only names, braces, commas, `*` and `as`; keeping +// `;`, `=`, `(` and quotes out of it stops a match from running into ordinary code. +const STATIC_RE = + /^[ \t]*(import|export)\s+(type\s+)?([\w$\s{},*]*?)\s*from\s*['"]([^'"\n]+)['"]/gm; +const SIDE_EFFECT_RE = /^[ \t]*import\s*['"]([^'"\n]+)['"]/gm; +const DYNAMIC_RE = /(\btypeof\s+)?\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g; +const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g; + +function lineAt(text: string, index: number): number { + let line = 1; + for (let i = text.indexOf('\n'); i !== -1 && i < index; i = text.indexOf('\n', i + 1)) line++; + return line; +} + +/** `{ type A, type B }`: every named binding is a type, so the import is erased. */ +function allInlineTypes(head: string): boolean { + const braces = /^\{([\s\S]*)\}$/.exec(head.trim()); + if (!braces) return false; + const names = braces[1] + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + return names.length > 0 && names.every((name) => name.startsWith('type ')); +} + +/** Every import specifier in `text`, in no particular order. */ +export function scanImports(text: string): ImportRef[] { + const refs: ImportRef[] = []; + for (const m of text.matchAll(STATIC_RE)) { + const typeOnly = m[2] !== undefined || allInlineTypes(m[3]); + refs.push({ specifier: m[4], line: lineAt(text, m.index), typeOnly, dynamic: false }); + } + for (const m of text.matchAll(SIDE_EFFECT_RE)) { + refs.push({ specifier: m[1], line: lineAt(text, m.index), typeOnly: false, dynamic: false }); + } + for (const m of text.matchAll(DYNAMIC_RE)) { + const typeOnly = m[1] !== undefined; + refs.push({ specifier: m[2], line: lineAt(text, m.index), typeOnly, dynamic: true }); + } + for (const m of text.matchAll(REQUIRE_RE)) { + refs.push({ specifier: m[1], line: lineAt(text, m.index), typeOnly: false, dynamic: false }); + } + return refs; +} + +// Bun's list also names its own modules and the npm packages it ships built in. +const NOT_NODE = new Set(['bun', 'ws', 'undici']); +const BUILTINS = new Set( + builtinModules.filter((name) => !NOT_NODE.has(name) && !name.startsWith('bun:')), +); + +/** A Node built-in, with or without the `node:` prefix (`fs/promises` counts). */ +export function isNodeBuiltin(specifier: string): boolean { + if (specifier.startsWith('node:')) return true; + return BUILTINS.has(specifier) || BUILTINS.has(specifier.split('/')[0]); +} + +/** A path relative to the importing file, as opposed to a package or built-in. */ +export function isRelative(specifier: string): boolean { + return specifier.startsWith('./') || specifier.startsWith('../'); +} + +/** + * The repo-relative path a relative specifier points at: the `.ts` file or folder + * `index.ts` when it is in `known`, else the bare joined path (a `.d.ts`, a wasm, a + * missing file), which still says which layer it is in. Packages resolve to null. + */ +export function resolveImport( + from: string, + specifier: string, + known: ReadonlySet<string>, +): string | null { + if (!isRelative(specifier)) return null; + const base = path.posix.join(path.posix.dirname(from), specifier); + const candidates = [base, `${base}.ts`, `${base}/index.ts`, base.replace(/\.js$/, '.ts')]; + return candidates.find((candidate) => known.has(candidate)) ?? base; +} diff --git a/scripts/architecture/naming-rules.test.ts b/scripts/architecture/naming-rules.test.ts new file mode 100644 index 0000000..c036485 --- /dev/null +++ b/scripts/architecture/naming-rules.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { checkNaming } from './naming-rules'; + +const named = (...paths: string[]) => paths.map((path) => ({ path, text: '' })); + +function rulesBroken(...paths: string[]): string[] { + return checkNaming(named(...paths)).map((violation) => `${violation.rule} ${violation.file}`); +} + +describe('checkNaming', () => { + const cases: [string, string, string[]][] = [ + ['kebab-case file', 'src/client/output-ack.ts', []], + ['kebab-case test', 'src/client/output-ack.test.ts', []], + ['camelCase file', 'src/client/outputAck.ts', ['kebab-case src/client/outputAck.ts']], + ['snake_case file', 'src/client/output_ack.ts', ['kebab-case src/client/output_ack.ts']], + [ + 'capitalised folder', + 'src/client/Layout/fitter.ts', + ['kebab-case src/client/Layout/fitter.ts'], + ], + ['utils.ts', 'src/server/utils.ts', ['banned-name src/server/utils.ts']], + ['utils.test.ts', 'src/utils.test.ts', ['banned-name src/utils.test.ts']], + ['helpers.ts', 'src/cli/helpers.ts', ['banned-name src/cli/helpers.ts']], + ['misc.ts', 'scripts/misc.ts', ['banned-name scripts/misc.ts']], + ['types.ts', 'src/pty/types.ts', ['banned-name src/pty/types.ts']], + ['common folder', 'src/client/common/x.ts', ['banned-name src/client/common/x.ts']], + ['a types file named for its subject', 'src/client/engine-types.ts', []], + ['client entry', 'src/client/index.ts', []], + ['vendor barrel', 'src/client/vendor/ghostty-web/index.ts', []], + ['pty facade and its test', 'src/pty/index.test.ts', []], + [ + 'index.ts in a domain folder', + 'src/client/layout/index.ts', + ['index-file src/client/layout/index.ts'], + ], + ['test helper', 'src/client/test-support/fake-socket.ts', []], + [ + 'test helper named *.test.ts', + 'src/client/test-support/fake-socket.test.ts', + ['test-support-name src/client/test-support/fake-socket.test.ts'], + ], + [ + 'vendor test helper named *.test.ts', + 'src/client/vendor/tests/support/wasm.test.ts', + ['test-support-name src/client/vendor/tests/support/wasm.test.ts'], + ], + ]; + for (const [name, path, expected] of cases) { + test(name, () => expect(rulesBroken(path)).toEqual(expected)); + } + + test('client source basenames are unique', () => { + expect(rulesBroken('src/client/fit.ts', 'src/client/vendor/ghostty-web/addons/fit.ts')).toEqual( + [ + 'duplicate-basename src/client/fit.ts', + 'duplicate-basename src/client/vendor/ghostty-web/addons/fit.ts', + ], + ); + }); + + test('tests, index files and other layers may share a basename', () => { + expect( + rulesBroken( + 'src/client/a/x.test.ts', + 'src/client/b/x.test.ts', + 'src/client/index.ts', + 'src/client/vendor/ghostty-web/index.ts', + 'src/server/session.ts', + 'src/client/session.ts', + ), + ).toEqual([]); + }); + + test('a duplicate names the other file', () => { + const [violation] = checkNaming(named('src/client/a/x.ts', 'src/client/b/x.ts')); + expect(violation.message).toBe('src/client/a/x.ts shares its name with src/client/b/x.ts'); + expect(violation.key).toBe('duplicate-basename src/client/a/x.ts'); + }); +}); diff --git a/scripts/architecture/naming-rules.ts b/scripts/architecture/naming-rules.ts new file mode 100644 index 0000000..85abc41 --- /dev/null +++ b/scripts/architecture/naming-rules.ts @@ -0,0 +1,80 @@ +// The SPEC's naming rules: kebab-case names, banned names, where index.ts may sit, +// test helpers never named *.test.ts, unique client basenames (util, ADR 059). + +import path from 'node:path'; +import { + isTestCode, + isTestFile, + isTestSupport, + type RuleId, + type SourceFile, + stemOf, + type Violation, +} from './source-file'; + +const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** Names that say a file's kind, not its subject (SPEC "Naming"). */ +export const BANNED_STEMS = new Set(['utils', 'helpers', 'misc', 'types']); +/** A folder named for a kind of code rather than a domain. */ +export const BANNED_FOLDERS = new Set(['common']); + +/** The folders whose index.ts is an entry, a facade or the vendor barrel. */ +export const INDEX_FOLDERS = new Set([ + 'src/client', + 'src/server', + 'src/cli', + 'src/pty', + 'src/config', + 'src/client/vendor/ghostty-web', +]); + +function violation(rule: RuleId, file: string, message: string): Violation { + return { rule, file, key: `${rule} ${file}`, message: `${file} ${message}` }; +} + +function checkName(file: string): Violation[] { + const out: Violation[] = []; + const stem = stemOf(file); + const folders = path.posix.dirname(file).split('/'); + if (!KEBAB_RE.test(stem) || !folders.every((folder) => KEBAB_RE.test(folder))) { + out.push(violation('kebab-case', file, 'is not kebab-case')); + } + if (BANNED_STEMS.has(stem) || folders.some((folder) => BANNED_FOLDERS.has(folder))) { + out.push(violation('banned-name', file, 'has a banned name; name a file after its subject')); + } + if (stem === 'index' && !INDEX_FOLDERS.has(path.posix.dirname(file))) { + out.push(violation('index-file', file, 'is an index.ts outside an entry or barrel folder')); + } + if (isTestSupport(file) && isTestFile(file)) { + out.push(violation('test-support-name', file, 'is a test helper named *.test.ts')); + } + return out; +} + +/** Source files in src/client sharing a basename, so tabs and traces stay unambiguous. */ +function duplicateBasenames(files: readonly SourceFile[]): Violation[] { + const byName = new Map<string, string[]>(); + for (const { path: file } of files) { + if (!file.startsWith('src/client/') || isTestCode(file)) continue; + const name = path.posix.basename(file); + if (name === 'index.ts') continue; + byName.set(name, [...(byName.get(name) ?? []), file]); + } + return [...byName.values()] + .filter((same) => same.length > 1) + .flatMap((same) => + same.map((file) => + violation( + 'duplicate-basename', + file, + `shares its name with ${same.filter((f) => f !== file).join(', ')}`, + ), + ), + ); +} + +/** Every naming rule broken in the tree, keyed `<rule> <file>`. */ +export function checkNaming(files: readonly SourceFile[]): Violation[] { + return [...files.flatMap((file) => checkName(file.path)), ...duplicateBasenames(files)]; +} diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts new file mode 100644 index 0000000..cef72ef --- /dev/null +++ b/scripts/architecture/ratchet-allowlist.ts @@ -0,0 +1,72 @@ +// What the refactor still tolerates (constants, ADR 059): each file over its size limit +// at the commit that added the checker, with its line count, and each rule broken then, +// with the step that removes it. Entries may only be removed or lowered; the merge of +// the server and client branches empties both lists and sets `strict`. + +import type { Allowlist } from './ratchet'; + +const ENGINE_SPLIT = 'vendor engine split, ghostty.ts into engine/ (ADR 061)'; +const VENDOR_TESTS = 'vendor tests split with their facades into vendor/tests/ (ADR 061, ADR 064)'; +const CLIENT_ROOT = 'client composition root, index.ts an entry only (ADR 060)'; +const KITTY_SPLIT = 'kitty graphics split into features/kitty/ (ADR 060)'; +const VENDOR_BARREL = 'barrel exports Glide, Pace and watchDevicePixelRatio (ADR 060)'; +const SERVER_HARNESS = + 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; +const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; + +/** The allowlist the real tree is checked against. */ +export const RATCHET_ALLOWLIST: Allowlist = { + strict: false, + sizes: { + 'scripts/update-ghostty-vt.ts': { lines: 360, step: 'scripts/ghostty-vt/ split (ADR 059)' }, + 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, + 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, + 'src/client/graphics.ts': { lines: 662, step: KITTY_SPLIT }, + 'src/client/index.ts': { lines: 565, step: CLIENT_ROOT }, + 'src/client/kitty.ts': { lines: 384, step: KITTY_SPLIT }, + 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, + 'src/client/vendor/ghostty-web/input-handler.ts': { + lines: 856, + step: 'vendor input split into input/ (ADR 061)', + }, + 'src/client/vendor/ghostty-web/renderer.ts': { + lines: 1508, + step: 'vendor renderer split into render/ (ADR 061)', + }, + 'src/client/vendor/ghostty-web/selection-manager.ts': { + lines: 736, + step: 'vendor selection split into selection/ (ADR 061)', + }, + 'src/client/vendor/ghostty-web/terminal.ts': { + lines: 2147, + step: 'vendor terminal split into core/ (ADR 061)', + }, + 'src/client/vendor/official-terminal.test.ts': { lines: 1440, step: VENDOR_TESTS }, + 'src/client/vendor/renderer.test.ts': { lines: 1094, step: VENDOR_TESTS }, + 'src/client/vendor/terminal.test.ts': { lines: 1165, step: VENDOR_TESTS }, + 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, + 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, + }, + rules: { + 'test-import src/cli/commands.test.ts -> src/utils.test.ts': SERVER_HARNESS, + 'test-import src/server/routes.test.ts -> src/utils.test.ts': SERVER_HARNESS, + 'test-import src/server/websocket.test.ts -> src/utils.test.ts': SERVER_HARNESS, + 'test-import src/version.test.ts -> src/utils.test.ts': SERVER_HARNESS, + 'banned-name src/utils.test.ts': SERVER_HARNESS, + 'banned-name src/pty/types.ts': 'server track: pty/pty-process.ts (ADR 062)', + 'banned-name src/client/vendor/ghostty-web/types.ts': + 'V15: the vendor types.ts shim is removed (ADR 061)', + 'vendor-deep src/client/index.ts -> src/client/vendor/ghostty-web/renderer.ts': VENDOR_BARREL, + 'vendor-deep src/client/smooth-scroll.ts -> src/client/vendor/ghostty-web/glide.ts': + VENDOR_BARREL, + 'vendor-deep src/client/wheel-stream.ts -> src/client/vendor/ghostty-web/glide.ts': + VENDOR_BARREL, + // Upstream's Node fallback in Ghostty.loadFromPath; tests run under Bun, where Bun.file + // reads the wasm first, so engine/wasm-loader.ts can drop it. + 'client-node src/client/vendor/ghostty-web/ghostty.ts -> fs/promises': ENGINE_SPLIT, + 'duplicate-basename src/client/fit.ts': + 'client layout/: fit.ts becomes fit-insets.ts (ADR 060)', + 'duplicate-basename src/client/vendor/ghostty-web/addons/fit.ts': + 'client layout/: fit.ts becomes fit-insets.ts (ADR 060)', + }, +}; diff --git a/scripts/architecture/ratchet.test.ts b/scripts/architecture/ratchet.test.ts new file mode 100644 index 0000000..0834ede --- /dev/null +++ b/scripts/architecture/ratchet.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import { type Allowlist, applyAllowlist, EMPTY_ALLOWLIST } from './ratchet'; +import { RATCHET_ALLOWLIST } from './ratchet-allowlist'; +import type { SourceFile, Violation } from './source-file'; + +const sized = (file: string, lines: number): Violation => ({ + rule: 'size', + file, + key: `size ${file}`, + message: `${file} is big`, + lines, +}); +const broken = (key: string): Violation => ({ + rule: 'vendor-deep', + file: key.split(' ')[1], + key, + message: key, +}); +const files = (...entries: [string, number][]): SourceFile[] => + entries.map(([path, count]) => ({ path, text: 'x\n'.repeat(count) })); + +const allow = (overrides: Partial<Allowlist>): Allowlist => ({ + ...EMPTY_ALLOWLIST, + strict: false, + ...overrides, +}); + +describe('applyAllowlist', () => { + test('an empty allowlist keeps every violation', () => { + const found = [sized('src/a.ts', 400), broken('vendor-deep src/b.ts -> x')]; + expect(applyAllowlist(found, [], EMPTY_ALLOWLIST)).toEqual(found); + }); + + test('a listed file at or under its count passes', () => { + const list = allow({ sizes: { 'src/a.ts': { lines: 400, step: 'S' } } }); + expect(applyAllowlist([sized('src/a.ts', 400)], [], list)).toEqual([]); + expect(applyAllowlist([sized('src/a.ts', 350)], [], list)).toEqual([]); + }); + + test('a listed file that grew fails and names its step', () => { + const list = allow({ sizes: { 'src/a.ts': { lines: 400, step: 'step S' } } }); + const [violation] = applyAllowlist([sized('src/a.ts', 401)], [], list); + expect(violation.rule).toBe('size'); + expect(violation.message).toBe( + 'src/a.ts grew from 400 to 401 lines; an allowlisted file may only shrink (step S)', + ); + }); + + test('a listed rule break passes', () => { + const list = allow({ rules: { 'vendor-deep src/b.ts -> x': 'S' } }); + expect(applyAllowlist([broken('vendor-deep src/b.ts -> x')], [], list)).toEqual([]); + }); + + test('a missing or fixed entry is tolerated while the branches are apart', () => { + const list = allow({ + sizes: { 'src/gone.ts': { lines: 400, step: 'S' } }, + rules: { 'vendor-deep src/c.ts -> x': 'S' }, + }); + expect(applyAllowlist([], files(), list)).toEqual([]); + }); + + describe('strict', () => { + const strict = (overrides: Partial<Allowlist>): Allowlist => ({ + ...EMPTY_ALLOWLIST, + ...overrides, + }); + + test('a missing file is stale', () => { + const list = strict({ sizes: { 'src/gone.ts': { lines: 400, step: 'S' } } }); + expect(applyAllowlist([], files(), list).map((v) => v.message)).toEqual([ + "allowlist entry 'size src/gone.ts' names a missing file", + ]); + }); + + test('a file within its limit is stale', () => { + const list = strict({ sizes: { 'src/a.ts': { lines: 400, step: 'S' } } }); + expect(applyAllowlist([], files(['src/a.ts', 120]), list).map((v) => v.message)).toEqual([ + "allowlist entry 'size src/a.ts' is within its limit now (120 lines)", + ]); + }); + + test('a file that shrank must lower its count', () => { + const list = strict({ sizes: { 'src/a.ts': { lines: 400, step: 'S' } } }); + const found = [sized('src/a.ts', 350)]; + expect(applyAllowlist(found, files(['src/a.ts', 350]), list).map((v) => v.message)).toEqual([ + "allowlist entry 'size src/a.ts' shrank to 350 lines; lower the count", + ]); + }); + + test('a file at its count is not stale', () => { + const list = strict({ sizes: { 'src/a.ts': { lines: 400, step: 'S' } } }); + expect(applyAllowlist([sized('src/a.ts', 400)], files(['src/a.ts', 400]), list)).toEqual([]); + }); + + test('a rule entry matching nothing is stale', () => { + const list = strict({ rules: { 'vendor-deep src/b.ts -> x': 'S' } }); + const [violation] = applyAllowlist([], [], list); + expect(violation).toEqual({ + rule: 'stale-allowlist', + file: 'src/b.ts', + key: 'stale-allowlist vendor-deep src/b.ts -> x', + message: "allowlist entry 'vendor-deep src/b.ts -> x' matches no violation", + }); + }); + }); +}); + +describe('RATCHET_ALLOWLIST', () => { + test('tolerates missing entries until the branches merge', () => { + expect(RATCHET_ALLOWLIST.strict).toBe(false); + }); + + test('every entry names the step that removes it', () => { + for (const entry of Object.values(RATCHET_ALLOWLIST.sizes)) expect(entry.step).not.toBe(''); + for (const step of Object.values(RATCHET_ALLOWLIST.rules)) expect(step).not.toBe(''); + }); +}); diff --git a/scripts/architecture/ratchet.ts b/scripts/architecture/ratchet.ts new file mode 100644 index 0000000..ba73780 --- /dev/null +++ b/scripts/architecture/ratchet.ts @@ -0,0 +1,92 @@ +// Applies the ratchet allowlist to the checker's findings: a listed file may shrink or +// vanish but never grow, and a listed rule break is tolerated until its step removes +// it (util, ADR 059). + +import { lineCount, type SourceFile, type Violation } from './source-file'; + +/** A file allowed over its size limit for now, at its line count when it was listed. */ +export interface SizeEntry { + lines: number; + /** The refactor step that brings the file under its limit. */ + step: string; +} + +/** What the refactor still tolerates, and whether stale entries fail. */ +export interface Allowlist { + /** + * False while the server and client branches are apart, so a file the other branch + * moved does not fail; true once they merge and the lists are emptied. + */ + strict: boolean; + /** Oversized files by path. */ + sizes: Readonly<Record<string, SizeEntry>>; + /** Rule breaks by violation key, each with the step that removes it. */ + rules: Readonly<Record<string, string>>; +} + +/** An allowlist that tolerates nothing, for fixture tests. */ +export const EMPTY_ALLOWLIST: Allowlist = { strict: true, sizes: {}, rules: {} }; + +function allowSize(violation: Violation, entry: SizeEntry | undefined): Violation | null { + if (entry === undefined) return violation; + if ((violation.lines ?? 0) <= entry.lines) return null; + return { + ...violation, + message: `${violation.file} grew from ${entry.lines} to ${violation.lines} lines; an allowlisted file may only shrink (${entry.step})`, + }; +} + +function stale(file: string, key: string, why: string): Violation { + return { + rule: 'stale-allowlist', + file, + key: `stale-allowlist ${key}`, + message: `allowlist entry '${key}' ${why}`, + }; +} + +/** Allowlist entries that no longer match anything; reported only in strict mode. */ +function staleEntries( + violations: readonly Violation[], + files: readonly SourceFile[], + allowlist: Allowlist, +): Violation[] { + const texts = new Map(files.map((file) => [file.path, file.text])); + const oversized = new Set(violations.filter((v) => v.rule === 'size').map((v) => v.file)); + const keys = new Set(violations.map((violation) => violation.key)); + const out: Violation[] = []; + for (const [file, entry] of Object.entries(allowlist.sizes)) { + const text = texts.get(file); + if (text === undefined) out.push(stale(file, `size ${file}`, 'names a missing file')); + else if (!oversized.has(file)) { + out.push(stale(file, `size ${file}`, `is within its limit now (${lineCount(text)} lines)`)); + } else if (lineCount(text) < entry.lines) { + out.push(stale(file, `size ${file}`, `shrank to ${lineCount(text)} lines; lower the count`)); + } + } + for (const key of Object.keys(allowlist.rules)) { + if (!keys.has(key)) out.push(stale(key.split(' ')[1] ?? key, key, 'matches no violation')); + } + return out; +} + +/** The violations the allowlist does not cover, plus its stale entries when strict. */ +export function applyAllowlist( + violations: readonly Violation[], + files: readonly SourceFile[], + allowlist: Allowlist, +): Violation[] { + const remaining: Violation[] = []; + for (const violation of violations) { + const kept = + violation.rule === 'size' + ? allowSize(violation, allowlist.sizes[violation.file]) + : allowlist.rules[violation.key] === undefined + ? violation + : null; + if (kept !== null) remaining.push(kept); + } + return allowlist.strict + ? [...remaining, ...staleEntries(violations, files, allowlist)] + : remaining; +} diff --git a/scripts/architecture/size-limits.test.ts b/scripts/architecture/size-limits.test.ts new file mode 100644 index 0000000..2bbc7c3 --- /dev/null +++ b/scripts/architecture/size-limits.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { + checkSizes, + EXEMPT, + limitFor, + SOURCE_LIMIT, + TEST_LIMIT, + VENDOR_LIMIT, +} from './size-limits'; + +const lines = (count: number) => 'x\n'.repeat(count); +const V = 'src/client/vendor/ghostty-web/'; + +describe('limitFor', () => { + const cases: [string, number][] = [ + ['src/client/output-ack.ts', SOURCE_LIMIT], + ['src/server/app.ts', SOURCE_LIMIT], + ['scripts/build.ts', SOURCE_LIMIT], + ['src/client/test-support/fake-socket.ts', SOURCE_LIMIT], + ['src/client/output-ack.test.ts', TEST_LIMIT], + [`${V}terminal.test.ts`, TEST_LIMIT], + [`${V}glide.ts`, VENDOR_LIMIT], + [`${V}render/cell-painter.ts`, VENDOR_LIMIT], + [`${V}ghostty.ts`, 300], + [`${V}terminal.ts`, 600], + [`${V}renderer.ts`, 500], + [`${V}input-handler.ts`, 300], + [`${V}selection-manager.ts`, 300], + [`${V}engine/official-terminal.ts`, 500], + [`${V}engine/render-state.ts`, 700], + [`${V}input/key-map.ts`, Number.POSITIVE_INFINITY], + [`${V}input/key-types.ts`, Number.POSITIVE_INFINITY], + [`${V}engine/official-layout.ts`, Number.POSITIVE_INFINITY], + ]; + for (const [file, max] of cases) { + test(`${file} → ${max}`, () => expect(limitFor(file).max).toBe(max)); + } + + test('every exemption says why', () => { + for (const [file, reason] of Object.entries(EXEMPT)) { + expect(limitFor(file).reason).toBe(reason); + expect(reason.length).toBeGreaterThan(10); + } + }); +}); + +describe('checkSizes', () => { + test('a file at its limit passes; one line more fails with its count', () => { + const violations = checkSizes([ + { path: 'src/client/at-limit.ts', text: lines(SOURCE_LIMIT) }, + { path: 'src/client/over-limit.ts', text: lines(SOURCE_LIMIT + 1) }, + ]); + expect(violations).toEqual([ + { + rule: 'size', + file: 'src/client/over-limit.ts', + key: 'size src/client/over-limit.ts', + message: 'src/client/over-limit.ts has 301 lines, over its limit of 300 (source)', + lines: 301, + }, + ]); + }); + + test('an exempt data table has no limit', () => { + expect(checkSizes([{ path: `${V}input/key-map.ts`, text: lines(5000) }])).toEqual([]); + }); + + test('a test file may reach the test limit', () => { + expect(checkSizes([{ path: 'src/a.test.ts', text: lines(TEST_LIMIT) }])).toEqual([]); + expect(checkSizes([{ path: 'src/a.test.ts', text: lines(TEST_LIMIT + 1) }])).toHaveLength(1); + }); +}); diff --git a/scripts/architecture/size-limits.ts b/scripts/architecture/size-limits.ts new file mode 100644 index 0000000..cd9a402 --- /dev/null +++ b/scripts/architecture/size-limits.ts @@ -0,0 +1,66 @@ +// The SPEC's file size limits: which limit applies to which file, the exempt data +// tables with their reasons, and the check (constants + util, ADR 059). + +import { isTestFile, lineCount, type SourceFile, VENDOR_ROOT, type Violation } from './source-file'; + +/** The limit on one file, in lines, and why it is not the default for its layer. */ +export interface SizeLimit { + max: number; + reason: string; +} + +/** App, server, CLI, config, pty and script source (aim 200). */ +export const SOURCE_LIMIT = 300; +/** Vendored ghostty-web source (aim 400). */ +export const VENDOR_LIMIT = 600; +/** Test files (aim 600). */ +export const TEST_LIMIT = 800; + +/** The vendored facades and the two engine files with limits of their own (ADR 061). */ +export const OWN_LIMITS: Record<string, number> = { + [`${VENDOR_ROOT}ghostty.ts`]: 300, + [`${VENDOR_ROOT}terminal.ts`]: 600, + [`${VENDOR_ROOT}renderer.ts`]: 500, + [`${VENDOR_ROOT}input-handler.ts`]: 300, + [`${VENDOR_ROOT}selection-manager.ts`]: 300, + [`${VENDOR_ROOT}engine/official-terminal.ts`]: 500, + // The per-cell decode hot path, kept as one unit so its calls stay direct. + [`${VENDOR_ROOT}engine/render-state.ts`]: 700, +}; + +/** Data tables with no size limit: splitting a table across files would not help a reader. */ +export const EXEMPT: Record<string, string> = { + [`${VENDOR_ROOT}input/key-map.ts`]: 'data table: one entry per browser key code', + [`${VENDOR_ROOT}input/key-types.ts`]: + "data table: the Key enum, one member per key, translated to libghostty-vt's by name", + [`${VENDOR_ROOT}engine/official-layout.ts`]: + "data table: libghostty-vt's struct field offsets, one entry per field", +}; + +/** The limit that applies to `file`. */ +export function limitFor(file: string): SizeLimit { + if (file in EXEMPT) return { max: Number.POSITIVE_INFINITY, reason: EXEMPT[file] }; + if (isTestFile(file)) return { max: TEST_LIMIT, reason: 'test file' }; + if (file in OWN_LIMITS) + return { max: OWN_LIMITS[file], reason: 'facade or engine file, limit in the SPEC tree' }; + if (file.startsWith(VENDOR_ROOT)) return { max: VENDOR_LIMIT, reason: 'vendored source' }; + return { max: SOURCE_LIMIT, reason: 'source' }; +} + +/** Every file over its limit, keyed `size <file>`, with its line count for the ratchet. */ +export function checkSizes(files: readonly SourceFile[]): Violation[] { + const violations: Violation[] = []; + for (const file of files) { + const lines = lineCount(file.text); + const limit = limitFor(file.path); + if (lines <= limit.max) continue; + violations.push({ + rule: 'size', + file: file.path, + key: `size ${file.path}`, + message: `${file.path} has ${lines} lines, over its limit of ${limit.max} (${limit.reason})`, + lines, + }); + } + return violations; +} diff --git a/scripts/architecture/source-file.test.ts b/scripts/architecture/source-file.test.ts new file mode 100644 index 0000000..f36d03c --- /dev/null +++ b/scripts/architecture/source-file.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test'; +import { + isTestCode, + isTestFile, + isTestSupport, + type Layer, + layerOf, + lineCount, + stemOf, +} from './source-file'; + +describe('layerOf', () => { + const cases: [string, Layer][] = [ + ['src/client/vendor/ghostty-web/terminal.ts', 'vendor'], + ['src/client/vendor/tests/support/wasm.ts', 'client'], + ['src/client/output-ack.ts', 'client'], + ['src/server/app.ts', 'server'], + ['src/server', 'server'], + ['src/cli/main.ts', 'cli'], + ['src/pty/index.ts', 'pty'], + ['src/pty', 'pty'], + ['src/config.ts', 'config'], + ['src/config', 'config'], + ['src/config/defaults.ts', 'config'], + ['scripts/build.ts', 'scripts'], + ['src/version.ts', 'shared'], + ['src/configuration.ts', 'shared'], + ]; + for (const [file, layer] of cases) { + test(`${file} → ${layer}`, () => expect(layerOf(file)).toBe(layer)); + } +}); + +describe('test code', () => { + const cases: [string, boolean, boolean, boolean][] = [ + // file, isTestFile, isTestSupport, isTestCode + ['src/client/output-ack.ts', false, false, false], + ['src/client/output-ack.test.ts', true, false, true], + ['src/client/test-support/fake-socket.ts', false, true, true], + ['src/test-support/server-harness.ts', false, true, true], + ['src/client/vendor/tests/support/wasm.ts', false, true, true], + ['src/client/vendor/tests/engine/scratch.test.ts', true, false, true], + ['src/client/vendor/ghostty-web/glide.ts', false, false, false], + ]; + for (const [file, testFile, support, code] of cases) { + test(file, () => { + expect([isTestFile(file), isTestSupport(file), isTestCode(file)]).toEqual([ + testFile, + support, + code, + ]); + }); + } +}); + +describe('stemOf', () => { + test('drops the folder, .ts and .test', () => { + expect(['src/a/output-ack.ts', 'src/a/output-ack.test.ts', 'index.ts'].map(stemOf)).toEqual([ + 'output-ack', + 'output-ack', + 'index', + ]); + }); +}); + +describe('lineCount', () => { + const cases: [string, number][] = [ + ['', 0], + ['a', 1], + ['a\n', 1], + ['a\nb', 2], + ['a\nb\n', 2], + ['\n', 1], + ]; + for (const [text, count] of cases) { + test(JSON.stringify(text), () => expect(lineCount(text)).toBe(count)); + } +}); diff --git a/scripts/architecture/source-file.ts b/scripts/architecture/source-file.ts new file mode 100644 index 0000000..7bc954b --- /dev/null +++ b/scripts/architecture/source-file.ts @@ -0,0 +1,111 @@ +// The files the architecture checker reads, the findings it reports and the path +// predicates every rule shares (types + util, ADR 059). + +import path from 'node:path'; + +/** One `.ts` source file under `src/` or `scripts/`, by repo-relative POSIX path. */ +export interface SourceFile { + path: string; + text: string; +} + +/** The rules the checker enforces; a violation's `rule` and the allowlist keys use these ids. */ +export type RuleId = + | 'size' + | 'test-import' + | 'client-node' + | 'client-server' + | 'client-config-value' + | 'server-client' + | 'cli-server' + | 'vendor-outside' + | 'vendor-deep' + | 'feature-feature' + | 'facade-value-import' + | 'cycle' + | 'kebab-case' + | 'banned-name' + | 'index-file' + | 'test-support-name' + | 'duplicate-basename' + | 'stale-allowlist'; + +/** + * One broken rule. `key` is how the ratchet allowlist names it; `lines` is set for + * size violations so the ratchet can tell a shrinking file from a growing one. + */ +export interface Violation { + rule: RuleId; + file: string; + key: string; + message: string; + lines?: number; +} + +/** The vendored ghostty-web root, the one layer with its own size limits and import rule. */ +export const VENDOR_ROOT = 'src/client/vendor/ghostty-web/'; +/** The vendored layer's public barrel, the only vendor path app code may import. */ +export const VENDOR_BARREL = `${VENDOR_ROOT}index.ts`; + +/** The layers the import rules talk about; `shared` is src/version.ts and its kin. */ +export type Layer = + | 'vendor' + | 'client' + | 'server' + | 'cli' + | 'pty' + | 'config' + | 'scripts' + | 'shared'; + +const LAYER_PREFIXES: [string, Layer][] = [ + [VENDOR_ROOT, 'vendor'], + ['src/client/', 'client'], + ['src/server/', 'server'], + ['src/cli/', 'cli'], + ['src/pty/', 'pty'], + ['src/config/', 'config'], + ['scripts/', 'scripts'], +]; + +/** Which layer a file (or an unresolved import target) belongs to. */ +export function layerOf(file: string): Layer { + if (file === 'src/config' || file === 'src/config.ts') return 'config'; + for (const [prefix, layer] of LAYER_PREFIXES) { + if (file.startsWith(prefix) || `${file}/` === prefix) return layer; + } + return 'shared'; +} + +/** A test file, by its `*.test.ts` name. */ +export function isTestFile(file: string): boolean { + return file.endsWith('.test.ts'); +} + +/** A shared test helper: under a `test-support/` folder or the vendor tests' `support/`. */ +export function isTestSupport(file: string): boolean { + return /(^|\/)test-support\//.test(file) || file.startsWith('src/client/vendor/tests/support/'); +} + +/** + * Test code: tests, their helpers and the vendor test mirror. The import rules skip it, + * so contract tests may cross the client/server line (SPEC "Layers and dependency rules"). + */ +export function isTestCode(file: string): boolean { + return isTestFile(file) || isTestSupport(file) || file.startsWith('src/client/vendor/tests/'); +} + +/** The file name without `.ts` and without a `.test` suffix: `ack.test.ts` → `ack`. */ +export function stemOf(file: string): string { + return path.posix + .basename(file) + .replace(/\.ts$/, '') + .replace(/\.test$/, ''); +} + +/** Lines as `wc -l` counts them: a final newline does not start another line. */ +export function lineCount(text: string): number { + if (text === '') return 0; + const lines = text.split('\n').length; + return text.endsWith('\n') ? lines - 1 : lines; +} diff --git a/scripts/check-architecture.test.ts b/scripts/check-architecture.test.ts new file mode 100644 index 0000000..9bf2628 --- /dev/null +++ b/scripts/check-architecture.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { EMPTY_ALLOWLIST } from './architecture/ratchet'; +import type { SourceFile } from './architecture/source-file'; +import { checkArchitecture, isCheckedFile, readSourceTree, runCheck } from './check-architecture'; + +const REPO_ROOT = path.join(import.meta.dir, '..'); + +const file = (filePath: string, ...lines: string[]): SourceFile => ({ + path: filePath, + text: `${lines.join('\n')}\n`, +}); + +let tmpRoots: string[] = []; + +function tmpTree(files: Record<string, string>): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-arch-')); + tmpRoots.push(root); + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + } + return root; +} + +afterEach(() => { + for (const root of tmpRoots) fs.rmSync(root, { recursive: true, force: true }); + tmpRoots = []; +}); + +describe('the real tree', () => { + test('breaks no rule the ratchet allowlist does not cover', () => { + const violations = checkArchitecture(readSourceTree(REPO_ROOT)); + expect(violations.map((violation) => violation.message)).toEqual([]); + }); +}); + +describe('isCheckedFile', () => { + const cases: [string, boolean][] = [ + ['a.ts', true], + ['a.test.ts', true], + ['node-pty.d.ts', false], + ['less.bin', false], + ['ghostty-vt-small.wasm', false], + ['ghostty-vt-small.wasm.json', false], + ['PROVENANCE.md', false], + ['client.html', false], + ['index.css', false], + ]; + for (const [name, expected] of cases) { + test(`${name} → ${expected}`, () => expect(isCheckedFile(name)).toBe(expected)); + } +}); + +describe('readSourceTree', () => { + test('reads .ts files under src/ and scripts/ only, sorted', () => { + const root = tmpTree({ + 'src/b.ts': 'b', + 'src/a/a.ts': 'a', + 'src/pty/node-pty.d.ts': 'declare', + 'src/client/vendor/fixtures/ls.bin': 'bytes', + 'src/node_modules/x/index.ts': 'skipped', + 'src/.hidden/x.ts': 'skipped', + 'scripts/build.ts': 'build', + 'docs/x.ts': 'not walked', + }); + expect(readSourceTree(root)).toEqual([ + { path: 'scripts/build.ts', text: 'build' }, + { path: 'src/a/a.ts', text: 'a' }, + { path: 'src/b.ts', text: 'b' }, + ]); + }); + + test('a tree without scripts/ is fine', () => { + expect(readSourceTree(tmpTree({ 'src/a.ts': '' }))).toEqual([{ path: 'src/a.ts', text: '' }]); + }); +}); + +describe('checkArchitecture', () => { + test('runs the size, import, cycle and naming rules together', () => { + const files = [ + file('src/client/big-file.ts', ...Array<string>(301).fill('')), + file('src/client/a.ts', "import fs from 'node:fs';", "import { b } from './b';"), + file('src/client/b.ts', "import { a } from './a';"), + file('src/client/myFile.ts'), + ]; + expect(checkArchitecture(files, EMPTY_ALLOWLIST).map((violation) => violation.rule)).toEqual([ + 'size', + 'client-node', + 'cycle', + 'kebab-case', + ]); + }); + + test('an allowlisted file that grows fails', () => { + const files = [file('src/client/big-file.ts', ...Array<string>(320).fill(''))]; + const allowlist = { + ...EMPTY_ALLOWLIST, + sizes: { 'src/client/big-file.ts': { lines: 310, step: 'S' } }, + }; + const [violation] = checkArchitecture(files, allowlist); + expect(violation.message).toContain('grew from 310 to 320 lines'); + }); +}); + +describe('runCheck', () => { + test('returns 0 and prints nothing for a clean tree', () => { + const printed: string[] = []; + expect( + runCheck(tmpTree({ 'src/client/output-ack.ts': '' }), (line) => printed.push(line)), + ).toBe(0); + expect(printed).toEqual([]); + }); + + test('returns 1 and prints each violation and a total', () => { + const printed: string[] = []; + const root = tmpTree({ 'src/client/a.ts': "import fs from 'node:fs';\n" }); + expect(runCheck(root, (line) => printed.push(line))).toBe(1); + expect(printed).toEqual([ + "client-node: src/client/a.ts:1 imports the Node module 'node:fs'", + '1 architecture violation(s); see docs/specs/architecture.md', + ]); + }); +}); diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts new file mode 100644 index 0000000..9caeca5 --- /dev/null +++ b/scripts/check-architecture.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +// Checks the code organisation rules of docs/specs/architecture.md: file sizes with a +// ratchet allowlist, import rules, runtime cycles and naming (entry, ADR 059). It runs +// under `bun test` through check-architecture.test.ts, and alone with +// `bun scripts/check-architecture.ts`. + +import fs from 'node:fs'; +import path from 'node:path'; +import { checkCycles } from './architecture/import-cycles'; +import { checkImports } from './architecture/import-rules'; +import { checkNaming } from './architecture/naming-rules'; +import { type Allowlist, applyAllowlist } from './architecture/ratchet'; +import { RATCHET_ALLOWLIST } from './architecture/ratchet-allowlist'; +import { checkSizes } from './architecture/size-limits'; +import type { SourceFile, Violation } from './architecture/source-file'; + +/** The folders the checker walks, relative to the repo root. */ +export const CHECKED_ROOTS = ['src', 'scripts']; + +const SKIPPED_FOLDERS = new Set(['node_modules', 'dist', 'coverage']); + +/** A file the checker counts: `.ts` source, not a `.d.ts` declaration. */ +export function isCheckedFile(name: string): boolean { + return name.endsWith('.ts') && !name.endsWith('.d.ts'); +} + +function walk(root: string, folder: string, out: SourceFile[]): void { + for (const entry of fs.readdirSync(path.join(root, folder), { withFileTypes: true })) { + const rel = `${folder}/${entry.name}`; + if (entry.isDirectory()) { + if (!SKIPPED_FOLDERS.has(entry.name) && !entry.name.startsWith('.')) walk(root, rel, out); + } else if (entry.isFile() && isCheckedFile(entry.name)) { + out.push({ path: rel, text: fs.readFileSync(path.join(root, rel), 'utf8') }); + } + } +} + +/** Every checked file under `src/` and `scripts/` of the repo at `root`, sorted by path. */ +export function readSourceTree(root: string): SourceFile[] { + const files: SourceFile[] = []; + for (const folder of CHECKED_ROOTS) { + if (fs.existsSync(path.join(root, folder))) walk(root, folder, files); + } + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** Every rule broken in `files` that `allowlist` does not tolerate. */ +export function checkArchitecture( + files: readonly SourceFile[], + allowlist: Allowlist = RATCHET_ALLOWLIST, +): Violation[] { + const found = [ + ...checkSizes(files), + ...checkImports(files), + ...checkCycles(files), + ...checkNaming(files), + ]; + return applyAllowlist(found, files, allowlist); +} + +/** Prints the violations and returns the exit code. */ +export function runCheck(root: string, print: (line: string) => void = console.error): number { + const violations = checkArchitecture(readSourceTree(root)); + for (const violation of violations) print(`${violation.rule}: ${violation.message}`); + if (violations.length > 0) { + print(`${violations.length} architecture violation(s); see docs/specs/architecture.md`); + } + return violations.length === 0 ? 0 : 1; +} + +if (import.meta.main) { + process.exit(runCheck(path.join(import.meta.dir, '..'))); +} From 66fb27b0988b4d631c0d81f0a08f14b72e4faf55 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:56:06 -0400 Subject: [PATCH 142/295] docs(arch): the architecture checker's parts in the SPEC tree The checker is an entry plus scripts/architecture/, one module per rule group; the facade rule is now checked, and the allowlist keys are named. ADR 059. --- docs/specs/architecture.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 84106f8..ab26c46 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -45,7 +45,7 @@ src/client: | `src/client/vendor/ghostty-web/**` imports nothing outside itself. | same | | App code (`src/client/**` outside `vendor/`) imports vendor code only through `vendor/ghostty-web` (the barrel), never a deep path. Tests may deep-import. | same | | `src/client/features/<a>/**` never imports `src/client/features/<b>/**`. Features reach each other only through handles the composition root passes in. | same | -| Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`), never the facade class as a value, so no runtime import cycle can form. | review + `tsc` | +| Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`), never the facade class as a value, so no runtime import cycle can form. | same (a value import of a facade, and any runtime cycle), plus review | | A file under a `test-support/` folder is never named `*.test.ts`, and no file imports a `*.test.ts` file. | same | | Test files (`*.test.ts`) may cross the client/server line (contract tests); the import rules above apply to source files only. | same | | The checker counts `.ts` files only, excluding `*.d.ts`; fixtures, the wasm, JSON, Markdown, HTML and CSS are not source. | same | @@ -160,7 +160,10 @@ src/ ghostty-web/ Biome-excluded; see "The vendored layer" scripts/ build.ts unchanged outputs - check-architecture.ts size, naming and import rules with the ratchet allowlist + check-architecture.test.ts + check-architecture.ts entry: walks src/ and scripts/, runs the rules below, applies the allowlist (+ test) + architecture/ the checker's parts, each with its test: source-file.ts (paths, layers), size-limits.ts, + import-scan.ts, import-rules.ts (one table entry per rule), import-cycles.ts, + naming-rules.ts, ratchet.ts, ratchet-allowlist.ts (the data, with each entry's step) check-coverage.ts per-layer coverage targets over bun's lcov report (run by test:coverage) check-pkg-manager.ts clean-pkg-scripts.ts package-manager guard and publish-time script cleanup (headers; pure parts tested) update-ghostty-vt.ts CLI only (--commit, --verify) @@ -276,7 +279,7 @@ Counted in lines of `.ts` source (not `*.d.ts`), comments and blank lines includ | Test files | ≤ 600 | 800 | | Functions | ≤ 60 lines | not checked; hot paths (`decodeRow`, `render`) say why when longer | -While the refactor runs, the checker carries a **ratchet allowlist**: each file over its limit today, with its current line count. A listed file may shrink or disappear, never grow; each step removes the entries it fixes, and the list is empty at the end. While both branches run, a missing (deleted or renamed) entry is tolerated so the server branch never edits the checker; once they merge the list is emptied and a stale entry fails. +While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/architecture/ratchet-allowlist.ts`): each file over its limit today, with its current line count, and each import or naming rule broken today, keyed `<rule> <file> -> <target>` or `<rule> <file>`, each with the step that removes it. A listed file may shrink or disappear, never grow; each step removes the entries it fixes, and the list is empty at the end. While both branches run, a missing (deleted or renamed) entry is tolerated so the server branch never edits the checker; once they merge the list is emptied and a stale entry fails. ## Naming @@ -313,7 +316,7 @@ While the refactor runs, the checker carries a **ratchet allowlist**: each file | Feature | Description | ADR | Done? | |---|---|---|---| | Architecture SPEC and conventions | Layers, folders, module kinds, naming, size limits, comments | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | -| Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ⬜ | +| Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | | Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | From 91213c0e2a3cec98668fe5ce74ce1679e21ae84b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:57:06 -0400 Subject: [PATCH 143/295] refactor(config): split the config module into schema, paths, defaults, parser and bindings src/config/index.ts keeps loadConfig and initConfig and re-exports everything it exported before, so every '../config' import resolves unchanged. The types move to config-schema.ts, which also defines CLIENT_CONFIG_KEYS (the one list of client-visible keys, legacy 'scrollback' included) and ClientConfig as a Pick of Config over it. The file rules are in parse-config.ts (pure), the binding rules in keyboard-bindings.ts, and toClientConfig in client-view.ts. Parsing is unchanged key for key (ADR 062). --- src/config/client-view.ts | 9 + src/config/config-paths.ts | 20 ++ src/config/config-schema.ts | 160 +++++++++++++++ src/config/defaults.ts | 63 ++++++ src/config/index.ts | 331 +++----------------------------- src/config/keyboard-bindings.ts | 52 +++++ src/config/parse-config.ts | 84 ++++++++ 7 files changed, 411 insertions(+), 308 deletions(-) create mode 100644 src/config/client-view.ts create mode 100644 src/config/config-paths.ts create mode 100644 src/config/config-schema.ts create mode 100644 src/config/defaults.ts create mode 100644 src/config/keyboard-bindings.ts create mode 100644 src/config/parse-config.ts diff --git a/src/config/client-view.ts b/src/config/client-view.ts new file mode 100644 index 0000000..988ca5b --- /dev/null +++ b/src/config/client-view.ts @@ -0,0 +1,9 @@ +// The client-safe view of a Config, served by `GET /api/config`. A pure util module built on +// the one key list in config-schema.ts, so the served keys and their order are defined once +// (ADR 062, architecture SPEC "Contracts across the client/server line"). +import { CLIENT_CONFIG_KEYS, type ClientConfig, type Config } from './config-schema'; + +/** Picks {@link CLIENT_CONFIG_KEYS} from `config`, in that order, so server-only keys never reach the browser. */ +export function toClientConfig(config: Config): ClientConfig { + return Object.fromEntries(CLIENT_CONFIG_KEYS.map((key) => [key, config[key]])) as ClientConfig; +} diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts new file mode 100644 index 0000000..6cb699b --- /dev/null +++ b/src/config/config-paths.ts @@ -0,0 +1,20 @@ +// Where webtty keeps its configuration on disk. A small util module that reads HOME per +// call, so tests can point it at a temporary directory (ADR 062). +import os from 'node:os'; +import path from 'node:path'; + +/** Returns the webtty config directory: `~/.config/webtty`. */ +export function configDir(): string { + // os.homedir() is the authoritative home directory. process.env.HOME is used + // as an override in tests (e.g. to isolate config state), but only when it is + // an absolute path — guarding against accidental env pollution such as the + // string "undefined" being assigned when HOME was originally unset on Windows. + const home = + process.env.HOME && path.isAbsolute(process.env.HOME) ? process.env.HOME : os.homedir(); + return path.join(home, '.config', 'webtty'); +} + +/** Returns the config file path, `~/.config/webtty/config.json`, which loadConfig reads and initConfig writes. */ +export function getConfigPath(): string { + return path.join(configDir(), 'config.json'); +} diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts new file mode 100644 index 0000000..33c1021 --- /dev/null +++ b/src/config/config-schema.ts @@ -0,0 +1,160 @@ +// The shape of webtty's configuration: the Config type and its parts, plus the one list of +// keys the client may see. A types module with a single constant tuple; the client imports +// it with `import type` only (ADR 062, architecture SPEC "Contracts across the client/server line"). + +/** A single keyboard binding: intercepts `key`+`mods` and sends `chars` to the PTY. */ +export interface KeyboardBinding { + /** Key name matched case-insensitively against `KeyboardEvent.key` (e.g. `"enter"`, `"arrowup"`, `"a"`). */ + key: string; + /** Modifier keys that must be active — and no others — for this binding to match. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Order irrelevant. Unknown values are filtered out at config load time. */ + mods?: string[]; + /** Byte sequence sent verbatim to the PTY. Standard JSON escapes apply (`\r`, `\uXXXX`, etc.). */ + chars: string; +} + +/** Terminal color palette. All keys are optional; omitted keys fall back to the Campbell defaults. */ +export interface Theme { + /** Terminal background. */ + background?: string; + /** Default text color. */ + foreground?: string; + /** Cursor color. */ + cursor?: string; + /** Selection highlight (background). */ + selection?: string; + /** Text colour inside the selection. Defaults to `background`. */ + selectionForeground?: string; + /** Background of find matches (ADR 055). Defaults to Ghostty's `#FFE082`. */ + searchBackground?: string; + /** Text colour of find matches. Defaults to `#000000`. */ + searchForeground?: string; + /** Background of the selected find match. Defaults to Ghostty's `#F2A57E`. */ + searchSelectedBackground?: string; + /** Text colour of the selected find match. Defaults to `#000000`. */ + searchSelectedForeground?: string; + /** Colour of the padding set by the `padding` setting. Defaults to `background`. The sub-cell leftover from fitting is not coloured by this: the client extends the edge cells' own background into it. */ + padding?: string; + /** ANSI 0. */ + black?: string; + /** ANSI 1. */ + red?: string; + /** ANSI 2. */ + green?: string; + /** ANSI 3. */ + yellow?: string; + /** ANSI 4. */ + blue?: string; + /** ANSI 5. */ + purple?: string; + /** ANSI 6. */ + cyan?: string; + /** ANSI 7. */ + white?: string; + /** ANSI 8. */ + brightBlack?: string; + /** ANSI 9. */ + brightRed?: string; + /** ANSI 10. */ + brightGreen?: string; + /** ANSI 11. */ + brightYellow?: string; + /** ANSI 12. */ + brightBlue?: string; + /** ANSI 13. */ + brightPurple?: string; + /** ANSI 14. */ + brightCyan?: string; + /** ANSI 15. */ + brightWhite?: string; +} + +/** + * What a right-click does when the app does not take it (it takes it while it tracks the + * mouse, unless Shift is held): `"contextMenu"` shows the browser menu; `"copyPaste"` + * copies and clears a selection, or shows the menu without one; `"paste"` pastes the + * clipboard; `"none"` does nothing. See ADR 051. + */ +export type RightClickBehavior = 'contextMenu' | 'copyPaste' | 'paste' | 'none'; + +/** Full webtty configuration. All keys are optional in the config file; missing keys fall back to {@link DEFAULT_CONFIG}. */ +export interface Config { + /** HTTP listen port; env `PORT` takes precedence. */ + port: number; + /** Bind address; use `"0.0.0.0"` for remote access. */ + host: string; + /** Shell for new sessions. */ + shell: string; + /** `$TERM` env var passed to the shell. Fixed to `xterm-256color` — the PTY child talks to webtty, not to the parent terminal. */ + term: string; + /** `$COLORTERM` env var passed to the shell. */ + colorTerm: string; + /** PTY history buffer in bytes; used for server-side replay on reload/reconnect. The client does not read it. */ + scrollback: number; + /** Terminal history in lines; `0` = no line limit. History is also capped at 32 MiB. See ADR 046. */ + scrollbackLines: number; + /** Initial terminal width in columns. */ + cols: number; + /** Initial terminal height in rows. */ + rows: number; + /** Font size in px. */ + fontSize: number; + /** CSS font-family stack. */ + fontFamily: string; + /** Default cursor shape. Apps override at runtime via DECSCUSR — this is the startup default only. */ + cursorStyle: 'block' | 'bar' | 'underline'; + /** Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. */ + cursorStyleBlink: boolean; + /** Copy a mouse selection to the clipboard when the button is released (kitty / Windows Terminal style). A drag the app receives is not a selection. */ + copyOnSelect: boolean; + /** What a right-click does when the app does not take it; see {@link RightClickBehavior}. */ + rightClickBehavior: RightClickBehavior; + /** Let programs write the clipboard with OSC 52 (write only: reads are never answered). `false` ignores the writes. */ + clipboardWrite: boolean; + /** Put a 🔔 in front of the tab title when a program rings the bell (BEL), until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title`. Bells replayed on reconnect are ignored. */ + bellTitle: boolean; + /** Let programs show desktop notifications with OSC 9 and OSC 777, as Ghostty's `desktop-notifications`: shown while the terminal is not focused, once the browser grants permission. Notifications replayed on reconnect are ignored. `false` ignores them. */ + desktopNotifications: boolean; + /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ + mouseScrollSpeed: number; + /** Padding in px around the terminal canvas, on all four sides. `0` = none. Coloured by `theme.padding`, or `theme.background` if that is unset. */ + padding: number; + /** Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. */ + logs: boolean; + /** Terminal color palette. */ + theme: Theme; + /** Custom key-to-sequence bindings. Merged with built-in defaults by `(key, mods)` identity. */ + keyboardBindings: KeyboardBinding[]; + /** Additional environment variables injected into PTY shells and execute commands. Merges over `process.env`; `TERM` and `COLORTERM` always take precedence for PTY spawns. */ + env: Record<string, string>; +} + +/** + * The keys `GET /api/config` sends to the browser, in the order they are sent. Server-only + * keys (port, host, shell, term, colorTerm, logs, env) stay out. `scrollback` is legacy: the + * client no longer reads it, but it is still sent so the response is unchanged. + */ +export const CLIENT_CONFIG_KEYS = [ + 'cols', + 'rows', + 'fontSize', + 'fontFamily', + 'cursorStyle', + 'cursorStyleBlink', + 'scrollback', + 'scrollbackLines', + 'theme', + 'copyOnSelect', + 'rightClickBehavior', + 'clipboardWrite', + 'bellTitle', + 'desktopNotifications', + 'mouseScrollSpeed', + 'padding', + 'keyboardBindings', +] as const satisfies readonly (keyof Config)[]; + +/** One key of {@link CLIENT_CONFIG_KEYS}. */ +export type ClientConfigKey = (typeof CLIENT_CONFIG_KEYS)[number]; + +/** The client-safe view of {@link Config} served by `GET /api/config`; the one type the server and the browser share. */ +export type ClientConfig = Pick<Config, ClientConfigKey>; diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..4c50f43 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,63 @@ +// The built-in configuration every missing key falls back to. A constants module; the shell +// default is read from the environment once, when the module loads (ADR 062). +import type { Config, KeyboardBinding, RightClickBehavior, Theme } from './config-schema'; + +/** The Campbell palette, used for every theme key the config file leaves out. */ +export const DEFAULT_THEME: Theme = { + background: '#000000', + foreground: '#CCCCCC', + cursor: '#FFFFFF', + selection: '#FFFFFF', + black: '#0C0C0C', + red: '#C50F1F', + green: '#13A10E', + yellow: '#C19C00', + blue: '#0037DA', + purple: '#881798', + cyan: '#3A96DD', + white: '#CCCCCC', + brightBlack: '#767676', + brightRed: '#E74856', + brightGreen: '#16C60C', + brightYellow: '#F9F1A5', + brightBlue: '#3B78FF', + brightPurple: '#B4009E', + brightCyan: '#61D6D6', + brightWhite: '#F2F2F2', +}; + +/** No bindings ship by default; user bindings are merged over this list by `(key, mods)`. */ +export const DEFAULT_KEYBOARD_BINDINGS: KeyboardBinding[] = []; + +/** The full configuration used when the file sets nothing; loadConfig spreads the parsed keys over it. */ +export const DEFAULT_CONFIG: Config = { + port: 2346, + host: '127.0.0.1', + shell: + process.platform === 'win32' + ? (process.env.COMSPEC ?? 'cmd.exe') + : (process.env.SHELL ?? '/bin/bash'), + term: 'xterm-256color', + colorTerm: 'truecolor', + scrollback: 256 * 1024, + scrollbackLines: 10000, + cols: 80, + rows: 24, + fontSize: 13, + // 'STIX Two Text' comes last: it fills only clusters no earlier font covers whole, + // such as a letter with an enclosing mark (a⃝, U+20DD), which Menlo lacks. + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + cursorStyle: 'bar', + cursorStyleBlink: true, + copyOnSelect: true, + rightClickBehavior: 'contextMenu' as RightClickBehavior, + clipboardWrite: true, + bellTitle: true, + desktopNotifications: true, + mouseScrollSpeed: 1, + padding: 0, + logs: false, + theme: DEFAULT_THEME, + keyboardBindings: DEFAULT_KEYBOARD_BINDINGS, + env: {}, +}; diff --git a/src/config/index.ts b/src/config/index.ts index 272a9eb..96bb3dc 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,260 +1,27 @@ +// The config service used by the server and the CLI: read the config file (writing a stub on +// first run) and re-export the schema, defaults and parsers, so every '../config' import keeps +// working. A service module; the pure parts live beside it (ADR 062, ADR 009). import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; - -/** A single keyboard binding: intercepts `key`+`mods` and sends `chars` to the PTY. */ -export interface KeyboardBinding { - /** Key name matched case-insensitively against `KeyboardEvent.key` (e.g. `"enter"`, `"arrowup"`, `"a"`). */ - key: string; - /** Modifier keys that must be active — and no others — for this binding to match. Accepted values: `"shift"`, `"ctrl"`, `"alt"`, `"meta"`. Order irrelevant. Unknown values are filtered out at config load time. */ - mods?: string[]; - /** Byte sequence sent verbatim to the PTY. Standard JSON escapes apply (`\r`, `\uXXXX`, etc.). */ - chars: string; -} - -/** Terminal color palette. All keys are optional; omitted keys fall back to the Campbell defaults. */ -export interface Theme { - /** Terminal background. */ - background?: string; - /** Default text color. */ - foreground?: string; - /** Cursor color. */ - cursor?: string; - /** Selection highlight (background). */ - selection?: string; - /** Text colour inside the selection. Defaults to `background`. */ - selectionForeground?: string; - /** Background of find matches (ADR 055). Defaults to Ghostty's `#FFE082`. */ - searchBackground?: string; - /** Text colour of find matches. Defaults to `#000000`. */ - searchForeground?: string; - /** Background of the selected find match. Defaults to Ghostty's `#F2A57E`. */ - searchSelectedBackground?: string; - /** Text colour of the selected find match. Defaults to `#000000`. */ - searchSelectedForeground?: string; - /** Colour of the padding set by the `padding` setting. Defaults to `background`. The sub-cell leftover from fitting is not coloured by this: the client extends the edge cells' own background into it. */ - padding?: string; - /** ANSI 0. */ - black?: string; - /** ANSI 1. */ - red?: string; - /** ANSI 2. */ - green?: string; - /** ANSI 3. */ - yellow?: string; - /** ANSI 4. */ - blue?: string; - /** ANSI 5. */ - purple?: string; - /** ANSI 6. */ - cyan?: string; - /** ANSI 7. */ - white?: string; - /** ANSI 8. */ - brightBlack?: string; - /** ANSI 9. */ - brightRed?: string; - /** ANSI 10. */ - brightGreen?: string; - /** ANSI 11. */ - brightYellow?: string; - /** ANSI 12. */ - brightBlue?: string; - /** ANSI 13. */ - brightPurple?: string; - /** ANSI 14. */ - brightCyan?: string; - /** ANSI 15. */ - brightWhite?: string; -} - -/** - * What a right-click does when the app does not take it (it takes it while it tracks the - * mouse, unless Shift is held): `"contextMenu"` shows the browser menu; `"copyPaste"` - * copies and clears a selection, or shows the menu without one; `"paste"` pastes the - * clipboard; `"none"` does nothing. See ADR 051. - */ -export type RightClickBehavior = 'contextMenu' | 'copyPaste' | 'paste' | 'none'; - -const RIGHT_CLICK_BEHAVIORS: readonly string[] = ['contextMenu', 'copyPaste', 'paste', 'none']; - -/** A configured `rightClickBehavior`: `"default"` is the old name of `"contextMenu"`, and anything unknown is `"contextMenu"`. */ -export function parseRightClickBehavior(value: string): RightClickBehavior { - return RIGHT_CLICK_BEHAVIORS.includes(value) ? (value as RightClickBehavior) : 'contextMenu'; -} - -/** Full webtty configuration. All keys are optional in the config file; missing keys fall back to {@link DEFAULT_CONFIG}. */ -export interface Config { - /** HTTP listen port; env `PORT` takes precedence. */ - port: number; - /** Bind address; use `"0.0.0.0"` for remote access. */ - host: string; - /** Shell for new sessions. */ - shell: string; - /** `$TERM` env var passed to the shell. Fixed to `xterm-256color` — the PTY child talks to webtty, not to the parent terminal. */ - term: string; - /** `$COLORTERM` env var passed to the shell. */ - colorTerm: string; - /** PTY history buffer in bytes; used for server-side replay on reload/reconnect. The client does not read it. */ - scrollback: number; - /** Terminal history in lines; `0` = no line limit. History is also capped at 32 MiB. See ADR 046. */ - scrollbackLines: number; - /** Initial terminal width in columns. */ - cols: number; - /** Initial terminal height in rows. */ - rows: number; - /** Font size in px. */ - fontSize: number; - /** CSS font-family stack. */ - fontFamily: string; - /** Default cursor shape. Apps override at runtime via DECSCUSR — this is the startup default only. */ - cursorStyle: 'block' | 'bar' | 'underline'; - /** Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. */ - cursorStyleBlink: boolean; - /** Copy a mouse selection to the clipboard when the button is released (kitty / Windows Terminal style). A drag the app receives is not a selection. */ - copyOnSelect: boolean; - /** What a right-click does when the app does not take it; see {@link RightClickBehavior}. */ - rightClickBehavior: RightClickBehavior; - /** Let programs write the clipboard with OSC 52 (write only: reads are never answered). `false` ignores the writes. */ - clipboardWrite: boolean; - /** Put a 🔔 in front of the tab title when a program rings the bell (BEL), until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title`. Bells replayed on reconnect are ignored. */ - bellTitle: boolean; - /** Let programs show desktop notifications with OSC 9 and OSC 777, as Ghostty's `desktop-notifications`: shown while the terminal is not focused, once the browser grants permission. Notifications replayed on reconnect are ignored. `false` ignores them. */ - desktopNotifications: boolean; - /** Mouse wheel scroll speed multiplier for apps with mouse tracking. `1` = one SGR per tick. Values `< 1` reduce rate; values `> 1` send multiple SGRs per tick. Must be `> 0`. */ - mouseScrollSpeed: number; - /** Padding in px around the terminal canvas, on all four sides. `0` = none. Coloured by `theme.padding`, or `theme.background` if that is unset. */ - padding: number; - /** Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. */ - logs: boolean; - /** Terminal color palette. */ - theme: Theme; - /** Custom key-to-sequence bindings. Merged with built-in defaults by `(key, mods)` identity. */ - keyboardBindings: KeyboardBinding[]; - /** Additional environment variables injected into PTY shells and execute commands. Merges over `process.env`; `TERM` and `COLORTERM` always take precedence for PTY spawns. */ - env: Record<string, string>; -} - -/** Returns the webtty config directory: `~/.config/webtty`. */ -export function configDir(): string { - // os.homedir() is the authoritative home directory. process.env.HOME is used - // as an override in tests (e.g. to isolate config state), but only when it is - // an absolute path — guarding against accidental env pollution such as the - // string "undefined" being assigned when HOME was originally unset on Windows. - const home = - process.env.HOME && path.isAbsolute(process.env.HOME) ? process.env.HOME : os.homedir(); - return path.join(home, '.config', 'webtty'); -} - -function getConfigPath(): string { - return path.join(configDir(), 'config.json'); -} - -// NOTE: export for testing only; users should use loadConfig() and initConfig() instead -export const DEFAULT_THEME: Theme = { - background: '#000000', - foreground: '#CCCCCC', - cursor: '#FFFFFF', - selection: '#FFFFFF', - black: '#0C0C0C', - red: '#C50F1F', - green: '#13A10E', - yellow: '#C19C00', - blue: '#0037DA', - purple: '#881798', - cyan: '#3A96DD', - white: '#CCCCCC', - brightBlack: '#767676', - brightRed: '#E74856', - brightGreen: '#16C60C', - brightYellow: '#F9F1A5', - brightBlue: '#3B78FF', - brightPurple: '#B4009E', - brightCyan: '#61D6D6', - brightWhite: '#F2F2F2', -}; - -// NOTE: export for testing only; users should use loadConfig() and initConfig() instead -export const DEFAULT_KEYBOARD_BINDINGS: KeyboardBinding[] = []; - -// NOTE: export for testing only; users should use loadConfig() and initConfig() instead -export const DEFAULT_CONFIG: Config = { - port: 2346, - host: '127.0.0.1', - shell: - process.platform === 'win32' - ? (process.env.COMSPEC ?? 'cmd.exe') - : (process.env.SHELL ?? '/bin/bash'), - term: 'xterm-256color', - colorTerm: 'truecolor', - scrollback: 256 * 1024, - scrollbackLines: 10000, - cols: 80, - rows: 24, - fontSize: 13, - // 'STIX Two Text' comes last: it fills only clusters no earlier font covers whole, - // such as a letter with an enclosing mark (a⃝, U+20DD), which Menlo lacks. - fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", - cursorStyle: 'bar', - cursorStyleBlink: true, - copyOnSelect: true, - rightClickBehavior: 'contextMenu' as RightClickBehavior, - clipboardWrite: true, - bellTitle: true, - desktopNotifications: true, - mouseScrollSpeed: 1, - padding: 0, - logs: false, - theme: DEFAULT_THEME, - keyboardBindings: DEFAULT_KEYBOARD_BINDINGS, - env: {}, -}; - -function bindingKey(b: KeyboardBinding): string { - const mods = [...(b.mods ?? [])].sort().join('+'); - return `${b.key.toLowerCase()}|${mods}`; -} - -const VALID_MODS = new Set(['shift', 'ctrl', 'alt', 'meta']); - -function isValidBinding(b: unknown): b is KeyboardBinding { - if (!b || typeof b !== 'object') return false; - const o = b as Record<string, unknown>; - if (typeof o.key !== 'string' || typeof o.chars !== 'string') return false; - if (o.mods !== undefined && !Array.isArray(o.mods)) return false; - return true; -} - -function normalizeBinding(b: KeyboardBinding): KeyboardBinding { - return { - ...b, - key: b.key.toLowerCase(), - mods: (b.mods ?? []).map((m) => m.toLowerCase()).filter((m) => VALID_MODS.has(m)), - }; -} - -/** - * Merges user-supplied bindings over a set of defaults by `(key, mods)` identity. - * User entries replace matching defaults; unmatched user entries are appended. - * - * @param defaults - Default bindings to merge over. - * @param user - User-supplied bindings that override defaults. - * @returns Merged bindings with user entries replacing matching defaults and new user entries appended. - */ -// NOTE: export for testing only -export function mergeKeyboardBindings( - defaults: KeyboardBinding[], - user: KeyboardBinding[], -): KeyboardBinding[] { - const deduped = [...new Map(user.map((b) => [bindingKey(b), b])).values()]; - const overrides = new Map(deduped.map((b) => [bindingKey(b), b])); - const merged = defaults.map((d) => overrides.get(bindingKey(d)) ?? d); - const defaultKeys = new Set(defaults.map(bindingKey)); - for (const b of deduped) { - if (!defaultKeys.has(bindingKey(b))) merged.push(b); - } - return merged; -} +import { getConfigPath } from './config-paths'; +import type { Config } from './config-schema'; +import { DEFAULT_CONFIG } from './defaults'; +import { parseConfig } from './parse-config'; + +export { toClientConfig } from './client-view'; +export { configDir, getConfigPath } from './config-paths'; +export { + CLIENT_CONFIG_KEYS, + type ClientConfig, + type ClientConfigKey, + type Config, + type KeyboardBinding, + type RightClickBehavior, + type Theme, +} from './config-schema'; +export { DEFAULT_CONFIG, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_THEME } from './defaults'; +export { mergeKeyboardBindings } from './keyboard-bindings'; +export { parseConfig, parseRightClickBehavior } from './parse-config'; /** * Load config from `~/.config/webtty/config.json`, merged over `DEFAULT_CONFIG`. @@ -293,59 +60,7 @@ export function loadConfig(): Config { throw new Error(`webtty: invalid JSON in config file ${getConfigPath()}`); } - const p = parsed as Partial<Config>; - return { - ...DEFAULT_CONFIG, - ...(typeof p.port === 'number' && { port: p.port }), - ...(typeof p.host === 'string' && { host: p.host }), - ...(typeof p.shell === 'string' && { shell: p.shell }), - ...(typeof p.term === 'string' && { term: p.term }), - ...(typeof p.colorTerm === 'string' && { colorTerm: p.colorTerm }), - ...(typeof p.scrollback === 'number' && { scrollback: p.scrollback }), - ...(typeof p.scrollbackLines === 'number' && - Number.isFinite(p.scrollbackLines) && - p.scrollbackLines >= 0 && { scrollbackLines: Math.floor(p.scrollbackLines) }), - ...(typeof p.cols === 'number' && { cols: p.cols }), - ...(typeof p.rows === 'number' && { rows: p.rows }), - ...(typeof p.fontSize === 'number' && { fontSize: p.fontSize }), - ...(typeof p.fontFamily === 'string' && { fontFamily: p.fontFamily }), - ...(typeof p.cursorStyle === 'string' && - (p.cursorStyle === 'block' || p.cursorStyle === 'bar' || p.cursorStyle === 'underline') && { - cursorStyle: p.cursorStyle, - }), - ...(typeof p.cursorStyleBlink === 'boolean' && { cursorStyleBlink: p.cursorStyleBlink }), - ...(typeof p.copyOnSelect === 'boolean' && { copyOnSelect: p.copyOnSelect }), - ...(typeof p.rightClickBehavior === 'string' && { - rightClickBehavior: parseRightClickBehavior(p.rightClickBehavior), - }), - ...(typeof p.clipboardWrite === 'boolean' && { clipboardWrite: p.clipboardWrite }), - ...(typeof p.bellTitle === 'boolean' && { bellTitle: p.bellTitle }), - ...(typeof p.desktopNotifications === 'boolean' && { - desktopNotifications: p.desktopNotifications, - }), - ...(typeof p.mouseScrollSpeed === 'number' && - p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), - ...(typeof p.padding === 'number' && - Number.isFinite(p.padding) && - p.padding >= 0 && { padding: Math.floor(p.padding) }), - ...(typeof p.logs === 'boolean' && { logs: p.logs }), - ...(p.theme && typeof p.theme === 'object' && { theme: { ...DEFAULT_THEME, ...p.theme } }), - ...(Array.isArray(p.keyboardBindings) && { - keyboardBindings: mergeKeyboardBindings( - DEFAULT_KEYBOARD_BINDINGS, - p.keyboardBindings.filter(isValidBinding).map(normalizeBinding), - ), - }), - ...(p.env && - typeof p.env === 'object' && - !Array.isArray(p.env) && { - env: Object.fromEntries( - Object.entries(p.env as Record<string, unknown>).filter( - ([k, v]) => typeof k === 'string' && typeof v === 'string', - ) as [string, string][], - ), - }), - }; + return parseConfig(parsed); } /** diff --git a/src/config/keyboard-bindings.ts b/src/config/keyboard-bindings.ts new file mode 100644 index 0000000..8d155af --- /dev/null +++ b/src/config/keyboard-bindings.ts @@ -0,0 +1,52 @@ +// The rules for `keyboardBindings`: which entries are valid, how they are normalised and how +// user entries merge over the defaults by `(key, mods)` identity. A pure util module (ADR 062; +// the binding semantics are in the key-bindings SPEC). +import type { KeyboardBinding } from './config-schema'; + +/** The identity of a binding: its lower-cased key plus its sorted modifiers. */ +function bindingKey(b: KeyboardBinding): string { + const mods = [...(b.mods ?? [])].sort().join('+'); + return `${b.key.toLowerCase()}|${mods}`; +} + +const VALID_MODS = new Set(['shift', 'ctrl', 'alt', 'meta']); + +/** True for an entry shaped like a {@link KeyboardBinding}: string `key` and `chars`, and `mods` absent or an array. */ +export function isValidBinding(b: unknown): b is KeyboardBinding { + if (!b || typeof b !== 'object') return false; + const o = b as Record<string, unknown>; + if (typeof o.key !== 'string' || typeof o.chars !== 'string') return false; + if (o.mods !== undefined && !Array.isArray(o.mods)) return false; + return true; +} + +/** Lower-cases the key and modifiers and drops modifiers other than shift, ctrl, alt and meta. */ +export function normalizeBinding(b: KeyboardBinding): KeyboardBinding { + return { + ...b, + key: b.key.toLowerCase(), + mods: (b.mods ?? []).map((m) => m.toLowerCase()).filter((m) => VALID_MODS.has(m)), + }; +} + +/** + * Merges user-supplied bindings over a set of defaults by `(key, mods)` identity. + * User entries replace matching defaults; unmatched user entries are appended. + * + * @param defaults - Default bindings to merge over. + * @param user - User-supplied bindings that override defaults. + * @returns Merged bindings with user entries replacing matching defaults and new user entries appended. + */ +export function mergeKeyboardBindings( + defaults: KeyboardBinding[], + user: KeyboardBinding[], +): KeyboardBinding[] { + const deduped = [...new Map(user.map((b) => [bindingKey(b), b])).values()]; + const overrides = new Map(deduped.map((b) => [bindingKey(b), b])); + const merged = defaults.map((d) => overrides.get(bindingKey(d)) ?? d); + const defaultKeys = new Set(defaults.map(bindingKey)); + for (const b of deduped) { + if (!defaultKeys.has(bindingKey(b))) merged.push(b); + } + return merged; +} diff --git a/src/config/parse-config.ts b/src/config/parse-config.ts new file mode 100644 index 0000000..b5621fd --- /dev/null +++ b/src/config/parse-config.ts @@ -0,0 +1,84 @@ +// Turns the parsed JSON of a config file into a full Config. A pure util module: no file +// access, so every key's rule is tested with a table (ADR 062). Unknown keys and keys of the +// wrong type are ignored and fall back to DEFAULT_CONFIG; nothing here throws on bad values. +import type { Config, KeyboardBinding, RightClickBehavior } from './config-schema'; +import { DEFAULT_CONFIG, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_THEME } from './defaults'; +import { isValidBinding, mergeKeyboardBindings, normalizeBinding } from './keyboard-bindings'; + +const RIGHT_CLICK_BEHAVIORS: readonly string[] = ['contextMenu', 'copyPaste', 'paste', 'none']; + +/** A configured `rightClickBehavior`: `"default"` is the old name of `"contextMenu"`, and anything unknown is `"contextMenu"`. */ +export function parseRightClickBehavior(value: string): RightClickBehavior { + return RIGHT_CLICK_BEHAVIORS.includes(value) ? (value as RightClickBehavior) : 'contextMenu'; +} + +/** A finite number of zero or more; the rule shared by `scrollbackLines` and `padding`. */ +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isCursorStyle(value: unknown): value is Config['cursorStyle'] { + return value === 'block' || value === 'bar' || value === 'underline'; +} + +/** The valid user bindings, normalised and merged over the defaults. */ +function parseKeyboardBindings(user: unknown[]): KeyboardBinding[] { + return mergeKeyboardBindings( + DEFAULT_KEYBOARD_BINDINGS, + user.filter(isValidBinding).map(normalizeBinding), + ); +} + +/** The string-valued entries of a plain object; anything else in it is dropped. */ +function parseEnv(env: object): Record<string, string> { + return Object.fromEntries( + Object.entries(env as Record<string, unknown>).filter( + ([k, v]) => typeof k === 'string' && typeof v === 'string', + ) as [string, string][], + ); +} + +/** + * Merges the valid keys of a parsed config file over {@link DEFAULT_CONFIG}. The result keeps + * DEFAULT_CONFIG's key order. `parsed` is expected to be an object (what `JSON.parse` returns + * for a config file); `null` throws a TypeError, as loadConfig always has. + */ +export function parseConfig(parsed: unknown): Config { + const p = parsed as Partial<Config>; + return { + ...DEFAULT_CONFIG, + ...(typeof p.port === 'number' && { port: p.port }), + ...(typeof p.host === 'string' && { host: p.host }), + ...(typeof p.shell === 'string' && { shell: p.shell }), + ...(typeof p.term === 'string' && { term: p.term }), + ...(typeof p.colorTerm === 'string' && { colorTerm: p.colorTerm }), + ...(typeof p.scrollback === 'number' && { scrollback: p.scrollback }), + ...(isNonNegativeFinite(p.scrollbackLines) && { + scrollbackLines: Math.floor(p.scrollbackLines), + }), + ...(typeof p.cols === 'number' && { cols: p.cols }), + ...(typeof p.rows === 'number' && { rows: p.rows }), + ...(typeof p.fontSize === 'number' && { fontSize: p.fontSize }), + ...(typeof p.fontFamily === 'string' && { fontFamily: p.fontFamily }), + ...(isCursorStyle(p.cursorStyle) && { cursorStyle: p.cursorStyle }), + ...(typeof p.cursorStyleBlink === 'boolean' && { cursorStyleBlink: p.cursorStyleBlink }), + ...(typeof p.copyOnSelect === 'boolean' && { copyOnSelect: p.copyOnSelect }), + ...(typeof p.rightClickBehavior === 'string' && { + rightClickBehavior: parseRightClickBehavior(p.rightClickBehavior), + }), + ...(typeof p.clipboardWrite === 'boolean' && { clipboardWrite: p.clipboardWrite }), + ...(typeof p.bellTitle === 'boolean' && { bellTitle: p.bellTitle }), + ...(typeof p.desktopNotifications === 'boolean' && { + desktopNotifications: p.desktopNotifications, + }), + ...(typeof p.mouseScrollSpeed === 'number' && + p.mouseScrollSpeed > 0 && { mouseScrollSpeed: p.mouseScrollSpeed }), + ...(isNonNegativeFinite(p.padding) && { padding: Math.floor(p.padding) }), + ...(typeof p.logs === 'boolean' && { logs: p.logs }), + ...(p.theme && typeof p.theme === 'object' && { theme: { ...DEFAULT_THEME, ...p.theme } }), + ...(Array.isArray(p.keyboardBindings) && { + keyboardBindings: parseKeyboardBindings(p.keyboardBindings), + }), + ...(p.env && typeof p.env === 'object' && !Array.isArray(p.env) && { env: parseEnv(p.env) }), + }; +} From 1917a40b2b56d256f813be83ba35397bd76f0459 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 14:59:53 -0400 Subject: [PATCH 144/295] test(arch): import scanner reads commented heads and stops at the next statement A comment inside the braces of a multi-line import made the static pattern skip the whole import, so client-config-value, vendor-deep, facade-value-import, feature-feature and cycle checks passed when they should fail. In a file without semicolons, 'export type { A }' followed by a value import matched as one type-only import. The head now accepts // and /* */ comments and never crosses a bare import or export keyword; inline-type detection ignores the comments. --- scripts/architecture/import-rules.test.ts | 12 ++++++++++++ scripts/architecture/import-scan.test.ts | 20 ++++++++++++++++++++ scripts/architecture/import-scan.ts | 18 +++++++++++++----- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/architecture/import-rules.test.ts b/scripts/architecture/import-rules.test.ts index 4b396ab..65bd111 100644 --- a/scripts/architecture/import-rules.test.ts +++ b/scripts/architecture/import-rules.test.ts @@ -61,6 +61,18 @@ describe('checkImports', () => { "import { loadConfig } from '../config';", ['client-config-value'], ], + [ + 'client imports config values in commented multi-line braces', + 'src/client/a.ts', + "import {\n loadConfig, // why\n} from '../config';", + ['client-config-value'], + ], + [ + 'client without semicolons imports config values after a type re-export', + 'src/client/a.ts', + "export type { Config } from '../config'\nimport { loadConfig } from '../config'", + ['client-config-value'], + ], [ 'client imports config types', 'src/client/a.ts', diff --git a/scripts/architecture/import-scan.test.ts b/scripts/architecture/import-scan.test.ts index 31146b1..c1dac6f 100644 --- a/scripts/architecture/import-scan.test.ts +++ b/scripts/architecture/import-scan.test.ts @@ -58,6 +58,26 @@ describe('scanImports', () => { "import {\n a,\n b,\n} from './x';", [{ specifier: './x', typeOnly: false, dynamic: false }], ], + [ + 'line comment inside multi-line braces', + "import {\n loadConfig, // why: it's import-time\n} from '../config';", + [{ specifier: '../config', typeOnly: false, dynamic: false }], + ], + [ + 'block comment inside braces', + "import { a, /* (webtty, ADR 065) */ b } from './x';", + [{ specifier: './x', typeOnly: false, dynamic: false }], + ], + [ + 'commented inline types stay type-only', + "import {\n type A, // note\n type B,\n} from './x';", + [{ specifier: './x', typeOnly: true, dynamic: false }], + ], + [ + 'no semicolons: export type does not swallow the next import', + "export type { A }\nimport x from '../config'", + [{ specifier: '../config', typeOnly: false, dynamic: false }], + ], [ 'side effect', "import './x.css';", diff --git a/scripts/architecture/import-scan.ts b/scripts/architecture/import-scan.ts index c1db898..d027102 100644 --- a/scripts/architecture/import-scan.ts +++ b/scripts/architecture/import-scan.ts @@ -15,10 +15,18 @@ export interface ImportRef { dynamic: boolean; } -// An import or re-export head holds only names, braces, commas, `*` and `as`; keeping -// `;`, `=`, `(` and quotes out of it stops a match from running into ordinary code. -const STATIC_RE = - /^[ \t]*(import|export)\s+(type\s+)?([\w$\s{},*]*?)\s*from\s*['"]([^'"\n]+)['"]/gm; +// An import or re-export head holds only names, braces, commas, `*`, `as` and comments +// (a `// why` after a name in multi-line braces). Keeping `;`, `=`, `(` and quotes out +// of it stops a match from running into ordinary code, and refusing a bare `import` or +// `export` keyword stops it from swallowing the next statement in a file without +// semicolons (`export type { A }` then `import x from '...'`). +const COMMENT = String.raw`\/\/[^\n]*|\/\*[\s\S]*?\*\/`; +const HEAD = String.raw`(?:(?!\b(?:import|export)\b)(?:[\w$\s{},*]|${COMMENT}))*?`; +const STATIC_RE = new RegExp( + String.raw`^[ \t]*(import|export)\s+(type\s+)?(${HEAD})\s*from\s*['"]([^'"\n]+)['"]`, + 'gm', +); +const COMMENT_RE = new RegExp(COMMENT, 'g'); const SIDE_EFFECT_RE = /^[ \t]*import\s*['"]([^'"\n]+)['"]/gm; const DYNAMIC_RE = /(\btypeof\s+)?\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g; const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g; @@ -31,7 +39,7 @@ function lineAt(text: string, index: number): number { /** `{ type A, type B }`: every named binding is a type, so the import is erased. */ function allInlineTypes(head: string): boolean { - const braces = /^\{([\s\S]*)\}$/.exec(head.trim()); + const braces = /^\{([\s\S]*)\}$/.exec(head.replace(COMMENT_RE, ' ').trim()); if (!braces) return false; const names = braces[1] .split(',') From 3042df3974529506da1f7dc817f4e0097ce64771 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:00:07 -0400 Subject: [PATCH 145/295] refactor(server): build /api/config from the one client key list The route picks CLIENT_CONFIG_KEYS through toClientConfig instead of its own hand-written whitelist. A new route test pins the exact response text (keys, order and default values), and passes against both the old and the new route (ADR 062). --- src/server/routes.test.ts | 27 +++++++++++++++++++++++++++ src/server/routes.ts | 25 +++---------------------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index d145bac..4831912 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -11,6 +11,7 @@ import { waitForServer, waitForServerDown, } from '../test-support/server-harness'; +import { DEFAULT_THEME } from '../config'; import { getVersion } from '../version'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -129,6 +130,32 @@ describe('server — routes', () => { expect(body.logs).toBeUndefined(); }); + test('GET /api/config sends exactly the client keys, in order, with the default values', async () => { + // Pinned literal: the whitelist the route built by hand before CLIENT_CONFIG_KEYS existed. + const expected = { + cols: 80, + rows: 24, + fontSize: 13, + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + cursorStyle: 'bar', + cursorStyleBlink: true, + scrollback: 256 * 1024, + scrollbackLines: 10000, + theme: DEFAULT_THEME, + copyOnSelect: true, + rightClickBehavior: 'contextMenu', + clipboardWrite: true, + bellTitle: true, + desktopNotifications: true, + mouseScrollSpeed: 1, + padding: 0, + keyboardBindings: [], + }; + const res = await fetch(`${baseUrl}/api/config`); + expect(res.headers.get('content-type')).toBe('application/json'); + expect(await res.text()).toBe(JSON.stringify(expected)); + }); + test('GET /api/sessions returns array', async () => { const res = await fetch(`${baseUrl}/api/sessions`); expect(res.status).toBe(200); diff --git a/src/server/routes.ts b/src/server/routes.ts index 4043b96..410f755 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import type http from 'node:http'; import { homedir } from 'node:os'; import path from 'node:path'; -import { loadConfig } from '../config'; +import { loadConfig, toClientConfig } from '../config'; import { getVersion } from '../version'; import { createSession, @@ -108,27 +108,8 @@ export async function handleRequest( } if (req.method === 'GET' && pathname === '/api/config') { - const config = loadConfig(); - // Whitelist client-safe keys — avoid exposing server-side config (shell, host, logs, etc.) - const clientConfig = { - cols: config.cols, - rows: config.rows, - fontSize: config.fontSize, - fontFamily: config.fontFamily, - cursorStyle: config.cursorStyle, - cursorStyleBlink: config.cursorStyleBlink, - scrollback: config.scrollback, - scrollbackLines: config.scrollbackLines, - theme: config.theme, - copyOnSelect: config.copyOnSelect, - rightClickBehavior: config.rightClickBehavior, - clipboardWrite: config.clipboardWrite, - bellTitle: config.bellTitle, - desktopNotifications: config.desktopNotifications, - mouseScrollSpeed: config.mouseScrollSpeed, - padding: config.padding, - keyboardBindings: config.keyboardBindings, - }; + // Only the client-safe keys: server-side config (shell, host, logs, env…) stays here. + const clientConfig = toClientConfig(loadConfig()); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(clientConfig)); return; From 9408880d81452cc49e602b8866041f523d4546e4 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:00:20 -0400 Subject: [PATCH 146/295] test(server): sort the routes test imports The previous commit added the config import out of order, which biome rejects. --- src/server/routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 4831912..79a8374 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { DEFAULT_THEME } from '../config'; import { cleanupTmpHome, getFreePort, @@ -11,7 +12,6 @@ import { waitForServer, waitForServerDown, } from '../test-support/server-harness'; -import { DEFAULT_THEME } from '../config'; import { getVersion } from '../version'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); From f2c4939346c8f032cee6158322b33fb64e2597f8 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:01:42 -0400 Subject: [PATCH 147/295] chore(deps): happy-dom 20.14.5 for DOM tests (ADR 064) A pinned devDependency, used only as an explicit new Window() per test whose document is injected; never registered globally. Nothing in dist/ changes. --- bun.lock | 11 +++++++++++ package.json | 1 + 2 files changed, 12 insertions(+) diff --git a/bun.lock b/bun.lock index 32ee523..d8ddd6a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@biomejs/biome": "2.5.12", "@types/bun": "1.4.2", "@types/ws": "8.18.1", + "happy-dom": "20.14.5", "rimraf": "6.1.3", "semantic-release": "25.0.9", "tsx": "4.23.13", @@ -170,6 +171,8 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], @@ -200,6 +203,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], @@ -256,6 +261,8 @@ "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "env-ci": ["env-ci@11.2.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -308,6 +315,8 @@ "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], @@ -612,6 +621,8 @@ "web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], diff --git a/package.json b/package.json index 6fb9ddc..0304d6e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@biomejs/biome": "2.5.12", "@types/bun": "1.4.2", "@types/ws": "8.18.1", + "happy-dom": "20.14.5", "rimraf": "6.1.3", "semantic-release": "25.0.9", "tsx": "4.23.13", From faacc29acd732470b55a9d410d55043cc323d943 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:02:38 -0400 Subject: [PATCH 148/295] refactor(config): read the default shell through defaultShell(platform, env) DEFAULT_CONFIG.shell is still computed once at module load from the live process; the function takes the platform and environment as arguments with production defaults so both the Windows and the POSIX branch are tested (ADR 062, ADR 064). --- src/config/defaults.test.ts | 62 +++++++++++++++++++++++++++++++++++++ src/config/defaults.ts | 13 +++++--- 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/config/defaults.test.ts diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts new file mode 100644 index 0000000..5b3cc37 --- /dev/null +++ b/src/config/defaults.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_CONFIG, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_THEME, defaultShell } from './defaults'; + +describe('defaultShell', () => { + test.each([ + ['win32', { COMSPEC: 'C:\\pwsh.exe' }, 'C:\\pwsh.exe'], + ['win32', {}, 'cmd.exe'], + ['darwin', { SHELL: '/bin/zsh', COMSPEC: 'x' }, '/bin/zsh'], + ['linux', {}, '/bin/bash'], + ])('%s with %p → %p', (platform, env, expected) => { + expect(defaultShell(platform, env)).toBe(expected); + }); + + test('reads the live process by default', () => { + expect(defaultShell()).toBe(defaultShell(process.platform, process.env)); + }); +}); + +describe('DEFAULT_CONFIG', () => { + test('term is xterm-256color', () => { + expect(DEFAULT_CONFIG.term).toBe('xterm-256color'); + }); + + test('fontFamily ends with STIX Two Text for enclosing marks', () => { + expect(DEFAULT_CONFIG.fontFamily).toBe( + "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + ); + }); + + test('uses the default theme and bindings', () => { + expect(DEFAULT_CONFIG.theme).toBe(DEFAULT_THEME); + expect(DEFAULT_CONFIG.keyboardBindings).toBe(DEFAULT_KEYBOARD_BINDINGS); + expect(DEFAULT_KEYBOARD_BINDINGS).toEqual([]); + }); + + test('pins the scalar defaults', () => { + const { shell: _shell, theme: _theme, keyboardBindings: _kb, ...rest } = DEFAULT_CONFIG; + expect(rest).toEqual({ + port: 2346, + host: '127.0.0.1', + term: 'xterm-256color', + colorTerm: 'truecolor', + scrollback: 262144, + scrollbackLines: 10000, + cols: 80, + rows: 24, + fontSize: 13, + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + cursorStyle: 'bar', + cursorStyleBlink: true, + copyOnSelect: true, + rightClickBehavior: 'contextMenu', + clipboardWrite: true, + bellTitle: true, + desktopNotifications: true, + mouseScrollSpeed: 1, + padding: 0, + logs: false, + env: {}, + }); + }); +}); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 4c50f43..f866ee3 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -2,6 +2,14 @@ // default is read from the environment once, when the module loads (ADR 062). import type { Config, KeyboardBinding, RightClickBehavior, Theme } from './config-schema'; +/** The shell new sessions run when the config sets none: `%COMSPEC%` on Windows, `$SHELL` elsewhere. */ +export function defaultShell( + platform: string = process.platform, + env: Record<string, string | undefined> = process.env, +): string { + return platform === 'win32' ? (env.COMSPEC ?? 'cmd.exe') : (env.SHELL ?? '/bin/bash'); +} + /** The Campbell palette, used for every theme key the config file leaves out. */ export const DEFAULT_THEME: Theme = { background: '#000000', @@ -33,10 +41,7 @@ export const DEFAULT_KEYBOARD_BINDINGS: KeyboardBinding[] = []; export const DEFAULT_CONFIG: Config = { port: 2346, host: '127.0.0.1', - shell: - process.platform === 'win32' - ? (process.env.COMSPEC ?? 'cmd.exe') - : (process.env.SHELL ?? '/bin/bash'), + shell: defaultShell(), term: 'xterm-256color', colorTerm: 'truecolor', scrollback: 256 * 1024, From 2ca2bf226cd47388b813514b8ccdb16e6f918bdb Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:02:38 -0400 Subject: [PATCH 149/295] test(config): split the config tests by module index.test.ts keeps the loadConfig and initConfig file tests and checks the re-exports. parse-config.test.ts is a table with one row per key (valid, wrong type, out of range) and a check that every Config key has a row; keyboard-bindings, client-view, config-schema (including a compile-time check that CLIENT_CONFIG_KEYS are Config keys), config-paths and defaults each get their own test. src/config is at 100% lines and functions. --- src/config/client-view.test.ts | 67 ++++++ src/config/config-paths.test.ts | 44 ++++ src/config/config-schema.test.ts | 23 ++ src/config/index.test.ts | 323 ++------------------------- src/config/keyboard-bindings.test.ts | 81 +++++++ src/config/parse-config.test.ts | 184 +++++++++++++++ 6 files changed, 420 insertions(+), 302 deletions(-) create mode 100644 src/config/client-view.test.ts create mode 100644 src/config/config-paths.test.ts create mode 100644 src/config/config-schema.test.ts create mode 100644 src/config/keyboard-bindings.test.ts create mode 100644 src/config/parse-config.test.ts diff --git a/src/config/client-view.test.ts b/src/config/client-view.test.ts new file mode 100644 index 0000000..a232c6b --- /dev/null +++ b/src/config/client-view.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { toClientConfig } from './client-view'; +import { CLIENT_CONFIG_KEYS } from './config-schema'; +import { DEFAULT_CONFIG, DEFAULT_THEME } from './defaults'; + +describe('toClientConfig', () => { + test('keeps exactly the client keys, in the order /api/config has always sent them', () => { + expect(Object.keys(toClientConfig(DEFAULT_CONFIG))).toEqual([ + 'cols', + 'rows', + 'fontSize', + 'fontFamily', + 'cursorStyle', + 'cursorStyleBlink', + 'scrollback', + 'scrollbackLines', + 'theme', + 'copyOnSelect', + 'rightClickBehavior', + 'clipboardWrite', + 'bellTitle', + 'desktopNotifications', + 'mouseScrollSpeed', + 'padding', + 'keyboardBindings', + ]); + }); + + test('serialises DEFAULT_CONFIG to the pinned JSON', () => { + expect(JSON.stringify(toClientConfig(DEFAULT_CONFIG))).toBe( + JSON.stringify({ + cols: 80, + rows: 24, + fontSize: 13, + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + cursorStyle: 'bar', + cursorStyleBlink: true, + scrollback: 262144, + scrollbackLines: 10000, + theme: DEFAULT_THEME, + copyOnSelect: true, + rightClickBehavior: 'contextMenu', + clipboardWrite: true, + bellTitle: true, + desktopNotifications: true, + mouseScrollSpeed: 1, + padding: 0, + keyboardBindings: [], + }), + ); + }); + + test('never carries a server-only key', () => { + const view = toClientConfig({ ...DEFAULT_CONFIG, env: { SECRET: 'x' }, logs: true }); + for (const key of ['port', 'host', 'shell', 'term', 'colorTerm', 'logs', 'env']) { + expect(key in view).toBe(false); + } + expect(Object.keys(view)).toHaveLength(CLIENT_CONFIG_KEYS.length); + }); + + test('passes the configured values through unchanged', () => { + const bindings = [{ key: 'enter', mods: ['shift'], chars: 'x' }]; + const view = toClientConfig({ ...DEFAULT_CONFIG, cols: 132, keyboardBindings: bindings }); + expect(view.cols).toBe(132); + expect(view.keyboardBindings).toBe(bindings); + }); +}); diff --git a/src/config/config-paths.test.ts b/src/config/config-paths.test.ts new file mode 100644 index 0000000..566e663 --- /dev/null +++ b/src/config/config-paths.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import os from 'node:os'; +import path from 'node:path'; +import { configDir, getConfigPath } from './config-paths'; + +let origHome: string | undefined; + +beforeEach(() => { + origHome = process.env.HOME; +}); + +afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; +}); + +describe('configDir', () => { + test('is ~/.config/webtty under an absolute HOME', () => { + const home = path.join(os.tmpdir(), 'webtty-home'); + process.env.HOME = home; + expect(configDir()).toBe(path.join(home, '.config', 'webtty')); + }); + + test.each([ + ['relative', 'relative/home'], + ['the string "undefined"', 'undefined'], + ['empty', ''], + ])('falls back to os.homedir() when HOME is %s', (_label, home) => { + process.env.HOME = home; + expect(configDir()).toBe(path.join(os.homedir(), '.config', 'webtty')); + }); + + test('falls back to os.homedir() when HOME is unset', () => { + delete process.env.HOME; + expect(configDir()).toBe(path.join(os.homedir(), '.config', 'webtty')); + }); +}); + +describe('getConfigPath', () => { + test('is config.json inside configDir()', () => { + process.env.HOME = path.join(os.tmpdir(), 'webtty-home'); + expect(getConfigPath()).toBe(path.join(configDir(), 'config.json')); + }); +}); diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts new file mode 100644 index 0000000..528c5eb --- /dev/null +++ b/src/config/config-schema.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test'; +import { CLIENT_CONFIG_KEYS, type ClientConfigKey, type Config } from './config-schema'; +import { DEFAULT_CONFIG } from './defaults'; + +// Compile-time checks: tsc fails here if a client key is not a Config key, or if +// ClientConfig stops being a Pick of Config. +type Assert<T extends true> = T; +type _KeysAreConfigKeys = Assert<ClientConfigKey extends keyof Config ? true : false>; +type _NotEveryKey = Assert<keyof Config extends ClientConfigKey ? false : true>; + +describe('CLIENT_CONFIG_KEYS', () => { + test('names only keys that DEFAULT_CONFIG has', () => { + for (const key of CLIENT_CONFIG_KEYS) expect(key in DEFAULT_CONFIG).toBe(true); + }); + + test('has no duplicates', () => { + expect(new Set(CLIENT_CONFIG_KEYS).size).toBe(CLIENT_CONFIG_KEYS.length); + }); + + test('keeps the legacy scrollback key the server still sends', () => { + expect(CLIENT_CONFIG_KEYS).toContain('scrollback'); + }); +}); diff --git a/src/config/index.test.ts b/src/config/index.test.ts index 0d2073f..68bea82 100644 --- a/src/config/index.test.ts +++ b/src/config/index.test.ts @@ -2,14 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { - DEFAULT_CONFIG, - DEFAULT_KEYBOARD_BINDINGS, - DEFAULT_THEME, - initConfig, - loadConfig, - mergeKeyboardBindings, -} from '.'; +import * as configModule from '.'; +import { DEFAULT_CONFIG, DEFAULT_THEME, initConfig, loadConfig } from '.'; let tmpDir: string; let configPath: string; @@ -58,16 +52,6 @@ describe('loadConfig — first run', () => { expect(fs.existsSync(configPath)).toBe(true); }); - test('DEFAULT_CONFIG.term is xterm-256color', () => { - expect(DEFAULT_CONFIG.term).toBe('xterm-256color'); - }); - - test('DEFAULT_CONFIG.fontFamily ends with STIX Two Text for enclosing marks', () => { - expect(DEFAULT_CONFIG.fontFamily).toBe( - "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", - ); - }); - test('first run leaves fontFamily out of the file, so the default applies', () => { const config = loadConfig(); expect(fs.readFileSync(configPath, 'utf8')).not.toContain('fontFamily'); @@ -127,50 +111,6 @@ describe('loadConfig — reads and merges', () => { expect(loadConfig().port).toBe(9999); }); - test('overrides host when set in file', () => { - writeConfig(JSON.stringify({ host: '0.0.0.0' })); - expect(loadConfig().host).toBe('0.0.0.0'); - }); - - test('overrides shell when set in file', () => { - writeConfig(JSON.stringify({ shell: '/bin/zsh' })); - expect(loadConfig().shell).toBe('/bin/zsh'); - }); - - test('overrides fontSize and fontFamily when set in file', () => { - writeConfig(JSON.stringify({ fontSize: 18, fontFamily: 'Menlo' })); - const config = loadConfig(); - expect(config.fontSize).toBe(18); - expect(config.fontFamily).toBe('Menlo'); - }); - - test('overrides cursorStyleBlink when set to false', () => { - writeConfig(JSON.stringify({ cursorStyleBlink: false })); - expect(loadConfig().cursorStyleBlink).toBe(false); - }); - - test('overrides cursorStyle when set to a valid value', () => { - writeConfig(JSON.stringify({ cursorStyle: 'underline' })); - expect(loadConfig().cursorStyle).toBe('underline'); - }); - - test('falls back cursorStyle to default for invalid value', () => { - writeConfig(JSON.stringify({ cursorStyle: 'bogus' })); - expect(loadConfig().cursorStyle).toBe(DEFAULT_CONFIG.cursorStyle); - }); - - test('overrides cols and rows when set in file', () => { - writeConfig(JSON.stringify({ cols: 120, rows: 40 })); - const config = loadConfig(); - expect(config.cols).toBe(120); - expect(config.rows).toBe(40); - }); - - test('overrides scrollback when set in file', () => { - writeConfig(JSON.stringify({ scrollback: 1024 })); - expect(loadConfig().scrollback).toBe(1024); - }); - test('merges partial theme over DEFAULT_THEME', () => { writeConfig(JSON.stringify({ theme: { background: '#000000' } })); const config = loadConfig(); @@ -179,149 +119,6 @@ describe('loadConfig — reads and merges', () => { expect(config.theme.red).toBe(DEFAULT_THEME.red); }); - test('defaults padding to 0 and accepts a non-negative number', () => { - writeConfig(JSON.stringify({})); - expect(loadConfig().padding).toBe(0); - writeConfig(JSON.stringify({ padding: 8 })); - expect(loadConfig().padding).toBe(8); - writeConfig(JSON.stringify({ padding: 6.9 })); - expect(loadConfig().padding).toBe(6); - }); - - test('ignores a negative or non-numeric padding', () => { - writeConfig(JSON.stringify({ padding: -4 })); - expect(loadConfig().padding).toBe(0); - writeConfig(JSON.stringify({ padding: '8' })); - expect(loadConfig().padding).toBe(0); - }); - - test('defaults scrollbackLines to 10000 and accepts 0 or a whole number of lines', () => { - writeConfig(JSON.stringify({})); - expect(loadConfig().scrollbackLines).toBe(10000); - writeConfig(JSON.stringify({ scrollbackLines: 0 })); - expect(loadConfig().scrollbackLines).toBe(0); - writeConfig(JSON.stringify({ scrollbackLines: 2500.7 })); - expect(loadConfig().scrollbackLines).toBe(2500); - }); - - test('ignores a negative, infinite or non-numeric scrollbackLines', () => { - for (const scrollbackLines of [-1, '5000', null]) { - writeConfig(JSON.stringify({ scrollbackLines })); - expect(loadConfig().scrollbackLines).toBe(10000); - } - writeConfig('{"scrollbackLines": 1e999}'); - expect(loadConfig().scrollbackLines).toBe(10000); - }); - - test('passes theme.padding through and leaves it unset by default', () => { - writeConfig(JSON.stringify({ theme: { padding: '#0D0D0D' } })); - expect(loadConfig().theme.padding).toBe('#0D0D0D'); - writeConfig(JSON.stringify({ theme: { background: '#101010' } })); - expect(loadConfig().theme.padding).toBeUndefined(); - }); - - test('ignores unknown keys', () => { - writeConfig(JSON.stringify({ unknownKey: 'value', port: 1234 })); - const config = loadConfig(); - expect(config.port).toBe(1234); - expect((config as unknown as Record<string, unknown>).unknownKey).toBeUndefined(); - }); - - test('ignores keys with wrong types and falls back to defaults', () => { - writeConfig(JSON.stringify({ port: 'not-a-number', cols: true, host: 42 })); - const config = loadConfig(); - expect(config.port).toBe(DEFAULT_CONFIG.port); - expect(config.cols).toBe(DEFAULT_CONFIG.cols); - expect(config.host).toBe(DEFAULT_CONFIG.host); - }); - - test('overrides copyOnSelect when set to false', () => { - writeConfig(JSON.stringify({ copyOnSelect: false })); - expect(loadConfig().copyOnSelect).toBe(false); - }); - - test('defaults clipboardWrite to true', () => { - writeConfig(JSON.stringify({})); - expect(loadConfig().clipboardWrite).toBe(true); - }); - - test('overrides clipboardWrite when set to false', () => { - writeConfig(JSON.stringify({ clipboardWrite: false })); - expect(loadConfig().clipboardWrite).toBe(false); - }); - - test('ignores a clipboardWrite that is not a boolean', () => { - for (const value of ['false', 0, null]) { - writeConfig(JSON.stringify({ clipboardWrite: value })); - expect(loadConfig().clipboardWrite).toBe(true); - } - }); - - test('defaults bellTitle to true', () => { - writeConfig(JSON.stringify({})); - expect(loadConfig().bellTitle).toBe(true); - }); - - test('overrides bellTitle when set to false', () => { - writeConfig(JSON.stringify({ bellTitle: false })); - expect(loadConfig().bellTitle).toBe(false); - }); - - test('ignores a bellTitle that is not a boolean', () => { - for (const value of ['false', 0, null]) { - writeConfig(JSON.stringify({ bellTitle: value })); - expect(loadConfig().bellTitle).toBe(true); - } - }); - - test('defaults desktopNotifications to true', () => { - writeConfig(JSON.stringify({})); - expect(loadConfig().desktopNotifications).toBe(true); - }); - - test('overrides desktopNotifications when set to false', () => { - writeConfig(JSON.stringify({ desktopNotifications: false })); - expect(loadConfig().desktopNotifications).toBe(false); - }); - - test('ignores a desktopNotifications that is not a boolean', () => { - for (const value of ['false', 0, null]) { - writeConfig(JSON.stringify({ desktopNotifications: value })); - expect(loadConfig().desktopNotifications).toBe(true); - } - }); - - test('overrides rightClickBehavior when set to copyPaste', () => { - writeConfig(JSON.stringify({ rightClickBehavior: 'copyPaste' })); - expect(loadConfig().rightClickBehavior).toBe('copyPaste'); - }); - - test('defaults rightClickBehavior to contextMenu', () => { - writeConfig(JSON.stringify({})); - expect(loadConfig().rightClickBehavior).toBe('contextMenu'); - }); - - test('accepts each rightClickBehavior value', () => { - for (const value of ['contextMenu', 'copyPaste', 'paste', 'none'] as const) { - writeConfig(JSON.stringify({ rightClickBehavior: value })); - expect(loadConfig().rightClickBehavior).toBe(value); - } - }); - - test('reads the old rightClickBehavior "default" as contextMenu', () => { - writeConfig(JSON.stringify({ rightClickBehavior: 'default' })); - expect(loadConfig().rightClickBehavior).toBe('contextMenu'); - }); - - test('falls back rightClickBehavior to contextMenu for an invalid value', () => { - for (const value of ['bogus', 'webtty', 'Paste', 'nothing']) { - writeConfig(JSON.stringify({ rightClickBehavior: value })); - expect(loadConfig().rightClickBehavior).toBe('contextMenu'); - } - writeConfig(JSON.stringify({ rightClickBehavior: 3 })); - expect(loadConfig().rightClickBehavior).toBe('contextMenu'); - }); - test('throws on invalid JSON', () => { writeConfig('{ not valid json }'); expect(() => loadConfig()).toThrow(/invalid JSON/); @@ -333,104 +130,26 @@ describe('loadConfig — reads and merges', () => { }); }); -describe('loadConfig — keyboardBindings', () => { - function writeConfig(content: string) { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, content, 'utf8'); - } - - test('returns empty keyboardBindings when not set in file', () => { - writeConfig('{}'); - expect(loadConfig().keyboardBindings).toEqual([]); - }); - - test('user binding is used as-is when no defaults exist', () => { - writeConfig( - JSON.stringify({ keyboardBindings: [{ key: 'enter', mods: ['shift'], chars: 'custom' }] }), - ); - const bindings = loadConfig().keyboardBindings; - expect(bindings).toHaveLength(1); - expect(bindings[0].chars).toBe('custom'); - }); - - test('multiple user bindings are all preserved', () => { - writeConfig( - JSON.stringify({ - keyboardBindings: [ - { key: 'enter', mods: ['ctrl'], chars: '\u001b[13;5u' }, - { key: 'tab', mods: ['shift'], chars: '\u001b[9;2u' }, - ], - }), - ); - const bindings = loadConfig().keyboardBindings; - expect(bindings).toHaveLength(2); - expect(bindings.some((b) => b.key === 'enter' && b.mods?.includes('ctrl'))).toBe(true); - expect(bindings.some((b) => b.key === 'tab' && b.mods?.includes('shift'))).toBe(true); - }); - - test('mods order does not affect identity — ["ctrl","shift"] matches ["shift","ctrl"]', () => { - writeConfig( - JSON.stringify({ - keyboardBindings: [ - { key: 'enter', mods: ['ctrl', 'shift'], chars: 'first' }, - { key: 'enter', mods: ['shift', 'ctrl'], chars: 'last-wins' }, - ], - }), - ); - const bindings = loadConfig().keyboardBindings; - expect(bindings).toHaveLength(1); - expect(bindings[0].chars).toBe('last-wins'); - }); - - test('entries missing key or chars are silently ignored', () => { - writeConfig(JSON.stringify({ keyboardBindings: [{ mods: ['shift'] }, { key: 'enter' }] })); - expect(loadConfig().keyboardBindings).toEqual(DEFAULT_KEYBOARD_BINDINGS); - }); - - test('non-array keyboardBindings is ignored, defaults preserved', () => { - writeConfig(JSON.stringify({ keyboardBindings: 'invalid' })); - expect(loadConfig().keyboardBindings).toEqual(DEFAULT_KEYBOARD_BINDINGS); - }); - - test('binding with non-array mods is rejected', () => { - writeConfig(JSON.stringify({ keyboardBindings: [{ key: 'enter', chars: 'x', mods: {} }] })); - expect(loadConfig().keyboardBindings).toEqual([]); - }); - - test('unknown mods are filtered out at load time', () => { - writeConfig( - JSON.stringify({ - keyboardBindings: [{ key: 'enter', mods: ['shift', 'super', 'unknown'], chars: 'x' }], - }), - ); - const bindings = loadConfig().keyboardBindings; - expect(bindings[0].mods).toEqual(['shift']); - }); -}); - -describe('mergeKeyboardBindings', () => { - const base = { key: 'enter', mods: ['shift'], chars: '\u001b[13;2u' }; - - test('user entry replaces matching default by key+mods identity', () => { - const result = mergeKeyboardBindings([base], [{ ...base, chars: 'custom' }]); - expect(result).toHaveLength(1); - expect(result[0].chars).toBe('custom'); - }); - - test('user entry for different mods is added alongside default', () => { - const user = { key: 'enter', mods: ['ctrl'], chars: '\u001b[13;5u' }; - const result = mergeKeyboardBindings([base], [user]); - expect(result).toHaveLength(2); - }); - - test('default is preserved when user has no matching entry', () => { - const result = mergeKeyboardBindings([base], []); - expect(result).toEqual([base]); +describe('config index — re-exports', () => { + test('keeps every name src/config.ts exported before the split', () => { + for (const name of [ + 'configDir', + 'parseRightClickBehavior', + 'DEFAULT_THEME', + 'DEFAULT_KEYBOARD_BINDINGS', + 'DEFAULT_CONFIG', + 'mergeKeyboardBindings', + 'loadConfig', + 'initConfig', + ]) { + expect(name in configModule).toBe(true); + } }); - test('mods order is irrelevant for identity', () => { - const user = { key: 'enter', mods: ['shift'], chars: 'replaced' }; - const result = mergeKeyboardBindings([{ ...base, mods: ['shift'] }], [user]); - expect(result[0].chars).toBe('replaced'); + test('also exports the client key list, its view, the parser and the path', () => { + expect(configModule.CLIENT_CONFIG_KEYS).toContain('cols'); + expect(typeof configModule.toClientConfig).toBe('function'); + expect(typeof configModule.parseConfig).toBe('function'); + expect(configModule.getConfigPath()).toBe(configPath); }); }); diff --git a/src/config/keyboard-bindings.test.ts b/src/config/keyboard-bindings.test.ts new file mode 100644 index 0000000..639fbec --- /dev/null +++ b/src/config/keyboard-bindings.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; +import { isValidBinding, mergeKeyboardBindings, normalizeBinding } from './keyboard-bindings'; + +describe('isValidBinding', () => { + test.each([ + ['key and chars', { key: 'a', chars: 'x' }, true], + ['key, chars and mods', { key: 'a', chars: 'x', mods: ['ctrl'] }, true], + ['an empty mods array', { key: 'a', chars: 'x', mods: [] }, true], + ['null', null, false], + ['a string', 'a', false], + ['no key', { chars: 'x' }, false], + ['no chars', { key: 'a' }, false], + ['a numeric key', { key: 1, chars: 'x' }, false], + ['object mods', { key: 'a', chars: 'x', mods: {} }, false], + ['string mods', { key: 'a', chars: 'x', mods: 'ctrl' }, false], + ])('%s → %p', (_label, value, expected) => { + expect(isValidBinding(value)).toBe(expected); + }); +}); + +describe('normalizeBinding', () => { + test.each([ + [ + { key: 'Enter', chars: 'x' }, + { key: 'enter', chars: 'x', mods: [] }, + ], + [ + { key: 'A', chars: 'x', mods: ['CTRL', 'Shift'] }, + { key: 'a', chars: 'x', mods: ['ctrl', 'shift'] }, + ], + [ + { key: 'tab', chars: 'x', mods: ['alt', 'meta', 'super', 'hyper'] }, + { key: 'tab', chars: 'x', mods: ['alt', 'meta'] }, + ], + ])('%p → %p', (input, expected) => { + expect(normalizeBinding(input)).toEqual(expected); + }); +}); + +describe('mergeKeyboardBindings', () => { + const base = { key: 'enter', mods: ['shift'], chars: '\u001b[13;2u' }; + + test('user entry replaces matching default by key+mods identity', () => { + const result = mergeKeyboardBindings([base], [{ ...base, chars: 'custom' }]); + expect(result).toHaveLength(1); + expect(result[0].chars).toBe('custom'); + }); + + test('user entry for different mods is added alongside default', () => { + const user = { key: 'enter', mods: ['ctrl'], chars: '\u001b[13;5u' }; + const result = mergeKeyboardBindings([base], [user]); + expect(result).toEqual([base, user]); + }); + + test('default is preserved when user has no matching entry', () => { + const result = mergeKeyboardBindings([base], []); + expect(result).toEqual([base]); + }); + + test('mods order and key case are irrelevant for identity', () => { + const defaults = [{ key: 'Enter', mods: ['shift', 'ctrl'], chars: 'default' }]; + const user = { key: 'enter', mods: ['ctrl', 'shift'], chars: 'replaced' }; + expect(mergeKeyboardBindings(defaults, [user])).toEqual([user]); + }); + + test('a binding without mods matches one with an empty mods list', () => { + const user = { key: 'enter', chars: 'replaced' }; + expect(mergeKeyboardBindings([{ key: 'enter', mods: [], chars: 'd' }], [user])).toEqual([user]); + }); + + test('of duplicate user entries the last one wins', () => { + const result = mergeKeyboardBindings( + [], + [ + { key: 'a', chars: 'first' }, + { key: 'a', chars: 'last' }, + ], + ); + expect(result).toEqual([{ key: 'a', chars: 'last' }]); + }); +}); diff --git a/src/config/parse-config.test.ts b/src/config/parse-config.test.ts new file mode 100644 index 0000000..7b31ff4 --- /dev/null +++ b/src/config/parse-config.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from 'bun:test'; +import type { Config } from './config-schema'; +import { DEFAULT_CONFIG, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_THEME } from './defaults'; +import { parseConfig, parseRightClickBehavior } from './parse-config'; + +const D = DEFAULT_CONFIG; + +/** One row per rule: the value the file sets for `key`, and what parseConfig makes of it. */ +type Row = [key: keyof Config, value: unknown, expected: unknown]; + +// Valid values pass through, wrong types and out-of-range values fall back to the default. +const ROWS: Row[] = [ + ['port', 9999, 9999], + ['port', 'not-a-number', D.port], + ['host', '0.0.0.0', '0.0.0.0'], + ['host', 42, D.host], + ['shell', '/bin/zsh', '/bin/zsh'], + ['shell', 1, D.shell], + ['term', 'xterm', 'xterm'], + ['term', false, D.term], + ['colorTerm', '24bit', '24bit'], + ['colorTerm', 1, D.colorTerm], + ['scrollback', 1024, 1024], + ['scrollback', '1024', D.scrollback], + ['scrollbackLines', 0, 0], + ['scrollbackLines', 2500.7, 2500], + ['scrollbackLines', -1, 10000], + ['scrollbackLines', Number.POSITIVE_INFINITY, 10000], + ['scrollbackLines', Number.NaN, 10000], + ['scrollbackLines', '5000', 10000], + ['scrollbackLines', null, 10000], + ['cols', 120, 120], + ['cols', true, D.cols], + ['rows', 40, 40], + ['rows', '40', D.rows], + ['fontSize', 18, 18], + ['fontSize', '18', D.fontSize], + ['fontFamily', 'Menlo', 'Menlo'], + ['fontFamily', 1, D.fontFamily], + ['cursorStyle', 'block', 'block'], + ['cursorStyle', 'bar', 'bar'], + ['cursorStyle', 'underline', 'underline'], + ['cursorStyle', 'bogus', D.cursorStyle], + ['cursorStyle', 1, D.cursorStyle], + ['cursorStyleBlink', false, false], + ['cursorStyleBlink', 'false', D.cursorStyleBlink], + ['copyOnSelect', false, false], + ['copyOnSelect', 0, D.copyOnSelect], + ['rightClickBehavior', 'copyPaste', 'copyPaste'], + ['rightClickBehavior', 'paste', 'paste'], + ['rightClickBehavior', 'none', 'none'], + ['rightClickBehavior', 'contextMenu', 'contextMenu'], + ['rightClickBehavior', 'default', 'contextMenu'], + ['rightClickBehavior', 'bogus', 'contextMenu'], + ['rightClickBehavior', 3, 'contextMenu'], + ['clipboardWrite', false, false], + ['clipboardWrite', 'false', true], + ['clipboardWrite', 0, true], + ['clipboardWrite', null, true], + ['bellTitle', false, false], + ['bellTitle', 'false', true], + ['bellTitle', 0, true], + ['bellTitle', null, true], + ['desktopNotifications', false, false], + ['desktopNotifications', 'false', true], + ['desktopNotifications', 0, true], + ['desktopNotifications', null, true], + ['mouseScrollSpeed', 2.5, 2.5], + ['mouseScrollSpeed', 0, D.mouseScrollSpeed], + ['mouseScrollSpeed', -1, D.mouseScrollSpeed], + ['mouseScrollSpeed', '2', D.mouseScrollSpeed], + ['padding', 8, 8], + ['padding', 6.9, 6], + ['padding', 0, 0], + ['padding', -4, 0], + ['padding', Number.POSITIVE_INFINITY, 0], + ['padding', '8', 0], + ['logs', true, true], + ['logs', 'true', D.logs], + ['theme', { background: '#101010' }, { ...DEFAULT_THEME, background: '#101010' }], + ['theme', { padding: '#0D0D0D' }, { ...DEFAULT_THEME, padding: '#0D0D0D' }], + ['theme', null, DEFAULT_THEME], + ['theme', 'dark', DEFAULT_THEME], + ['keyboardBindings', 'invalid', DEFAULT_KEYBOARD_BINDINGS], + ['env', { FOO: 'bar', NUM: 2, NIL: null }, { FOO: 'bar' }], + ['env', ['FOO=bar'], {}], + ['env', 'FOO=bar', {}], + ['env', null, {}], +]; + +describe('parseConfig — one rule per key', () => { + test.each(ROWS)('%s = %p gives %p', (key, value, expected) => { + const actual: unknown = parseConfig({ [key]: value })[key]; + expect(actual).toEqual(expected); + }); + + test('every Config key has at least one row', () => { + const covered = new Set(ROWS.map(([key]) => key)); + expect(Object.keys(DEFAULT_CONFIG).filter((k) => !covered.has(k as keyof Config))).toEqual([]); + }); +}); + +describe('parseConfig — the whole object', () => { + test('an empty file gives DEFAULT_CONFIG', () => { + expect(parseConfig({})).toEqual(DEFAULT_CONFIG); + }); + + test("keeps DEFAULT_CONFIG's key order whatever order the file uses", () => { + expect(Object.keys(parseConfig({ env: {}, port: 1, cols: 100 }))).toEqual( + Object.keys(DEFAULT_CONFIG), + ); + }); + + test('ignores unknown keys', () => { + const config = parseConfig({ unknownKey: 'value', port: 1234 }); + expect(config.port).toBe(1234); + expect('unknownKey' in config).toBe(false); + }); + + test('null is not a config object and throws, as loadConfig always has', () => { + expect(() => parseConfig(null)).toThrow(TypeError); + }); +}); + +describe('parseConfig — keyboardBindings', () => { + test('a user binding is used as-is when no defaults exist', () => { + const bindings = parseConfig({ + keyboardBindings: [{ key: 'enter', mods: ['shift'], chars: 'custom' }], + }).keyboardBindings; + expect(bindings).toEqual([{ key: 'enter', mods: ['shift'], chars: 'custom' }]); + }); + + test('several user bindings are all kept', () => { + const bindings = parseConfig({ + keyboardBindings: [ + { key: 'enter', mods: ['ctrl'], chars: '\u001b[13;5u' }, + { key: 'tab', mods: ['shift'], chars: '\u001b[9;2u' }, + ], + }).keyboardBindings; + expect(bindings.map((b) => b.key)).toEqual(['enter', 'tab']); + }); + + test('mods order does not affect identity, and the last entry wins', () => { + const bindings = parseConfig({ + keyboardBindings: [ + { key: 'enter', mods: ['ctrl', 'shift'], chars: 'first' }, + { key: 'enter', mods: ['shift', 'ctrl'], chars: 'last-wins' }, + ], + }).keyboardBindings; + expect(bindings).toHaveLength(1); + expect(bindings[0].chars).toBe('last-wins'); + }); + + test('entries missing key or chars, or with non-array mods, are dropped', () => { + const bindings = parseConfig({ + keyboardBindings: [{ mods: ['shift'] }, { key: 'enter' }, { key: 'a', chars: 'x', mods: {} }], + }).keyboardBindings; + expect(bindings).toEqual([]); + }); + + test('keys and mods are lower-cased and unknown mods are dropped', () => { + const bindings = parseConfig({ + keyboardBindings: [{ key: 'Enter', mods: ['SHIFT', 'super', 'unknown'], chars: 'x' }], + }).keyboardBindings; + expect(bindings).toEqual([{ key: 'enter', mods: ['shift'], chars: 'x' }]); + }); +}); + +describe('parseRightClickBehavior', () => { + test.each([ + ['contextMenu', 'contextMenu'], + ['copyPaste', 'copyPaste'], + ['paste', 'paste'], + ['none', 'none'], + ['default', 'contextMenu'], + ['webtty', 'contextMenu'], + ['Paste', 'contextMenu'], + ['', 'contextMenu'], + ])('%p gives %p', (value, expected) => { + expect(parseRightClickBehavior(value)).toBe( + expected as ReturnType<typeof parseRightClickBehavior>, + ); + }); +}); From 6689ece485410ce5fadfb1629ef93a601dc79218 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:06:24 -0400 Subject: [PATCH 150/295] refactor(server): rename flow.ts to output-pacer.ts flow.ts stays as a one-line re-export because the client's replay test imports DEFAULT_FLOW_LIMITS from '../server/flow' until C-F repoints it (ADR 062). --- src/server/flow.ts | 283 +----------------- .../{flow.test.ts => output-pacer.test.ts} | 8 +- src/server/output-pacer.ts | 279 +++++++++++++++++ src/server/session.ts | 2 +- src/server/websocket.ts | 2 +- 5 files changed, 292 insertions(+), 282 deletions(-) rename src/server/{flow.test.ts => output-pacer.test.ts} (99%) create mode 100644 src/server/output-pacer.ts diff --git a/src/server/flow.ts b/src/server/flow.ts index 669f2ec..24f59b9 100644 --- a/src/server/flow.ts +++ b/src/server/flow.ts @@ -1,279 +1,4 @@ -/** - * PTY output flow control (ADR 048). Each client acknowledges the characters it has - * parsed; the server stops sending to a client that is too far behind, queues its - * output in order, and pauses the PTY while every client is behind. - */ - -/** Close code for a client whose queued output passed {@link FlowLimits.maxPending}. */ -export const FELL_BEHIND = 4003; - -/** Limits in characters (UTF-16 code units, `string.length`), except `legacyBuffered`. */ -export interface FlowLimits { - /** Unacknowledged characters at which a client is blocked and output is queued. */ - highWater: number; - /** Unacknowledged characters at which a blocked client is unblocked. */ - lowWater: number; - /** Largest message the queue is coalesced into (whole chunks are never split). */ - chunk: number; - /** Queued characters past which the client is closed with {@link FELL_BEHIND}. */ - maxPending: number; - /** - * `bufferedAmount` (bytes) past which a legacy client is closed with {@link FELL_BEHIND}. - * Under Bun the close frame is stuck behind the native backlog and the client sees 1006. - */ - legacyBuffered: number; -} - -/** How long {@link OutputPacer.end} waits for an acknowledgement before it closes anyway, in ms. */ -export const END_FLUSH_MS = 2000; - -export const DEFAULT_FLOW_LIMITS: FlowLimits = { - highWater: 2 * 1024 * 1024, - lowWater: 512 * 1024, - chunk: 64 * 1024, - maxPending: 16 * 1024 * 1024, - legacyBuffered: 8 * 1024 * 1024, -}; - -/** The part of a WebSocket the pacer uses. */ -export interface FlowSocket { - readonly readyState: number; - readonly OPEN: number; - readonly bufferedAmount: number; - send(data: string): void; - close(code?: number, reason?: string): void; -} - -/** A PTY that may be able to stop reading output (node-pty can, `Bun.Terminal` cannot). */ -export interface PausablePty { - pause?(): void; - resume?(): void; -} - -interface ClientFlow { - /** Whether the client acknowledges (connected with `?flow=1`). */ - acks: boolean; - sent: number; - acked: number; - blocked: boolean; - pending: string[]; - pendingLength: number; - /** Queued characters past which the client is closed: the limit plus what {@link OutputPacer.send} queued. */ - maxPending: number; - /** Set by {@link OutputPacer.end}: the client is closed with `code` once its queue is sent. */ - ending: { code: number; reason: string; wait: () => void; done: () => void } | null; -} - -/** Splits `data` into pieces of at most `size` characters without cutting a surrogate pair. */ -export function splitChunks(data: string, size: number): string[] { - const pieces: string[] = []; - for (let i = 0; i < data.length; ) { - let end = Math.min(i + size, data.length); - const last = data.charCodeAt(end - 1); - if (end < data.length && end - i > 1 && last >= 0xd800 && last <= 0xdbff) end--; - pieces.push(data.slice(i, end)); - i = end; - } - return pieces; -} - -/** Paces one session's output to its clients. */ -export class OutputPacer { - private flows = new Map<FlowSocket, ClientFlow>(); - private paused = false; - - /** - * @param pty - Returns the session's PTY, or `null` before it spawns. - * @param limits - Flow limits (tests pass small ones). - */ - constructor( - private pty: () => PausablePty | null, - private limits: FlowLimits = DEFAULT_FLOW_LIMITS, - ) {} - - /** Whether the PTY is paused because every client is behind. */ - get isPaused(): boolean { - return this.paused; - } - - /** Starts pacing `ws`. `acks` is true for a client that sends acknowledgements. */ - add(ws: FlowSocket, acks: boolean): void { - this.flows.set(ws, { - acks, - sent: 0, - acked: 0, - blocked: false, - pending: [], - pendingLength: 0, - maxPending: this.limits.maxPending, - ending: null, - }); - this.updatePause(); - } - - /** Stops pacing `ws` (closed) and drops its queue. */ - remove(ws: FlowSocket): void { - const flow = this.flows.get(ws); - if (!flow) return; - this.flows.delete(ws); - flow.ending?.done(); - this.updatePause(); - } - - /** - * Closes every client with `code` once the output queued for it has been sent, as - * its acknowledgements allow, so the tail of the output is not lost when the shell - * exits. A client that does not acknowledge for `ms` is closed anyway; the queue - * no longer grows, so a client that keeps acknowledging is closed within - * {@link FlowLimits.maxPending}. - * - * @returns A promise that resolves once every client is closed. - */ - end(code: number, reason: string, ms = END_FLUSH_MS): Promise<void> { - const waits: Promise<void>[] = []; - for (const [ws, flow] of this.flows) { - if (flow.pending.length === 0) { - this.close(ws, code, reason); - continue; - } - waits.push( - new Promise((resolve) => { - let timer: ReturnType<typeof setTimeout> | undefined; - flow.ending = { - code, - reason, - wait: () => { - clearTimeout(timer); - timer = setTimeout(() => this.close(ws, code, reason), ms); - }, - done: () => { - clearTimeout(timer); - resolve(); - }, - }; - flow.ending.wait(); - }), - ); - } - return Promise.all(waits).then(() => {}); - } - - /** - * Sends `data` (the banner or the scrollback replay) to one client, in order after - * anything queued for it. An acknowledging client gets it in pieces of - * {@link FlowLimits.chunk}, so a large replay is paced like live output instead of - * filling the send buffer at once. What it queues is allowed on top of - * {@link FlowLimits.maxPending} until the queue drains: the replay is already held as the - * scrollback, and a scrollback over the limit would otherwise close every client that - * connects. A legacy - * client gets it whole as before: Bun's shim accepts one large message, but drops and - * reorders pieces sent past its 16 MB backlog. - */ - send(ws: FlowSocket, data: string): void { - const flow = this.flows.get(ws); - if (flow?.acks) { - const queued = flow.pendingLength; - for (const piece of splitChunks(data, this.limits.chunk)) this.write(ws, flow, piece, false); - flow.maxPending += flow.pendingLength - queued; - } else if (flow) { - this.write(ws, flow, data); - } - this.updatePause(); - } - - /** Sends `data` to every client. */ - broadcast(data: string): void { - for (const [ws, flow] of this.flows) this.write(ws, flow, data); - this.updatePause(); - } - - /** Records that `ws` has parsed `total` characters since it connected. */ - ack(ws: FlowSocket, total: number): void { - const flow = this.flows.get(ws); - if (!flow?.acks || !Number.isFinite(total)) return; - flow.acked = Math.max(flow.acked, Math.min(total, flow.sent)); - if (flow.blocked && flow.sent - flow.acked <= this.limits.lowWater) { - flow.blocked = false; - this.flush(ws, flow); - } - if (flow.ending && flow.pending.length === 0) { - this.close(ws, flow.ending.code, flow.ending.reason); - return; - } - flow.ending?.wait(); - this.updatePause(); - } - - /** Sends or queues `data`; `bounded` is false for a queue allowed past `maxPending`. */ - private write(ws: FlowSocket, flow: ClientFlow, data: string, bounded = true): void { - if (ws.readyState !== ws.OPEN) return; - if (!flow.acks) { - ws.send(data); - if (ws.bufferedAmount > this.limits.legacyBuffered) this.fellBehind(ws); - return; - } - if (!flow.blocked && flow.pending.length === 0) { - this.transmit(ws, flow, data); - return; - } - flow.pending.push(data); - flow.pendingLength += data.length; - if (bounded && flow.pendingLength > flow.maxPending) this.fellBehind(ws); - } - - private transmit(ws: FlowSocket, flow: ClientFlow, data: string): void { - ws.send(data); - flow.sent += data.length; - if (flow.sent - flow.acked >= this.limits.highWater) flow.blocked = true; - } - - /** Sends the queue in order, coalesced, until it is empty or the client blocks again. */ - private flush(ws: FlowSocket, flow: ClientFlow): void { - let i = 0; - while (i < flow.pending.length && !flow.blocked && ws.readyState === ws.OPEN) { - let message = flow.pending[i++]; - while ( - i < flow.pending.length && - message.length + flow.pending[i].length <= this.limits.chunk - ) { - message += flow.pending[i++]; - } - flow.pendingLength -= message.length; - this.transmit(ws, flow, message); - } - flow.pending.splice(0, i); - // A drained queue holds no replay any more, so live output gets the plain limit. - if (flow.pending.length === 0) flow.maxPending = this.limits.maxPending; - } - - private fellBehind(ws: FlowSocket): void { - this.flows.delete(ws); - ws.close(FELL_BEHIND, 'fell behind'); - } - - private close(ws: FlowSocket, code: number, reason: string): void { - const flow = this.flows.get(ws); - this.flows.delete(ws); - if (ws.readyState === ws.OPEN) ws.close(code, reason); - flow?.ending?.done(); - this.updatePause(); - } - - private updatePause(): void { - let allBlocked = this.flows.size > 0; - for (const flow of this.flows.values()) { - if (!flow.acks || !flow.blocked) { - allBlocked = false; - break; - } - } - const pty = this.pty(); - if (allBlocked && !this.paused && pty?.pause) { - pty.pause(); - this.paused = true; - } else if (!allBlocked && this.paused) { - pty?.resume?.(); - this.paused = false; - } - } -} +// Temporary re-export of output-pacer.ts (ADR 062). The client's replay test imports +// DEFAULT_FLOW_LIMITS from '../server/flow'; this file is deleted once that import is +// repointed after the client and server branches merge (step C-F). +export * from './output-pacer'; diff --git a/src/server/flow.test.ts b/src/server/output-pacer.test.ts similarity index 99% rename from src/server/flow.test.ts rename to src/server/output-pacer.test.ts index 304b6df..5ee966f 100644 --- a/src/server/flow.test.ts +++ b/src/server/output-pacer.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { FELL_BEHIND, type FlowLimits, type FlowSocket, OutputPacer, splitChunks } from './flow'; +import { + FELL_BEHIND, + type FlowLimits, + type FlowSocket, + OutputPacer, + splitChunks, +} from './output-pacer'; const LIMITS: FlowLimits = { highWater: 100, diff --git a/src/server/output-pacer.ts b/src/server/output-pacer.ts new file mode 100644 index 0000000..669f2ec --- /dev/null +++ b/src/server/output-pacer.ts @@ -0,0 +1,279 @@ +/** + * PTY output flow control (ADR 048). Each client acknowledges the characters it has + * parsed; the server stops sending to a client that is too far behind, queues its + * output in order, and pauses the PTY while every client is behind. + */ + +/** Close code for a client whose queued output passed {@link FlowLimits.maxPending}. */ +export const FELL_BEHIND = 4003; + +/** Limits in characters (UTF-16 code units, `string.length`), except `legacyBuffered`. */ +export interface FlowLimits { + /** Unacknowledged characters at which a client is blocked and output is queued. */ + highWater: number; + /** Unacknowledged characters at which a blocked client is unblocked. */ + lowWater: number; + /** Largest message the queue is coalesced into (whole chunks are never split). */ + chunk: number; + /** Queued characters past which the client is closed with {@link FELL_BEHIND}. */ + maxPending: number; + /** + * `bufferedAmount` (bytes) past which a legacy client is closed with {@link FELL_BEHIND}. + * Under Bun the close frame is stuck behind the native backlog and the client sees 1006. + */ + legacyBuffered: number; +} + +/** How long {@link OutputPacer.end} waits for an acknowledgement before it closes anyway, in ms. */ +export const END_FLUSH_MS = 2000; + +export const DEFAULT_FLOW_LIMITS: FlowLimits = { + highWater: 2 * 1024 * 1024, + lowWater: 512 * 1024, + chunk: 64 * 1024, + maxPending: 16 * 1024 * 1024, + legacyBuffered: 8 * 1024 * 1024, +}; + +/** The part of a WebSocket the pacer uses. */ +export interface FlowSocket { + readonly readyState: number; + readonly OPEN: number; + readonly bufferedAmount: number; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +/** A PTY that may be able to stop reading output (node-pty can, `Bun.Terminal` cannot). */ +export interface PausablePty { + pause?(): void; + resume?(): void; +} + +interface ClientFlow { + /** Whether the client acknowledges (connected with `?flow=1`). */ + acks: boolean; + sent: number; + acked: number; + blocked: boolean; + pending: string[]; + pendingLength: number; + /** Queued characters past which the client is closed: the limit plus what {@link OutputPacer.send} queued. */ + maxPending: number; + /** Set by {@link OutputPacer.end}: the client is closed with `code` once its queue is sent. */ + ending: { code: number; reason: string; wait: () => void; done: () => void } | null; +} + +/** Splits `data` into pieces of at most `size` characters without cutting a surrogate pair. */ +export function splitChunks(data: string, size: number): string[] { + const pieces: string[] = []; + for (let i = 0; i < data.length; ) { + let end = Math.min(i + size, data.length); + const last = data.charCodeAt(end - 1); + if (end < data.length && end - i > 1 && last >= 0xd800 && last <= 0xdbff) end--; + pieces.push(data.slice(i, end)); + i = end; + } + return pieces; +} + +/** Paces one session's output to its clients. */ +export class OutputPacer { + private flows = new Map<FlowSocket, ClientFlow>(); + private paused = false; + + /** + * @param pty - Returns the session's PTY, or `null` before it spawns. + * @param limits - Flow limits (tests pass small ones). + */ + constructor( + private pty: () => PausablePty | null, + private limits: FlowLimits = DEFAULT_FLOW_LIMITS, + ) {} + + /** Whether the PTY is paused because every client is behind. */ + get isPaused(): boolean { + return this.paused; + } + + /** Starts pacing `ws`. `acks` is true for a client that sends acknowledgements. */ + add(ws: FlowSocket, acks: boolean): void { + this.flows.set(ws, { + acks, + sent: 0, + acked: 0, + blocked: false, + pending: [], + pendingLength: 0, + maxPending: this.limits.maxPending, + ending: null, + }); + this.updatePause(); + } + + /** Stops pacing `ws` (closed) and drops its queue. */ + remove(ws: FlowSocket): void { + const flow = this.flows.get(ws); + if (!flow) return; + this.flows.delete(ws); + flow.ending?.done(); + this.updatePause(); + } + + /** + * Closes every client with `code` once the output queued for it has been sent, as + * its acknowledgements allow, so the tail of the output is not lost when the shell + * exits. A client that does not acknowledge for `ms` is closed anyway; the queue + * no longer grows, so a client that keeps acknowledging is closed within + * {@link FlowLimits.maxPending}. + * + * @returns A promise that resolves once every client is closed. + */ + end(code: number, reason: string, ms = END_FLUSH_MS): Promise<void> { + const waits: Promise<void>[] = []; + for (const [ws, flow] of this.flows) { + if (flow.pending.length === 0) { + this.close(ws, code, reason); + continue; + } + waits.push( + new Promise((resolve) => { + let timer: ReturnType<typeof setTimeout> | undefined; + flow.ending = { + code, + reason, + wait: () => { + clearTimeout(timer); + timer = setTimeout(() => this.close(ws, code, reason), ms); + }, + done: () => { + clearTimeout(timer); + resolve(); + }, + }; + flow.ending.wait(); + }), + ); + } + return Promise.all(waits).then(() => {}); + } + + /** + * Sends `data` (the banner or the scrollback replay) to one client, in order after + * anything queued for it. An acknowledging client gets it in pieces of + * {@link FlowLimits.chunk}, so a large replay is paced like live output instead of + * filling the send buffer at once. What it queues is allowed on top of + * {@link FlowLimits.maxPending} until the queue drains: the replay is already held as the + * scrollback, and a scrollback over the limit would otherwise close every client that + * connects. A legacy + * client gets it whole as before: Bun's shim accepts one large message, but drops and + * reorders pieces sent past its 16 MB backlog. + */ + send(ws: FlowSocket, data: string): void { + const flow = this.flows.get(ws); + if (flow?.acks) { + const queued = flow.pendingLength; + for (const piece of splitChunks(data, this.limits.chunk)) this.write(ws, flow, piece, false); + flow.maxPending += flow.pendingLength - queued; + } else if (flow) { + this.write(ws, flow, data); + } + this.updatePause(); + } + + /** Sends `data` to every client. */ + broadcast(data: string): void { + for (const [ws, flow] of this.flows) this.write(ws, flow, data); + this.updatePause(); + } + + /** Records that `ws` has parsed `total` characters since it connected. */ + ack(ws: FlowSocket, total: number): void { + const flow = this.flows.get(ws); + if (!flow?.acks || !Number.isFinite(total)) return; + flow.acked = Math.max(flow.acked, Math.min(total, flow.sent)); + if (flow.blocked && flow.sent - flow.acked <= this.limits.lowWater) { + flow.blocked = false; + this.flush(ws, flow); + } + if (flow.ending && flow.pending.length === 0) { + this.close(ws, flow.ending.code, flow.ending.reason); + return; + } + flow.ending?.wait(); + this.updatePause(); + } + + /** Sends or queues `data`; `bounded` is false for a queue allowed past `maxPending`. */ + private write(ws: FlowSocket, flow: ClientFlow, data: string, bounded = true): void { + if (ws.readyState !== ws.OPEN) return; + if (!flow.acks) { + ws.send(data); + if (ws.bufferedAmount > this.limits.legacyBuffered) this.fellBehind(ws); + return; + } + if (!flow.blocked && flow.pending.length === 0) { + this.transmit(ws, flow, data); + return; + } + flow.pending.push(data); + flow.pendingLength += data.length; + if (bounded && flow.pendingLength > flow.maxPending) this.fellBehind(ws); + } + + private transmit(ws: FlowSocket, flow: ClientFlow, data: string): void { + ws.send(data); + flow.sent += data.length; + if (flow.sent - flow.acked >= this.limits.highWater) flow.blocked = true; + } + + /** Sends the queue in order, coalesced, until it is empty or the client blocks again. */ + private flush(ws: FlowSocket, flow: ClientFlow): void { + let i = 0; + while (i < flow.pending.length && !flow.blocked && ws.readyState === ws.OPEN) { + let message = flow.pending[i++]; + while ( + i < flow.pending.length && + message.length + flow.pending[i].length <= this.limits.chunk + ) { + message += flow.pending[i++]; + } + flow.pendingLength -= message.length; + this.transmit(ws, flow, message); + } + flow.pending.splice(0, i); + // A drained queue holds no replay any more, so live output gets the plain limit. + if (flow.pending.length === 0) flow.maxPending = this.limits.maxPending; + } + + private fellBehind(ws: FlowSocket): void { + this.flows.delete(ws); + ws.close(FELL_BEHIND, 'fell behind'); + } + + private close(ws: FlowSocket, code: number, reason: string): void { + const flow = this.flows.get(ws); + this.flows.delete(ws); + if (ws.readyState === ws.OPEN) ws.close(code, reason); + flow?.ending?.done(); + this.updatePause(); + } + + private updatePause(): void { + let allBlocked = this.flows.size > 0; + for (const flow of this.flows.values()) { + if (!flow.acks || !flow.blocked) { + allBlocked = false; + break; + } + } + const pty = this.pty(); + if (allBlocked && !this.paused && pty?.pause) { + pty.pause(); + this.paused = true; + } else if (!allBlocked && this.paused) { + pty?.resume?.(); + this.paused = false; + } + } +} diff --git a/src/server/session.ts b/src/server/session.ts index e621f31..8b6d3e1 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os'; import type { WebSocket as WS } from 'ws'; import type { PtyProcess } from '../pty'; -import { OutputPacer } from './flow'; +import { OutputPacer } from './output-pacer'; /** A running (or recently created) terminal session. */ export interface Session { diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 1009bfb..3fb489a 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -4,7 +4,7 @@ import { WebSocketServer } from 'ws'; import { loadConfig } from '../config'; import { spawnForSession } from '../pty'; import { packageRunner } from '../runtime'; -import { FELL_BEHIND } from './flow'; +import { FELL_BEHIND } from './output-pacer'; import type { Session } from './session'; import { sessionRegistry, setLastUsedId } from './session'; From 7c942953037f04266a91367cd6b743a101c9278f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:07:02 -0400 Subject: [PATCH 151/295] test(client): test support fakes, an injected happy-dom window and DisposableStore The shared foundations of the refactor's tests (ADR 064) and its one teardown primitive (ADR 060, ADR 065): - src/client/test-support/: FakeTerminal (the Terminal slice features use, with fireable events and an ordered subscription log), FakeSocket and fakeWebSocket() (an injectable constructor; the test plays the server), ManualClock (timeouts, intervals, animation frames and now(), driven by the test), RecordingTarget (logs every listener added and removed with its options) and createDomWindow() (an explicit happy-dom Window whose document is injected; never global). Their self-tests are in src/client/test-support.test.ts, since nothing under test-support/ may be named *.test.ts. - vendor lifecycle/disposable-store.ts (webtty-original): DisposableStore disposes once, newest first; listen() removes with the same capture flag; toDisposable() wraps a timer or frame cancel. Exported by the barrel, whose header now names its origin and role. Tests in vendor/tests/lifecycle/. - PROVENANCE.md gains the webtty-original files list, the Layout note and the Moves and splits table (ADR 061). Nothing in the app uses the new code yet; the bundle is unchanged in size. --- docs/specs/architecture.md | 5 +- src/client/test-support.test.ts | 226 ++++++++++++++++++ src/client/test-support/dom-window.ts | 26 ++ src/client/test-support/fake-socket.ts | 101 ++++++++ src/client/test-support/fake-terminal.ts | 137 +++++++++++ src/client/test-support/manual-clock.ts | 101 ++++++++ src/client/test-support/recording-target.ts | 86 +++++++ src/client/vendor/ghostty-web/PROVENANCE.md | 21 +- src/client/vendor/ghostty-web/index.ts | 12 +- .../ghostty-web/lifecycle/disposable-store.ts | 122 ++++++++++ .../tests/lifecycle/disposable-store.test.ts | 120 ++++++++++ 11 files changed, 949 insertions(+), 8 deletions(-) create mode 100644 src/client/test-support.test.ts create mode 100644 src/client/test-support/dom-window.ts create mode 100644 src/client/test-support/fake-socket.ts create mode 100644 src/client/test-support/fake-terminal.ts create mode 100644 src/client/test-support/manual-clock.ts create mode 100644 src/client/test-support/recording-target.ts create mode 100644 src/client/vendor/ghostty-web/lifecycle/disposable-store.ts create mode 100644 src/client/vendor/tests/lifecycle/disposable-store.test.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index ab26c46..5434a66 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -152,6 +152,7 @@ src/ graphics/ image-store.ts, image-decoder.ts, placements.ts, kitty-limits.ts, overlay-canvas.ts (+ dispose), kitty-graphics.ts (facade) kitty-feature.ts test-support/ fake-terminal.ts, fake-socket.ts, manual-clock.ts, recording-target.ts, dom-window.ts + test-support.test.ts the fakes' self-tests (a test-support/ folder holds no *.test.ts) vendor/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/) @@ -185,7 +186,7 @@ ghostty-web/ interfaces.ts buffer.ts (IBufferSource) event-emitter.ts types.ts (re-export shim until removed) search.ts glide.ts wheel.ts mouse-route.ts webtty-original, kept in place link-detector.ts providers/ addons/fit.ts fn-table.ts - lifecycle/ disposable-store.ts (DisposableStore, listen) – webtty-original + lifecycle/ disposable-store.ts (DisposableStore, listen, toDisposable) – webtty-original engine/ every wasm call; no DOM engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts scrollback-pages.ts @@ -226,7 +227,7 @@ Other patterns used, each in one named place: **Command map** for shortcut dispa ### Disposables -`listen(target, type, fn, options)` adds a listener and returns a `Disposable` that removes it with the same `capture` flag. `DisposableStore` collects disposables and disposes them once, in reverse order; disposing twice is a no-op. Every class that adds a listener, a timer, an interval or a `requestAnimationFrame` loop owns a store and releases it in `dispose()`. +`listen(target, type, fn, options)` adds a listener and returns a `Disposable` that removes it with the same `capture` flag. `toDisposable(fn)` wraps a timer or frame cancel. `DisposableStore` collects disposables and disposes them once, in reverse order; disposing twice is a no-op, and a disposable added after `dispose()` is disposed at once. Every class that adds a listener, a timer, an interval or a `requestAnimationFrame` loop owns a store and releases it in `dispose()`. ### The client composition root diff --git a/src/client/test-support.test.ts b/src/client/test-support.test.ts new file mode 100644 index 0000000..247cbd1 --- /dev/null +++ b/src/client/test-support.test.ts @@ -0,0 +1,226 @@ +// Self-tests for the client's test fakes in test-support/ (ADR 064). They live here, not +// beside the fakes, because nothing under a test-support/ folder may be named *.test.ts. + +import { describe, expect, test } from 'bun:test'; +import { createDomWindow } from './test-support/dom-window'; +import { fakeWebSocket } from './test-support/fake-socket'; +import { FakeTerminal } from './test-support/fake-terminal'; +import { ManualClock } from './test-support/manual-clock'; +import { RecordingTarget } from './test-support/recording-target'; + +describe('ManualClock', () => { + test('runs timeouts in due order when time advances, with now() at each due time', () => { + const clock = new ManualClock(100); + const log: string[] = []; + clock.setTimeout(() => log.push(`b@${clock.now()}`), 20); + clock.setTimeout(() => log.push(`a@${clock.now()}`), 10); + clock.setTimeout(() => log.push(`c@${clock.now()}`), 20); + clock.advance(15); + expect(log).toEqual(['a@110']); + expect(clock.now()).toBe(115); + clock.advance(5); + expect(log).toEqual(['a@110', 'b@120', 'c@120']); + expect(clock.pending.timers).toBe(0); + }); + + test('a timeout set by a running timer runs in the same advance when it falls due', () => { + const clock = new ManualClock(); + const log: number[] = []; + clock.setTimeout(() => clock.setTimeout(() => log.push(clock.now()), 5), 5); + clock.advance(10); + expect(log).toEqual([10]); + }); + + test('clearTimeout cancels; unknown ids are ignored', () => { + const clock = new ManualClock(); + let runs = 0; + const id = clock.setTimeout(() => runs++); + clock.clearTimeout(id); + clock.clearTimeout(999); + clock.clearTimeout(undefined); + clock.advance(1); + expect(runs).toBe(0); + }); + + test('intervals repeat until cleared', () => { + const clock = new ManualClock(); + const log: number[] = []; + const id = clock.setInterval(() => log.push(clock.now()), 10); + clock.advance(35); + expect(log).toEqual([10, 20, 30]); + clock.clearInterval(id); + clock.advance(100); + expect(log).toEqual([10, 20, 30]); + }); + + test('a zero interval still moves time on, so it cannot loop forever', () => { + const clock = new ManualClock(); + let runs = 0; + clock.setInterval(() => runs++, 0); + clock.advance(3); + expect(runs).toBe(4); + }); + + test('frame() runs queued callbacks with the frame time; ones queued meanwhile wait', () => { + const clock = new ManualClock(); + const log: string[] = []; + clock.requestAnimationFrame((t) => { + log.push(`first@${t}`); + clock.requestAnimationFrame((u) => log.push(`next@${u}`)); + }); + const dropped = clock.requestAnimationFrame(() => log.push('dropped')); + clock.cancelAnimationFrame(dropped); + clock.frame(16); + expect(log).toEqual(['first@16']); + expect(clock.pending.frames).toBe(1); + clock.frame(); + expect(log).toEqual(['first@16', `next@${16 + 1000 / 60}`]); + }); +}); + +describe('RecordingTarget', () => { + const a = () => {}; + const b = () => {}; + + test('records adds with their options and removes that match type, listener and capture', () => { + const target = new RecordingTarget('window'); + target.addEventListener('keydown', a, { capture: true, passive: false }); + target.addEventListener('keydown', a, { capture: true }); + target.addEventListener('keydown', b, { once: true }); + expect(target.added.map((r) => [r.target, r.type, r.capture, r.passive, r.once])).toEqual([ + ['window', 'keydown', true, false, undefined], + ['window', 'keydown', true, undefined, undefined], + ['window', 'keydown', false, undefined, true], + ]); + // The duplicate add is ignored, as the DOM does. + expect(target.active).toHaveLength(2); + target.removeEventListener('keydown', a); + expect(target.removed).toEqual([]); + target.removeEventListener('keydown', a, true); + expect(target.removed).toEqual([target.added[0]]); + expect(target.active.map((r) => r.listener)).toEqual([b]); + }); + + test('dispatch calls live listeners in order, objects too, and drops once listeners', () => { + const target = new RecordingTarget(); + const log: string[] = []; + target.addEventListener('focus', () => log.push('fn'), { once: true }); + target.addEventListener('focus', { handleEvent: (e: Event) => log.push(`object:${e.type}`) }); + target.addEventListener('blur', () => log.push('blur')); + target.dispatch({ type: 'focus' }); + target.dispatch({ type: 'focus' }); + expect(log).toEqual(['fn', 'object:focus', 'object:focus']); + expect(target.active.map((r) => r.type)).toEqual(['focus', 'blur']); + }); +}); + +describe('fakeWebSocket', () => { + test('records the sockets made and what the client sends while open', () => { + const fake = fakeWebSocket(); + expect(() => fake.last()).toThrow(); + new fake.WebSocket('ws://host/ws/main?cols=80'); + const socket = fake.last(); + expect(socket.url).toBe('ws://host/ws/main?cols=80'); + expect(socket.readyState).toBe(fake.WebSocket.CONNECTING); + expect(() => socket.send('early')).toThrow(); + const log: string[] = []; + socket.onopen = () => log.push('open'); + socket.onmessage = (e) => log.push(`message:${e.data}`); + socket.onclose = (e) => log.push(`close:${e.code}`); + socket.open(); + expect(socket.readyState).toBe(socket.OPEN); + socket.send('hi'); + socket.receive('out'); + socket.serverClose(4001); + socket.send('late'); + expect(socket.sent).toEqual(['hi']); + expect(log).toEqual(['open', 'message:out', 'close:4001']); + }); + + test('a client close and a failure end with a close event', () => { + const fake = fakeWebSocket(); + new fake.WebSocket('ws://a'); + const closed = fake.last(); + const codes: number[] = []; + closed.onclose = (e) => codes.push(e.code); + closed.close(1000, 'bye'); + closed.close(); + expect(closed.closedBy).toEqual({ code: 1000, reason: 'bye' }); + new fake.WebSocket('ws://b'); + const failed = fake.last(); + const events: string[] = []; + failed.onerror = (e) => events.push(e.type); + failed.onclose = (e) => events.push(`${e.type}:${e.code}:${e.wasClean}`); + failed.fail(); + new fake.WebSocket('ws://c'); + const plain = fake.last(); + plain.onclose = (e) => codes.push(e.code); + plain.close(); + expect(codes).toEqual([1000, 1005]); + expect(events).toEqual(['error', 'close:1006:false']); + expect(fake.sockets).toHaveLength(3); + }); +}); + +describe('FakeTerminal', () => { + test('logs subscriptions in order and delivers fired events until disposed', () => { + const term = new FakeTerminal(); + const log: string[] = []; + const title = term.onTitleChange((t) => log.push(`title:${t}`)); + term.onBell(() => log.push('bell')); + term.onResize(({ cols, rows }) => log.push(`resize:${cols}x${rows}`)); + expect(term.subscriptions).toEqual(['onTitleChange', 'onBell', 'onResize']); + term.fire('onTitleChange', 'vim'); + term.fire('onBell'); + title.dispose(); + term.fire('onTitleChange', 'ignored'); + term.resize(100, 30); + expect(log).toEqual(['title:vim', 'bell', 'resize:100x30']); + expect([term.cols, term.rows]).toEqual([100, 30]); + }); + + test('records writes and commands', () => { + const term = new FakeTerminal(); + let written = false; + term.write('a'); + term.write(new Uint8Array([1])); + term.write('b', () => { + written = true; + }); + expect(written).toBe(true); + expect(term.output).toBe('ab'); + expect(term.written).toHaveLength(3); + term.viewportY = 5; + term.paste('x'); + term.find('needle'); + expect(term.findNext()).toBe(true); + expect(term.findPrevious()).toBe(true); + term.endFind(); + term.focus(); + term.reset(); + term.scrollToBottom(); + expect(term.viewportY).toBe(0); + expect(term.calls).toEqual([ + 'paste:x', + 'find:needle', + 'findNext', + 'findPrevious', + 'endFind', + 'focus', + 'reset', + 'scrollToBottom', + ]); + expect(term.asTerminal() as object).toBe(term); + }); +}); + +describe('createDomWindow', () => { + test('gives a document of its own, not a global one', async () => { + const dom = createDomWindow(); + expect(dom.window.location.pathname).toBe('/s/test'); + dom.document.body.appendChild(dom.document.createElement('div')); + expect(dom.document.querySelectorAll('div')).toHaveLength(1); + expect((globalThis as { document?: unknown }).document).not.toBe(dom.document); + await dom.close(); + }); +}); diff --git a/src/client/test-support/dom-window.ts b/src/client/test-support/dom-window.ts new file mode 100644 index 0000000..196df9a --- /dev/null +++ b/src/client/test-support/dom-window.ts @@ -0,0 +1,26 @@ +// A real DOM for view and feature tests (test adapter, ADR 064): one happy-dom Window per +// test, whose document and window are injected into the code under test. It is never +// registered on globalThis, so tests that stub globals and the wasm tests see no change. + +import { Window as HappyWindow } from 'happy-dom'; + +/** One test's DOM, typed as the browser's own, and the call that closes it. */ +export interface DomWindow { + window: Window; + document: Document; + /** Aborts happy-dom's pending tasks and closes the window; call it in the test's teardown. */ + close(): Promise<void>; +} + +/** Opens a fresh happy-dom window at `url`; nothing is fetched, the URL only sets `location`. */ +export function createDomWindow(url = 'http://localhost/s/test'): DomWindow { + const happy = new HappyWindow({ url, width: 1024, height: 768 }); + // happy-dom implements the DOM interfaces the code under test uses; lib.dom's types + // name the full browser API, so the window is typed as the browser's here, once. + const window = happy as unknown as Window; + return { + window, + document: window.document, + close: () => happy.happyDOM.close(), + }; +} diff --git a/src/client/test-support/fake-socket.ts b/src/client/test-support/fake-socket.ts new file mode 100644 index 0000000..49738fc --- /dev/null +++ b/src/client/test-support/fake-socket.ts @@ -0,0 +1,101 @@ +// A WebSocket stand-in for client tests (test fake, ADR 064): it records what the client +// sends and lets the test play the server, opening, sending output and closing with a +// code. fakeWebSocket() gives a constructor to inject and the sockets it made, in order. + +/** The ready states, as numbers, matching `WebSocket.CONNECTING` and its kin. */ +export const READY_STATE = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 } as const; + +/** One client socket: the slice of `WebSocket` webtty's client uses, plus server-side controls. */ +export class FakeSocket { + static readonly CONNECTING = READY_STATE.CONNECTING; + static readonly OPEN = READY_STATE.OPEN; + static readonly CLOSING = READY_STATE.CLOSING; + static readonly CLOSED = READY_STATE.CLOSED; + readonly CONNECTING = READY_STATE.CONNECTING; + readonly OPEN = READY_STATE.OPEN; + readonly CLOSING = READY_STATE.CLOSING; + readonly CLOSED = READY_STATE.CLOSED; + + readyState: number = READY_STATE.CONNECTING; + /** Every message the client sent, in order. */ + readonly sent: string[] = []; + /** The code and reason of the client's own `close()`, once it called it. */ + closedBy?: { code?: number; reason?: string }; + + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent<string>) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(readonly url: string) {} + + /** + * The client sends a message. As in the browser, sending before the socket opens throws + * and sending after it closed is dropped. + */ + send(data: string): void { + if (this.readyState === READY_STATE.CONNECTING) { + throw new Error('FakeSocket: send before the socket opened'); + } + if (this.readyState === READY_STATE.OPEN) this.sent.push(data); + } + + /** The client closes the socket; the close event follows, as it would from the server. */ + close(code?: number, reason?: string): void { + if (this.readyState === READY_STATE.CLOSED) return; + this.closedBy = { code, reason }; + this.serverClose(code ?? 1005, reason ?? ''); + } + + /** The server accepts the connection. */ + open(): void { + this.readyState = READY_STATE.OPEN; + this.onopen?.({ type: 'open' } as Event); + } + + /** The server sends `data`. */ + receive(data: string): void { + this.onmessage?.({ type: 'message', data } as MessageEvent<string>); + } + + /** The connection fails: an error event, then a close with code 1006. */ + fail(): void { + this.onerror?.({ type: 'error' } as Event); + this.serverClose(1006, ''); + } + + /** The server closes the connection with `code`. */ + serverClose(code: number, reason = ''): void { + this.readyState = READY_STATE.CLOSED; + this.onclose?.({ type: 'close', code, reason, wasClean: code !== 1006 } as CloseEvent); + } +} + +/** A `WebSocket` constructor for injection, and every socket it has made. */ +export interface FakeWebSocket { + WebSocket: typeof WebSocket; + sockets: FakeSocket[]; + /** The newest socket; throws when none was made, so a test fails where it expected one. */ + last(): FakeSocket; +} + +/** Makes a fresh `WebSocket` stand-in whose sockets the test can reach. */ +export function fakeWebSocket(): FakeWebSocket { + const sockets: FakeSocket[] = []; + class RecordedSocket extends FakeSocket { + constructor(url: string | URL) { + super(String(url)); + sockets.push(this); + } + } + return { + // The fake covers the members the client uses, not the whole WebSocket interface. + WebSocket: RecordedSocket as unknown as typeof WebSocket, + sockets, + last() { + const socket = sockets.at(-1); + if (!socket) throw new Error('FakeSocket: no socket was created'); + return socket; + }, + }; +} diff --git a/src/client/test-support/fake-terminal.ts b/src/client/test-support/fake-terminal.ts new file mode 100644 index 0000000..b3d8bfc --- /dev/null +++ b/src/client/test-support/fake-terminal.ts @@ -0,0 +1,137 @@ +// The slice of the vendored Terminal that webtty's features use, as a fake (test fake, +// ADR 064): its emitters can be fired by the test, its subscriptions are logged in order +// for the client root's order test, and its commands are recorded instead of run. + +import { + type DesktopNotification, + EventEmitter, + type IEvent, + type Terminal, +} from '../vendor/ghostty-web'; + +/** What `onFindChange` reports, taken from the real terminal so the two cannot drift. */ +export type FindState = Parameters<Parameters<Terminal['onFindChange']>[0]>[0]; + +/** What each terminal event a feature subscribes to carries, by its `on…` name. */ +export interface TerminalEvents { + onData: string; + onResize: { cols: number; rows: number }; + onBell: undefined; + onTitleChange: string; + onScroll: number; + onPasteRequest: undefined; + onClipboardWrite: string; + onDesktopNotification: DesktopNotification; + onFindChange: FindState; +} + +/** The name of a terminal event. */ +export type TerminalEventName = keyof TerminalEvents; + +/** A fake `Terminal`: fire its events, read what was written, called and subscribed. */ +export class FakeTerminal { + cols = 80; + rows = 24; + viewportY = 0; + options: Record<string, unknown> = {}; + /** Everything passed to `write`, in order. */ + readonly written: (string | Uint8Array)[] = []; + /** Every command called, as `name` or `name:arg`, in order. */ + readonly calls: string[] = []; + /** Every event subscription, by event name, in order. */ + readonly subscriptions: TerminalEventName[] = []; + + private readonly emitters = new Map<TerminalEventName, EventEmitter<unknown>>(); + + readonly onData = this.subscribe('onData'); + readonly onResize = this.subscribe('onResize'); + readonly onBell = this.subscribe('onBell'); + readonly onTitleChange = this.subscribe('onTitleChange'); + readonly onScroll = this.subscribe('onScroll'); + readonly onPasteRequest = this.subscribe('onPasteRequest'); + readonly onClipboardWrite = this.subscribe('onClipboardWrite'); + readonly onDesktopNotification = this.subscribe('onDesktopNotification'); + readonly onFindChange = this.subscribe('onFindChange'); + + /** Fires `name` with `arg`, as the real terminal would. */ + fire<K extends TerminalEventName>( + name: K, + ...arg: TerminalEvents[K] extends undefined ? [] : [TerminalEvents[K]] + ): void { + this.emitter(name).fire(arg[0]); + } + + write(data: string | Uint8Array, callback?: () => void): void { + this.written.push(data); + callback?.(); + } + + /** The written strings joined, for asserting on text output. */ + get output(): string { + return this.written.filter((chunk) => typeof chunk === 'string').join(''); + } + + resize(cols: number, rows: number): void { + this.calls.push(`resize:${cols}x${rows}`); + this.cols = cols; + this.rows = rows; + this.emitter('onResize').fire({ cols, rows }); + } + + paste(text: string): void { + this.calls.push(`paste:${text}`); + } + + find(needle: string): void { + this.calls.push(`find:${needle}`); + } + + findNext(): boolean { + this.calls.push('findNext'); + return true; + } + + findPrevious(): boolean { + this.calls.push('findPrevious'); + return true; + } + + endFind(): void { + this.calls.push('endFind'); + } + + focus(): void { + this.calls.push('focus'); + } + + reset(): void { + this.calls.push('reset'); + } + + scrollToBottom(): void { + this.calls.push('scrollToBottom'); + this.viewportY = 0; + } + + /** The fake typed as the real class, for code under test that takes a `Terminal`. */ + asTerminal(): Terminal { + // The fake covers the members features use, not the whole Terminal class. + return this as unknown as Terminal; + } + + private emitter(name: TerminalEventName): EventEmitter<unknown> { + let emitter = this.emitters.get(name); + if (!emitter) { + emitter = new EventEmitter<unknown>(); + this.emitters.set(name, emitter); + } + return emitter; + } + + private subscribe<K extends TerminalEventName>(name: K): IEvent<TerminalEvents[K]> { + return (listener) => { + this.subscriptions.push(name); + return this.emitter(name).event(listener as (arg: unknown) => void); + }; + } +} diff --git a/src/client/test-support/manual-clock.ts b/src/client/test-support/manual-clock.ts new file mode 100644 index 0000000..6296018 --- /dev/null +++ b/src/client/test-support/manual-clock.ts @@ -0,0 +1,101 @@ +// A hand-driven clock for tests (test fake, ADR 064): timeouts, intervals and animation +// frames run only when the test advances time or steps a frame, so timing-dependent code +// is tested without waiting. Its methods are bound, so they can be injected as deps. + +interface Timer { + id: number; + due: number; + run: () => void; + /** Set for an interval: how far `due` moves after each run. */ + every?: number; +} + +/** Stands in for `setTimeout`, `setInterval`, `requestAnimationFrame` and `performance.now`. */ +export class ManualClock { + private time: number; + private nextId = 1; + private timers: Timer[] = []; + private frames = new Map<number, FrameRequestCallback>(); + + constructor(start = 0) { + this.time = start; + } + + /** The current time in ms, as `performance.now()` would report it. */ + readonly now = (): number => this.time; + + /** Schedules `run` to run once `ms` have passed. */ + readonly setTimeout = (run: () => void, ms = 0): number => this.schedule(run, ms); + + /** Schedules `run` every `ms`, first after `ms`. */ + readonly setInterval = (run: () => void, ms = 0): number => + this.schedule(run, ms, Math.max(1, ms)); + + /** Cancels a timeout; unknown ids are ignored, as the browser does. */ + readonly clearTimeout = (id: number | undefined): void => { + this.timers = this.timers.filter((timer) => timer.id !== id); + }; + + /** Cancels an interval. */ + readonly clearInterval = (id: number | undefined): void => this.clearTimeout(id); + + /** Queues `callback` for the next `frame()`. */ + readonly requestAnimationFrame = (callback: FrameRequestCallback): number => { + const id = this.nextId++; + this.frames.set(id, callback); + return id; + }; + + /** Drops a queued frame callback. */ + readonly cancelAnimationFrame = (id: number): void => { + this.frames.delete(id); + }; + + /** Timers and frame callbacks still waiting. */ + get pending(): { timers: number; frames: number } { + return { timers: this.timers.length, frames: this.frames.size }; + } + + /** + * Moves time forward by `ms`, running every timer that falls due on the way in due + * order (ties in the order they were set), each with `now()` at its due time. + */ + advance(ms: number): void { + const end = this.time + ms; + for (;;) { + const timer = this.earliestDueBy(end); + if (!timer) break; + this.time = timer.due; + if (timer.every === undefined) this.clearTimeout(timer.id); + else timer.due += timer.every; + timer.run(); + } + this.time = end; + } + + /** + * Runs the frame callbacks queued so far with `now()` as their timestamp, after moving + * time on by `ms` (a frame at 60 Hz by default). Callbacks queued while the frame runs + * wait for the next one, as in the browser. + */ + frame(ms = 1000 / 60): void { + this.advance(ms); + const queued = [...this.frames]; + this.frames.clear(); + for (const [, callback] of queued) callback(this.time); + } + + private schedule(run: () => void, ms: number, every?: number): number { + const id = this.nextId++; + this.timers.push({ id, due: this.time + Math.max(0, ms), run, every }); + return id; + } + + private earliestDueBy(end: number): Timer | undefined { + let earliest: Timer | undefined; + for (const timer of this.timers) { + if (timer.due <= end && (!earliest || timer.due < earliest.due)) earliest = timer; + } + return earliest; + } +} diff --git a/src/client/test-support/recording-target.ts b/src/client/test-support/recording-target.ts new file mode 100644 index 0000000..b3abdeb --- /dev/null +++ b/src/client/test-support/recording-target.ts @@ -0,0 +1,86 @@ +// An event target that records every listener added and removed, with its options (test +// fake, ADR 064). Listener-order and teardown tests compare its log with the SPEC's tables; +// dispatch() calls the live listeners, so it also stands in for a window or an element. + +import type { ListenerTarget } from '../vendor/ghostty-web'; + +/** One addEventListener or removeEventListener call, as the order and teardown tests read it. */ +export interface ListenerRecord { + target: string; + type: string; + capture: boolean; + passive?: boolean; + once?: boolean; + listener: EventListenerOrEventListenerObject; +} + +function captureOf(options: boolean | EventListenerOptions | undefined): boolean { + return typeof options === 'boolean' ? options : options?.capture === true; +} + +function sameListener(a: ListenerRecord, b: ListenerRecord): boolean { + return a.type === b.type && a.listener === b.listener && a.capture === b.capture; +} + +/** A named `EventTarget` stand-in that logs each listener added and removed. */ +export class RecordingTarget implements ListenerTarget { + /** Every add, in order, including ones the DOM would ignore as duplicates. */ + readonly added: ListenerRecord[] = []; + /** Every remove that matched a live listener, in order. */ + readonly removed: ListenerRecord[] = []; + private live: ListenerRecord[] = []; + + constructor(readonly name = 'target') {} + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + const record: ListenerRecord = { + target: this.name, + type, + capture: captureOf(options), + listener, + }; + if (typeof options === 'object') { + if (options.passive !== undefined) record.passive = options.passive; + if (options.once !== undefined) record.once = options.once; + } + this.added.push(record); + // The DOM adds a listener once per (type, listener, capture). + if (!this.live.some((other) => sameListener(other, record))) this.live.push(record); + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void { + const probe: ListenerRecord = { + target: this.name, + type, + capture: captureOf(options), + listener, + }; + const index = this.live.findIndex((other) => sameListener(other, probe)); + if (index < 0) return; + this.removed.push(this.live[index]); + this.live.splice(index, 1); + } + + /** The listeners still attached, in the order they were added. */ + get active(): readonly ListenerRecord[] { + return this.live; + } + + /** Calls the live listeners for `event.type` in order, as the DOM's target phase does. */ + dispatch(event: Event | { type: string }): void { + for (const record of this.live.filter((r) => r.type === event.type)) { + if (record.once) this.removeEventListener(record.type, record.listener, record.capture); + const { listener } = record; + if (typeof listener === 'function') listener(event as Event); + else listener.handleEvent(event as Event); + } + } +} diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index e5410c9..8896d6e 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -8,7 +8,7 @@ Vendored copy of [coder/ghostty-web](https://github.com/coder/ghostty-web), MIT `bun run build` writes the notice to `dist/THIRD_PARTY_LICENSES`. -webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. +webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. Lines moved out of a file into a subfolder (ADR 061) are followed with `git log --follow` and the **Moves and splits** table below. ## Local changes @@ -46,3 +46,22 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`, `index.ts`: desktop notifications (ADR 056). The binding installs Ghostty's `DESKTOP_NOTIFICATION` callback when the new `GhosttyTerminalConfig.desktopNotifications` is on (default true): it copies the request's `title` (`''` for OSC 9) and `body` out as UTF-8, reading only what the struct's `size` covers. `TerminalEffects` gains `notifications`, every request since `readEffects`, in order, and `DesktopNotification` is exported. `Terminal` gains the `desktopNotifications` option, passed to the engine, and `onDesktopNotification`, fired for each. Upstream ignored OSC 9 and OSC 777. - `ghostty.ts`, `terminal.ts`, `selection-manager.ts`: OSC 133 prompt jumps and output selection (ADR 057). The binding adds `promptJumpRow` (Ghostty's viewport put on webtty's top row with `ROW`, moved with `GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`, read back with `GHOSTTY_TERMINAL_DATA_SCROLLBAR` and put back at the bottom; `scrollViewport` writes its value as an `isize`) and `selectOutput` (`ghostty_terminal_select_output`). `Terminal` adds `jumpToPrompt`, Ghostty's `jump_to_prompt`, scrolling the view with `scrollToLine`. A triple click with Ctrl or Cmd held selects the command's output instead of the line, as in Ghostty. Upstream had neither. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. +- `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`). + +## webtty-original files + +Not from upstream; each starts with, or will start with when it is next touched, `// webtty (ADR NNN), not from upstream.` + +- `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). +- `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. + +## Layout + +- `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. + +## Moves and splits + +Each commit that moves code out of an upstream-derived file appends its rows here (ADR 061). The moved lines keep their `(webtty, ADR NNN)` tags, and the new file starts with `// Derived from coder/ghostty-web lib/<original>.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/).` + +| From (upstream file: symbols) | To | Commit subject | +|---|---|---| diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index d274852..7632cfe 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -1,8 +1,6 @@ -/** - * Public API for @cmux/ghostty-terminal - * - * Main entry point following xterm.js conventions - */ +// Derived from coder/ghostty-web lib/index.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The vendored layer's public barrel: the only path webtty's app code imports vendor code +// through (ADR 060, ADR 061). It follows xterm.js' naming. // Main Terminal class export { Terminal } from './terminal'; @@ -51,3 +49,7 @@ export { OSC8LinkProvider } from './providers/osc8-link-provider'; export { UrlRegexProvider } from './providers/url-regex-provider'; export { LinkDetector } from './link-detector'; export type { ILink, ILinkProvider, IBufferCellPosition } from './types'; + +// webtty's teardown (ADR 060, ADR 065) +export { DisposableStore, listen, toDisposable } from './lifecycle/disposable-store'; +export type { Disposable, ListenerTarget } from './lifecycle/disposable-store'; diff --git a/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts b/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts new file mode 100644 index 0000000..bc65094 --- /dev/null +++ b/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts @@ -0,0 +1,122 @@ +// webtty (ADR 060, ADR 065), not from upstream. +// The one teardown primitive (object): a DisposableStore collects what a class added, +// listeners, timers, animation frames, emitter subscriptions, and releases it once, in +// reverse order; listen() adds a DOM listener and returns the Disposable that removes it. + +/** + * Anything that releases what it holds. The same shape as xterm's `IDisposable`, so an + * emitter subscription goes into a store as it is. + */ +export interface Disposable { + dispose(): void; +} + +/** The slice of `EventTarget` that `listen` needs, so a test can pass a recording fake. */ +export interface ListenerTarget { + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} + +/** A `Disposable` that runs `release` the first time it is disposed, and never again. */ +export function toDisposable(release: () => void): Disposable { + let released = false; + return { + dispose() { + if (released) return; + released = true; + release(); + }, + }; +} + +/** + * Collects disposables and disposes them once, newest first, so teardown undoes setup in + * the opposite order. Disposing twice does nothing, and anything added after the store + * is disposed is disposed at once rather than leaked. + */ +export class DisposableStore implements Disposable { + private items: Disposable[] = []; + private disposed = false; + + /** True once `dispose()` has run. */ + get isDisposed(): boolean { + return this.disposed; + } + + /** Takes ownership of `item` and returns it, so a store can wrap the expression that made it. */ + add<T extends Disposable>(item: T): T { + if (this.disposed) item.dispose(); + else this.items.push(item); + return item; + } + + /** Disposes everything added, newest first. A throwing item does not stop the others; the first error is rethrown at the end. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + const items = this.items; + this.items = []; + let failure: { error: unknown } | undefined; + for (let i = items.length - 1; i >= 0; i--) { + try { + items[i].dispose(); + } catch (error) { + failure ??= { error }; + } + } + if (failure) throw failure.error; + } +} + +/** Whether `options` asks for the capture phase, which is what removal must match. */ +function captureOf(options: boolean | AddEventListenerOptions | undefined): boolean { + return typeof options === 'boolean' ? options : options?.capture === true; +} + +/** + * Adds `listener` for `type` on `target` and returns the `Disposable` that removes it with + * the same capture flag (a listener added in the capture phase is only removed by a + * matching `capture`). Overloads type the event for windows, documents and elements. + */ +export function listen<K extends keyof WindowEventMap>( + target: Window, + type: K, + listener: (event: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): Disposable; +export function listen<K extends keyof DocumentEventMap>( + target: Document, + type: K, + listener: (event: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): Disposable; +export function listen<K extends keyof HTMLElementEventMap>( + target: HTMLElement, + type: K, + listener: (event: HTMLElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, +): Disposable; +export function listen( + target: ListenerTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, +): Disposable; +export function listen( + target: ListenerTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, +): Disposable { + target.addEventListener(type, listener, options); + const capture = captureOf(options); + return toDisposable(() => target.removeEventListener(type, listener, capture)); +} diff --git a/src/client/vendor/tests/lifecycle/disposable-store.test.ts b/src/client/vendor/tests/lifecycle/disposable-store.test.ts new file mode 100644 index 0000000..1556bf2 --- /dev/null +++ b/src/client/vendor/tests/lifecycle/disposable-store.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { RecordingTarget } from '../../../test-support/recording-target'; +import { + DisposableStore, + listen, + toDisposable, +} from '../../ghostty-web/lifecycle/disposable-store'; + +describe('toDisposable', () => { + test('runs its release once', () => { + let runs = 0; + const disposable = toDisposable(() => runs++); + disposable.dispose(); + disposable.dispose(); + expect(runs).toBe(1); + }); +}); + +describe('DisposableStore', () => { + test('disposes everything in reverse order of adding', () => { + const order: string[] = []; + const store = new DisposableStore(); + for (const name of ['a', 'b', 'c']) store.add(toDisposable(() => order.push(name))); + store.dispose(); + expect(order).toEqual(['c', 'b', 'a']); + }); + + test('a second dispose does nothing', () => { + let runs = 0; + const store = new DisposableStore(); + store.add(toDisposable(() => runs++)); + expect(store.isDisposed).toBe(false); + store.dispose(); + store.dispose(); + expect(runs).toBe(1); + expect(store.isDisposed).toBe(true); + }); + + test('add returns the item, and an item added after dispose is disposed at once', () => { + const store = new DisposableStore(); + const kept = { dispose() {} }; + expect(store.add(kept)).toBe(kept); + store.dispose(); + let late = 0; + store.add(toDisposable(() => late++)); + expect(late).toBe(1); + }); + + test('a throwing item does not stop the rest; the first error is rethrown', () => { + const order: string[] = []; + const store = new DisposableStore(); + store.add(toDisposable(() => order.push('first'))); + store.add({ + dispose() { + throw new Error('older'); + }, + }); + store.add({ + dispose() { + throw new Error('newest'); + }, + }); + expect(() => store.dispose()).toThrow('newest'); + expect(order).toEqual(['first']); + expect(() => store.dispose()).not.toThrow(); + }); +}); + +describe('listen', () => { + const fn = () => {}; + + test.each([ + ['no options', undefined, false], + ['capture true', true, true], + ['capture false', false, false], + ['{ capture: true }', { capture: true }, true], + ['{ passive: true }', { passive: true }, false], + ] as const)('removes with the same capture flag: %s', (_name, options, capture) => { + const target = new RecordingTarget('el'); + const disposable = listen(target, 'keydown', fn, options); + expect(target.added).toEqual([ + expect.objectContaining({ type: 'keydown', capture, listener: fn }), + ]); + disposable.dispose(); + expect(target.active).toEqual([]); + expect(target.removed).toEqual(target.added); + }); + + test('disposing twice removes once', () => { + const target = new RecordingTarget(); + const disposable = listen(target, 'blur', fn, true); + // A second listener with the other capture flag must survive both disposes. + listen(target, 'blur', fn, false); + disposable.dispose(); + disposable.dispose(); + expect(target.removed).toHaveLength(1); + expect(target.active).toEqual([expect.objectContaining({ type: 'blur', capture: false })]); + }); + + describe('on a real DOM', () => { + let dom: DomWindow; + afterEach(() => dom.close()); + + test('a capture listener stops hearing events once its store is disposed', () => { + dom = createDomWindow(); + const button = dom.document.createElement('button'); + dom.document.body.appendChild(button); + const heard: string[] = []; + const store = new DisposableStore(); + store.add(listen(dom.document, 'click', () => heard.push('document'), { capture: true })); + store.add(listen(button, 'click', () => heard.push('button'))); + button.click(); + expect(heard).toEqual(['document', 'button']); + store.dispose(); + button.click(); + expect(heard).toEqual(['document', 'button']); + }); + }); +}); From d63684c5b707ae259322d2f96eb44ba066f018f7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:07:04 -0400 Subject: [PATCH 152/295] refactor(server): findGhosttyWasm takes the project root from the entry The entry already resolves the package root; passing it in keeps the lookup correct when static.ts moves one folder deeper, since the bundled dist/server/index.js and the source file would no longer share a depth. --- src/server/index.ts | 2 +- src/server/static.test.ts | 10 ++++++---- src/server/static.ts | 7 +------ 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 24c1df0..31cb79a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -20,8 +20,8 @@ delete process.env.PORT; const HTTP_HOST_DISPLAY = config.host; const HTTP_HOST = config.host === 'localhost' ? '127.0.0.1' : config.host; -const wasmPath = findGhosttyWasm(); const projectRoot = path.resolve(__dirname, '..', '..'); +const wasmPath = findGhosttyWasm(projectRoot); const builtDistPath = path.join(projectRoot, 'dist'); const srcClientPath = path.join(projectRoot, 'src', 'client'); const clientDistPath = fs.existsSync(path.join(builtDistPath, 'client.html')) diff --git a/src/server/static.test.ts b/src/server/static.test.ts index 4895188..c149702 100644 --- a/src/server/static.test.ts +++ b/src/server/static.test.ts @@ -3,6 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { findGhosttyWasm, mimeType, serveFile } from './static'; +const PROJECT_ROOT = path.resolve(import.meta.dir, '..', '..'); + describe('mimeType', () => { test('returns correct type for known extensions', () => { expect(mimeType('index.html')).toBe('text/html'); @@ -33,7 +35,7 @@ describe('findGhosttyWasm', () => { test('prefers the wasm copied into dist/ by the build', () => { const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => String(p).endsWith(BUILT)); - expect(findGhosttyWasm().endsWith(BUILT)).toBe(true); + expect(findGhosttyWasm(PROJECT_ROOT)).toBe(path.join(PROJECT_ROOT, BUILT)); existsSpy.mockRestore(); }); @@ -43,8 +45,8 @@ describe('findGhosttyWasm', () => { const s = String(p); return s.endsWith(BUILT) ? false : realExistsSync(s); }); - const wasmPath = findGhosttyWasm(); - expect(wasmPath.endsWith(VENDORED)).toBe(true); + const wasmPath = findGhosttyWasm(PROJECT_ROOT); + expect(wasmPath).toBe(path.join(PROJECT_ROOT, VENDORED)); expect(realExistsSync(wasmPath)).toBe(true); existsSpy.mockRestore(); }); @@ -54,7 +56,7 @@ describe('findGhosttyWasm', () => { const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - findGhosttyWasm(); + findGhosttyWasm(PROJECT_ROOT); expect(exitSpy).toHaveBeenCalledWith(1); expect(errorSpy).toHaveBeenCalledWith('Error: Could not find ghostty-vt-small.wasm.'); diff --git a/src/server/static.ts b/src/server/static.ts index 057f510..80685f1 100644 --- a/src/server/static.ts +++ b/src/server/static.ts @@ -1,10 +1,6 @@ import fs from 'node:fs'; import type http from 'node:http'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); /** File extension → MIME type map used by {@link serveFile}. */ export const MIME_TYPES: Record<string, string> = { @@ -40,8 +36,7 @@ export const GHOSTTY_WASM = 'ghostty-vt-small.wasm'; * @returns The absolute path to the wasm file. * @throws Exits the process with code 1 if the wasm cannot be found. */ -export function findGhosttyWasm(): string { - const projectRoot = path.join(__dirname, '..', '..'); +export function findGhosttyWasm(projectRoot: string): string { const candidates = [ path.join(projectRoot, 'dist', GHOSTTY_WASM), path.join(projectRoot, 'src', 'client', 'vendor', 'ghostty-vt', GHOSTTY_WASM), From 9882e6436652dcd621b15c79b38c87f7e839deca Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:07:24 -0400 Subject: [PATCH 153/295] refactor(client): app code imports vendor code through the barrel only The barrel exports Glide, AT_ONCE, FRAME_MS, linear, easeOutCubic, Pace and Ease (glide.ts) and watchDevicePixelRatio (renderer.ts), so index.ts, smooth-scroll.ts and wheel-stream.ts import './vendor/ghostty-web' instead of deep paths (ADR 060). The three vendor-deep entries leave the ratchet allowlist. The bundle keeps its size byte for byte; only minified names move. --- scripts/architecture/ratchet-allowlist.ts | 6 ------ src/client/index.ts | 3 +-- src/client/smooth-scroll.ts | 3 +-- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/index.ts | 5 ++++- src/client/wheel-stream.ts | 2 +- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index cef72ef..cc1e0dc 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -9,7 +9,6 @@ const ENGINE_SPLIT = 'vendor engine split, ghostty.ts into engine/ (ADR 061)'; const VENDOR_TESTS = 'vendor tests split with their facades into vendor/tests/ (ADR 061, ADR 064)'; const CLIENT_ROOT = 'client composition root, index.ts an entry only (ADR 060)'; const KITTY_SPLIT = 'kitty graphics split into features/kitty/ (ADR 060)'; -const VENDOR_BARREL = 'barrel exports Glide, Pace and watchDevicePixelRatio (ADR 060)'; const SERVER_HARNESS = 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; @@ -56,11 +55,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'banned-name src/pty/types.ts': 'server track: pty/pty-process.ts (ADR 062)', 'banned-name src/client/vendor/ghostty-web/types.ts': 'V15: the vendor types.ts shim is removed (ADR 061)', - 'vendor-deep src/client/index.ts -> src/client/vendor/ghostty-web/renderer.ts': VENDOR_BARREL, - 'vendor-deep src/client/smooth-scroll.ts -> src/client/vendor/ghostty-web/glide.ts': - VENDOR_BARREL, - 'vendor-deep src/client/wheel-stream.ts -> src/client/vendor/ghostty-web/glide.ts': - VENDOR_BARREL, // Upstream's Node fallback in Ghostty.loadFromPath; tests run under Bun, where Bun.file // reads the wasm first, so engine/wasm-loader.ts can drop it. 'client-node src/client/vendor/ghostty-web/ghostty.ts -> fs/promises': ENGINE_SPLIT, diff --git a/src/client/index.ts b/src/client/index.ts index 8949825..12dbcf4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -12,8 +12,7 @@ import { isMacPlatform, shortcutFor } from './shortcuts'; import { SmoothScroll } from './smooth-scroll'; import { TabTitle } from './tab-title'; import { toTerminalTheme } from './theme'; -import { FitAddon, Ghostty, Terminal } from './vendor/ghostty-web'; -import { watchDevicePixelRatio } from './vendor/ghostty-web/renderer'; +import { FitAddon, Ghostty, Terminal, watchDevicePixelRatio } from './vendor/ghostty-web'; import { WheelStream } from './wheel-stream'; interface KeyboardBinding { diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts index 7c386dc..d403ee3 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/smooth-scroll.ts @@ -19,8 +19,7 @@ * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ -import type { Terminal } from './vendor/ghostty-web'; -import { Glide } from './vendor/ghostty-web/glide'; +import { Glide, type Terminal } from './vendor/ghostty-web'; import type { WheelStream } from './wheel-stream'; export interface ScrollSplit { diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 8896d6e..51dfa2a 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -46,7 +46,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`, `index.ts`: desktop notifications (ADR 056). The binding installs Ghostty's `DESKTOP_NOTIFICATION` callback when the new `GhosttyTerminalConfig.desktopNotifications` is on (default true): it copies the request's `title` (`''` for OSC 9) and `body` out as UTF-8, reading only what the struct's `size` covers. `TerminalEffects` gains `notifications`, every request since `readEffects`, in order, and `DesktopNotification` is exported. `Terminal` gains the `desktopNotifications` option, passed to the engine, and `onDesktopNotification`, fired for each. Upstream ignored OSC 9 and OSC 777. - `ghostty.ts`, `terminal.ts`, `selection-manager.ts`: OSC 133 prompt jumps and output selection (ADR 057). The binding adds `promptJumpRow` (Ghostty's viewport put on webtty's top row with `ROW`, moved with `GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`, read back with `GHOSTTY_TERMINAL_DATA_SCROLLBAR` and put back at the bottom; `scrollViewport` writes its value as an `isize`) and `selectOutput` (`ghostty_terminal_select_output`). `Terminal` adds `jumpToPrompt`, Ghostty's `jump_to_prompt`, scrolling the view with `scrollToLine`. A triple click with Ctrl or Cmd held selects the command's output instead of the line, as in Ghostty. Upstream had neither. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. -- `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`). +- `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `Glide`, `AT_ONCE`, `FRAME_MS`, `linear`, `easeOutCubic`, `Pace` and `Ease` (`glide.ts`), `watchDevicePixelRatio` (`renderer.ts`) and `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`), which the app imported by deep path or did not have. ## webtty-original files diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index 7632cfe..23e287b 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -50,6 +50,9 @@ export { UrlRegexProvider } from './providers/url-regex-provider'; export { LinkDetector } from './link-detector'; export type { ILink, ILinkProvider, IBufferCellPosition } from './types'; -// webtty's teardown (ADR 060, ADR 065) +// webtty's scrolling motion (ADR 045), DPR tracking (ADR 039) and teardown (ADR 065) +export { AT_ONCE, easeOutCubic, FRAME_MS, Glide, linear } from './glide'; +export type { Ease, Pace } from './glide'; +export { watchDevicePixelRatio } from './renderer'; export { DisposableStore, listen, toDisposable } from './lifecycle/disposable-store'; export type { Disposable, ListenerTarget } from './lifecycle/disposable-store'; diff --git a/src/client/wheel-stream.ts b/src/client/wheel-stream.ts index 62ae1f4..f636db5 100644 --- a/src/client/wheel-stream.ts +++ b/src/client/wheel-stream.ts @@ -17,7 +17,7 @@ * reverses. * - Trackpad deltas, a frame apart, move at once. */ -import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from './vendor/ghostty-web/glide'; +import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from './vendor/ghostty-web'; /** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ export const WHEEL_GLIDE_MS = 125; From b57a68e0353a5379cb5da0409ccd03fc61600c3b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:07:32 -0400 Subject: [PATCH 154/295] refactor(server): move static.ts to http/static-files.ts Its test moves with it. No logic change (ADR 062). --- src/server/{static.test.ts => http/static-files.test.ts} | 4 ++-- src/server/{static.ts => http/static-files.ts} | 2 ++ src/server/index.ts | 2 +- src/server/routes.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) rename src/server/{static.test.ts => http/static-files.test.ts} (96%) rename src/server/{static.ts => http/static-files.ts} (92%) diff --git a/src/server/static.test.ts b/src/server/http/static-files.test.ts similarity index 96% rename from src/server/static.test.ts rename to src/server/http/static-files.test.ts index c149702..3ace329 100644 --- a/src/server/static.test.ts +++ b/src/server/http/static-files.test.ts @@ -1,9 +1,9 @@ import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { findGhosttyWasm, mimeType, serveFile } from './static'; +import { findGhosttyWasm, mimeType, serveFile } from './static-files'; -const PROJECT_ROOT = path.resolve(import.meta.dir, '..', '..'); +const PROJECT_ROOT = path.resolve(import.meta.dir, '..', '..', '..'); describe('mimeType', () => { test('returns correct type for known extensions', () => { diff --git a/src/server/static.ts b/src/server/http/static-files.ts similarity index 92% rename from src/server/static.ts rename to src/server/http/static-files.ts index 80685f1..a1f66a0 100644 --- a/src/server/static.ts +++ b/src/server/http/static-files.ts @@ -1,3 +1,5 @@ +// Static files for the HTTP server: MIME types, the wasm lookup and serveFile (ADR 062). +// A small service module; the caller passes the package root and file paths in. import fs from 'node:fs'; import type http from 'node:http'; import path from 'node:path'; diff --git a/src/server/index.ts b/src/server/index.ts index 31cb79a..8ac2956 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,8 +3,8 @@ import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config'; +import { findGhosttyWasm } from './http/static-files'; import { handleRequest } from './routes'; -import { findGhosttyWasm } from './static'; import { closeAllSessions, createWebSocketServer, setLastSessionClosedHandler } from './websocket'; const __filename = fileURLToPath(import.meta.url); diff --git a/src/server/routes.ts b/src/server/routes.ts index 410f755..a2b19b8 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -5,6 +5,7 @@ import { homedir } from 'node:os'; import path from 'node:path'; import { loadConfig, toClientConfig } from '../config'; import { getVersion } from '../version'; +import { GHOSTTY_WASM, serveFile } from './http/static-files'; import { createSession, generateId, @@ -14,7 +15,6 @@ import { sessionToJson, setLastUsedId, } from './session'; -import { GHOSTTY_WASM, serveFile } from './static'; import { broadcastToSubscribers, closeSession } from './websocket'; const MAX_BODY = 64 * 1024; From 571b28723c54044c9aa58b586e275a3d300cc3aa Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:08:01 -0400 Subject: [PATCH 155/295] refactor(server): move WS_CLOSE into ws/close-codes.ts A test pins 1001, 1008, 4001, 4002 and 4003. No logic change (ADR 062). --- src/server/websocket.ts | 10 +--------- src/server/ws/close-codes.test.ts | 14 ++++++++++++++ src/server/ws/close-codes.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 src/server/ws/close-codes.test.ts create mode 100644 src/server/ws/close-codes.ts diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 3fb489a..e57084b 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -4,17 +4,9 @@ import { WebSocketServer } from 'ws'; import { loadConfig } from '../config'; import { spawnForSession } from '../pty'; import { packageRunner } from '../runtime'; -import { FELL_BEHIND } from './output-pacer'; import type { Session } from './session'; import { sessionRegistry, setLastUsedId } from './session'; - -const WS_CLOSE = { - SERVER_STOPPED: 1001 as const, // RFC 6455: server going away - BAD_REQUEST: 1008 as const, // RFC 6455: policy violation / bad data - SESSION_GONE: 4001 as const, // app-level: session deleted or shell exited - PTY_NOT_RUNNING: 4002 as const, // app-level: PTY not running - FELL_BEHIND, // app-level: client fell too far behind the output (ADR 048) -} as const; +import { WS_CLOSE } from './ws/close-codes'; function closeClients(session: Session, code: number, reason: string): void { session.pty?.kill(); diff --git a/src/server/ws/close-codes.test.ts b/src/server/ws/close-codes.test.ts new file mode 100644 index 0000000..88a75af --- /dev/null +++ b/src/server/ws/close-codes.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test'; +import { WS_CLOSE } from './close-codes'; + +describe('WS_CLOSE', () => { + test('pins the wire values the client depends on', () => { + expect(WS_CLOSE).toEqual({ + SERVER_STOPPED: 1001, + BAD_REQUEST: 1008, + SESSION_GONE: 4001, + PTY_NOT_RUNNING: 4002, + FELL_BEHIND: 4003, + }); + }); +}); diff --git a/src/server/ws/close-codes.ts b/src/server/ws/close-codes.ts new file mode 100644 index 0000000..cec5ee1 --- /dev/null +++ b/src/server/ws/close-codes.ts @@ -0,0 +1,12 @@ +// The WebSocket close codes the server sends (ADR 048, ADR 062). Constants only; the +// client mirrors the ones it acts on and a contract test pins the two together. +import { FELL_BEHIND } from '../output-pacer'; + +/** Close codes by meaning, so call sites say why a socket closed rather than a number. */ +export const WS_CLOSE = { + SERVER_STOPPED: 1001 as const, // RFC 6455: server going away + BAD_REQUEST: 1008 as const, // RFC 6455: policy violation / bad data + SESSION_GONE: 4001 as const, // app-level: session deleted or shell exited + PTY_NOT_RUNNING: 4002 as const, // app-level: PTY not running + FELL_BEHIND, // app-level: client fell too far behind the output (ADR 048) +} as const; From 0e348d0c0a24d0af98b5bc4f6894e908d4fcaf86 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:12:30 -0400 Subject: [PATCH 156/295] test(server): the server harness self-tests sit beside test-support/ A test-support folder holds no *.test.ts (ADR 064), as the architecture checker enforces, so src/test-support/server-harness.test.ts moves to src/test-support.test.ts, the place the client track uses for its fakes' self-tests. --- .../server-harness.test.ts => test-support.test.ts} | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) rename src/{test-support/server-harness.test.ts => test-support.test.ts} (72%) diff --git a/src/test-support/server-harness.test.ts b/src/test-support.test.ts similarity index 72% rename from src/test-support/server-harness.test.ts rename to src/test-support.test.ts index d8c0782..9cc8815 100644 --- a/src/test-support/server-harness.test.ts +++ b/src/test-support.test.ts @@ -1,7 +1,9 @@ +// Self-tests of the server test harness, kept beside test-support/ because a +// test-support folder holds no *.test.ts (ADR 064). import { describe, expect, test } from 'bun:test'; -import { waitForServer, waitForServerDown } from './server-harness'; +import { waitForServer, waitForServerDown } from './test-support/server-harness'; -describe('test-helpers', () => { +describe('server-harness', () => { test('waitForServer throws when server never responds', async () => { await expect(waitForServer('http://127.0.0.1:1', 50)).rejects.toThrow( 'Server did not start in time', From 11a8cf59b8f0d669da1923efd3fa57b9f2fc3017 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:12:46 -0400 Subject: [PATCH 157/295] test(server): restore fetch even when the harness shut-down test fails The stubbed globalThis.fetch is put back in a finally block, so a failing assertion cannot leak the stub into later tests. --- src/test-support.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/test-support.test.ts b/src/test-support.test.ts index 9cc8815..c4b675e 100644 --- a/src/test-support.test.ts +++ b/src/test-support.test.ts @@ -13,9 +13,12 @@ describe('server-harness', () => { test('waitForServerDown throws when server keeps responding', async () => { const realFetch = globalThis.fetch; globalThis.fetch = (async () => new Response('[]', { status: 200 })) as unknown as typeof fetch; - await expect(waitForServerDown('http://127.0.0.1:1', 50)).rejects.toThrow( - 'Server did not shut down in time', - ); - globalThis.fetch = realFetch; + try { + await expect(waitForServerDown('http://127.0.0.1:1', 50)).rejects.toThrow( + 'Server did not shut down in time', + ); + } finally { + globalThis.fetch = realFetch; + } }); }); From dec8482a5e40b859256f537c1cfdd4e070601c8c Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:13:32 -0400 Subject: [PATCH 158/295] refactor(client): move the existing app modules into their folders Pure git mv into the SPEC tree (ADR 060), with only import paths changed: connection/ (output-ack, replay-window), layout/ (fit-insets, edge-extender), app/terminal-theme, and features/ (keyboard/shortcut-map, clipboard/clipboard-writer, compose/composer, tab-title, notifications/ desktop-notifier, paste/non-text-paste, scroll/, find/, kitty/). Tests move with their modules; the test count is unchanged. The ratchet allowlist follows the moves: the kitty size entries take their new paths, the fit.ts duplicate-basename entries go, index.ts is listed at 568 lines (its longer import paths), and find-bar -> shortcut-map is listed until the find keys leave find-bar.ts. --- scripts/architecture/ratchet-allowlist.ts | 13 ++++--- .../terminal-theme.test.ts} | 2 +- .../{theme.ts => app/terminal-theme.ts} | 0 .../output-ack.test.ts} | 2 +- .../{ack.ts => connection/output-ack.ts} | 0 .../replay-window.test.ts} | 4 +-- .../replay-window.ts} | 0 .../clipboard/clipboard-writer.test.ts} | 2 +- .../clipboard/clipboard-writer.ts} | 0 .../compose/composer.ts} | 2 +- .../{ => features/find}/find-bar.test.ts | 0 src/client/{ => features/find}/find-bar.ts | 2 +- .../keyboard/shortcut-map.test.ts} | 2 +- .../keyboard/shortcut-map.ts} | 0 .../{ => features/kitty}/graphics.test.ts | 0 src/client/{ => features/kitty}/graphics.ts | 2 +- src/client/{ => features/kitty}/kitty.test.ts | 0 src/client/{ => features/kitty}/kitty.ts | 0 .../notifications/desktop-notifier.test.ts} | 2 +- .../notifications/desktop-notifier.ts} | 0 .../paste/non-text-paste.test.ts} | 2 +- .../paste/non-text-paste.ts} | 0 .../scroll}/smooth-scroll.test.ts | 2 +- .../{ => features/scroll}/smooth-scroll.ts | 2 +- .../scroll}/wheel-stream.test.ts | 0 .../{ => features/scroll}/wheel-stream.ts | 2 +- .../tab-title}/tab-title.test.ts | 0 .../{ => features/tab-title}/tab-title.ts | 0 src/client/index.ts | 34 +++++++++++-------- .../edge-extender.test.ts} | 2 +- .../{edge.ts => layout/edge-extender.ts} | 0 .../fit-insets.test.ts} | 2 +- src/client/{fit.ts => layout/fit-insets.ts} | 0 33 files changed, 41 insertions(+), 38 deletions(-) rename src/client/{theme.test.ts => app/terminal-theme.test.ts} (97%) rename src/client/{theme.ts => app/terminal-theme.ts} (100%) rename src/client/{ack.test.ts => connection/output-ack.test.ts} (97%) rename src/client/{ack.ts => connection/output-ack.ts} (100%) rename src/client/{replay.test.ts => connection/replay-window.test.ts} (96%) rename src/client/{replay.ts => connection/replay-window.ts} (100%) rename src/client/{clipboard-write.test.ts => features/clipboard/clipboard-writer.test.ts} (97%) rename src/client/{clipboard-write.ts => features/clipboard/clipboard-writer.ts} (100%) rename src/client/{compose.ts => features/compose/composer.ts} (98%) rename src/client/{ => features/find}/find-bar.test.ts (100%) rename src/client/{ => features/find}/find-bar.ts (99%) rename src/client/{shortcuts.test.ts => features/keyboard/shortcut-map.test.ts} (99%) rename src/client/{shortcuts.ts => features/keyboard/shortcut-map.ts} (100%) rename src/client/{ => features/kitty}/graphics.test.ts (100%) rename src/client/{ => features/kitty}/graphics.ts (99%) rename src/client/{ => features/kitty}/kitty.test.ts (100%) rename src/client/{ => features/kitty}/kitty.ts (100%) rename src/client/{notifications.test.ts => features/notifications/desktop-notifier.test.ts} (99%) rename src/client/{notifications.ts => features/notifications/desktop-notifier.ts} (100%) rename src/client/{paste.test.ts => features/paste/non-text-paste.test.ts} (89%) rename src/client/{paste.ts => features/paste/non-text-paste.ts} (100%) rename src/client/{ => features/scroll}/smooth-scroll.test.ts (99%) rename src/client/{ => features/scroll}/smooth-scroll.ts (98%) rename src/client/{ => features/scroll}/wheel-stream.test.ts (100%) rename src/client/{ => features/scroll}/wheel-stream.ts (99%) rename src/client/{ => features/tab-title}/tab-title.test.ts (100%) rename src/client/{ => features/tab-title}/tab-title.ts (100%) rename src/client/{edge.test.ts => layout/edge-extender.test.ts} (96%) rename src/client/{edge.ts => layout/edge-extender.ts} (100%) rename src/client/{fit.test.ts => layout/fit-insets.test.ts} (95%) rename src/client/{fit.ts => layout/fit-insets.ts} (100%) diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index cc1e0dc..f6963fa 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -20,9 +20,10 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'scripts/update-ghostty-vt.ts': { lines: 360, step: 'scripts/ghostty-vt/ split (ADR 059)' }, 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/graphics.ts': { lines: 662, step: KITTY_SPLIT }, - 'src/client/index.ts': { lines: 565, step: CLIENT_ROOT }, - 'src/client/kitty.ts': { lines: 384, step: KITTY_SPLIT }, + 'src/client/features/kitty/graphics.ts': { lines: 662, step: KITTY_SPLIT }, + 'src/client/features/kitty/kitty.ts': { lines: 384, step: KITTY_SPLIT }, + // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. + 'src/client/index.ts': { lines: 568, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, @@ -58,9 +59,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { // Upstream's Node fallback in Ghostty.loadFromPath; tests run under Bun, where Bun.file // reads the wasm first, so engine/wasm-loader.ts can drop it. 'client-node src/client/vendor/ghostty-web/ghostty.ts -> fs/promises': ENGINE_SPLIT, - 'duplicate-basename src/client/fit.ts': - 'client layout/: fit.ts becomes fit-insets.ts (ADR 060)', - 'duplicate-basename src/client/vendor/ghostty-web/addons/fit.ts': - 'client layout/: fit.ts becomes fit-insets.ts (ADR 060)', + 'feature-feature src/client/features/find/find-bar.ts -> src/client/features/keyboard/shortcut-map.ts': + 'C4: the find keys move out of find-bar.ts, so features/find no longer imports features/keyboard (ADR 060)', }, }; diff --git a/src/client/theme.test.ts b/src/client/app/terminal-theme.test.ts similarity index 97% rename from src/client/theme.test.ts rename to src/client/app/terminal-theme.test.ts index 864c9d2..24997e5 100644 --- a/src/client/theme.test.ts +++ b/src/client/app/terminal-theme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { toTerminalTheme } from './theme'; +import { toTerminalTheme } from './terminal-theme'; describe('toTerminalTheme', () => { test('maps purple and brightPurple onto ghostty-web magenta names', () => { diff --git a/src/client/theme.ts b/src/client/app/terminal-theme.ts similarity index 100% rename from src/client/theme.ts rename to src/client/app/terminal-theme.ts diff --git a/src/client/ack.test.ts b/src/client/connection/output-ack.test.ts similarity index 97% rename from src/client/ack.test.ts rename to src/client/connection/output-ack.test.ts index 123ce92..8793b3d 100644 --- a/src/client/ack.test.ts +++ b/src/client/connection/output-ack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { ACK_EVERY, ACK_IDLE_MS, OutputAck } from './ack'; +import { ACK_EVERY, ACK_IDLE_MS, OutputAck } from './output-ack'; function setup() { const sent: { type: string; chars: number }[] = []; diff --git a/src/client/ack.ts b/src/client/connection/output-ack.ts similarity index 100% rename from src/client/ack.ts rename to src/client/connection/output-ack.ts diff --git a/src/client/replay.test.ts b/src/client/connection/replay-window.test.ts similarity index 96% rename from src/client/replay.test.ts rename to src/client/connection/replay-window.test.ts index 8fa2f5b..74c8b16 100644 --- a/src/client/replay.test.ts +++ b/src/client/connection/replay-window.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_FLOW_LIMITS } from '../server/flow'; -import { REPLAY_PIECE, REPLAY_QUIET_MS, ReplayWindow } from './replay'; +import { DEFAULT_FLOW_LIMITS } from '../../server/flow'; +import { REPLAY_PIECE, REPLAY_QUIET_MS, ReplayWindow } from './replay-window'; describe('ReplayWindow', () => { test('is replay from the connect through its last piece', () => { diff --git a/src/client/replay.ts b/src/client/connection/replay-window.ts similarity index 100% rename from src/client/replay.ts rename to src/client/connection/replay-window.ts diff --git a/src/client/clipboard-write.test.ts b/src/client/features/clipboard/clipboard-writer.test.ts similarity index 97% rename from src/client/clipboard-write.test.ts rename to src/client/features/clipboard/clipboard-writer.test.ts index e6edb35..3b41cdc 100644 --- a/src/client/clipboard-write.test.ts +++ b/src/client/features/clipboard/clipboard-writer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { ClipboardWriter } from './clipboard-write'; +import { ClipboardWriter } from './clipboard-writer'; function setup(clipboard: 'ok' | 'refuse' | 'none' = 'ok') { const written: string[] = []; diff --git a/src/client/clipboard-write.ts b/src/client/features/clipboard/clipboard-writer.ts similarity index 100% rename from src/client/clipboard-write.ts rename to src/client/features/clipboard/clipboard-writer.ts diff --git a/src/client/compose.ts b/src/client/features/compose/composer.ts similarity index 98% rename from src/client/compose.ts rename to src/client/features/compose/composer.ts index 1d605a8..3d664c8 100644 --- a/src/client/compose.ts +++ b/src/client/features/compose/composer.ts @@ -8,7 +8,7 @@ * ghostty-web's own compositionend handling. */ -import type { Terminal } from './vendor/ghostty-web'; +import type { Terminal } from '../../vendor/ghostty-web'; // ghostty-web's defaults for a theme without foreground/background. const DEFAULT_FOREGROUND = '#d4d4d4'; diff --git a/src/client/find-bar.test.ts b/src/client/features/find/find-bar.test.ts similarity index 100% rename from src/client/find-bar.test.ts rename to src/client/features/find/find-bar.test.ts diff --git a/src/client/find-bar.ts b/src/client/features/find/find-bar.ts similarity index 99% rename from src/client/find-bar.ts rename to src/client/features/find/find-bar.ts index f734d4c..13d06e0 100644 --- a/src/client/find-bar.ts +++ b/src/client/features/find/find-bar.ts @@ -9,7 +9,7 @@ * previous match, Escape closes. The count is Ghostty's `selected/total`: `0/n` until a * match is selected. */ -import { shortcutFor } from './shortcuts'; +import { shortcutFor } from '../keyboard/shortcut-map'; /** The match count and the selected match's index (0 the newest), as the terminal reports them. */ export interface FindState { diff --git a/src/client/shortcuts.test.ts b/src/client/features/keyboard/shortcut-map.test.ts similarity index 99% rename from src/client/shortcuts.test.ts rename to src/client/features/keyboard/shortcut-map.test.ts index f2f7a56..e943f3f 100644 --- a/src/client/shortcuts.test.ts +++ b/src/client/features/keyboard/shortcut-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { isMacPlatform, type Shortcut, shortcutFor } from './shortcuts'; +import { isMacPlatform, type Shortcut, shortcutFor } from './shortcut-map'; type Mod = 'ctrl' | 'shift' | 'alt' | 'meta'; const key = (code: string, mods: Mod[] = []) => ({ diff --git a/src/client/shortcuts.ts b/src/client/features/keyboard/shortcut-map.ts similarity index 100% rename from src/client/shortcuts.ts rename to src/client/features/keyboard/shortcut-map.ts diff --git a/src/client/graphics.test.ts b/src/client/features/kitty/graphics.test.ts similarity index 100% rename from src/client/graphics.test.ts rename to src/client/features/kitty/graphics.test.ts diff --git a/src/client/graphics.ts b/src/client/features/kitty/graphics.ts similarity index 99% rename from src/client/graphics.ts rename to src/client/features/kitty/graphics.ts index e2db933..3a0241c 100644 --- a/src/client/graphics.ts +++ b/src/client/features/kitty/graphics.ts @@ -1,3 +1,4 @@ +import type { Terminal } from '../../vendor/ghostty-web'; import { ApcSplitter, axisScale, @@ -13,7 +14,6 @@ import { pngSizeFromBytes, type Transmission, } from './kitty'; -import type { Terminal } from './vendor/ghostty-web'; // Sizes Ghostty reports to programs (CSI 14t/16t) are device pixels, as in kitty: // programs such as terminal-browser divide them by the display's diff --git a/src/client/kitty.test.ts b/src/client/features/kitty/kitty.test.ts similarity index 100% rename from src/client/kitty.test.ts rename to src/client/features/kitty/kitty.test.ts diff --git a/src/client/kitty.ts b/src/client/features/kitty/kitty.ts similarity index 100% rename from src/client/kitty.ts rename to src/client/features/kitty/kitty.ts diff --git a/src/client/notifications.test.ts b/src/client/features/notifications/desktop-notifier.test.ts similarity index 99% rename from src/client/notifications.test.ts rename to src/client/features/notifications/desktop-notifier.test.ts index 545fd54..edd2fca 100644 --- a/src/client/notifications.test.ts +++ b/src/client/features/notifications/desktop-notifier.test.ts @@ -5,7 +5,7 @@ import { NOTIFY_INTERVAL_MS, NOTIFY_REPEAT_MS, type NotificationApi, -} from './notifications'; +} from './desktop-notifier'; interface Shown { title: string; diff --git a/src/client/notifications.ts b/src/client/features/notifications/desktop-notifier.ts similarity index 100% rename from src/client/notifications.ts rename to src/client/features/notifications/desktop-notifier.ts diff --git a/src/client/paste.test.ts b/src/client/features/paste/non-text-paste.test.ts similarity index 89% rename from src/client/paste.test.ts rename to src/client/features/paste/non-text-paste.test.ts index 3382930..521b179 100644 --- a/src/client/paste.test.ts +++ b/src/client/features/paste/non-text-paste.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { nonTextPaste } from './paste'; +import { nonTextPaste } from './non-text-paste'; describe('nonTextPaste', () => { test('forwards Ctrl+V for an image-only clipboard (empty text/plain)', () => { diff --git a/src/client/paste.ts b/src/client/features/paste/non-text-paste.ts similarity index 100% rename from src/client/paste.ts rename to src/client/features/paste/non-text-paste.ts diff --git a/src/client/smooth-scroll.test.ts b/src/client/features/scroll/smooth-scroll.test.ts similarity index 99% rename from src/client/smooth-scroll.test.ts rename to src/client/features/scroll/smooth-scroll.test.ts index 872549c..558b57e 100644 --- a/src/client/smooth-scroll.test.ts +++ b/src/client/features/scroll/smooth-scroll.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; +import type { Terminal } from '../../vendor/ghostty-web'; import { SmoothScroll, splitScroll, wheelLines } from './smooth-scroll'; -import type { Terminal } from './vendor/ghostty-web'; import { WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; describe('splitScroll', () => { diff --git a/src/client/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts similarity index 98% rename from src/client/smooth-scroll.ts rename to src/client/features/scroll/smooth-scroll.ts index d403ee3..094fbf6 100644 --- a/src/client/smooth-scroll.ts +++ b/src/client/features/scroll/smooth-scroll.ts @@ -19,7 +19,7 @@ * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ -import { Glide, type Terminal } from './vendor/ghostty-web'; +import { Glide, type Terminal } from '../../vendor/ghostty-web'; import type { WheelStream } from './wheel-stream'; export interface ScrollSplit { diff --git a/src/client/wheel-stream.test.ts b/src/client/features/scroll/wheel-stream.test.ts similarity index 100% rename from src/client/wheel-stream.test.ts rename to src/client/features/scroll/wheel-stream.test.ts diff --git a/src/client/wheel-stream.ts b/src/client/features/scroll/wheel-stream.ts similarity index 99% rename from src/client/wheel-stream.ts rename to src/client/features/scroll/wheel-stream.ts index f636db5..899b87e 100644 --- a/src/client/wheel-stream.ts +++ b/src/client/features/scroll/wheel-stream.ts @@ -17,7 +17,7 @@ * reverses. * - Trackpad deltas, a frame apart, move at once. */ -import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from './vendor/ghostty-web'; +import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from '../../vendor/ghostty-web'; /** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ export const WHEEL_GLIDE_MS = 125; diff --git a/src/client/tab-title.test.ts b/src/client/features/tab-title/tab-title.test.ts similarity index 100% rename from src/client/tab-title.test.ts rename to src/client/features/tab-title/tab-title.test.ts diff --git a/src/client/tab-title.ts b/src/client/features/tab-title/tab-title.ts similarity index 100% rename from src/client/tab-title.ts rename to src/client/features/tab-title/tab-title.ts diff --git a/src/client/index.ts b/src/client/index.ts index 12dbcf4..8c652ce 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,19 +1,23 @@ -import { OutputAck } from './ack'; -import { ClipboardWriter } from './clipboard-write'; -import { Composer } from './compose'; -import { EdgeExtender } from './edge'; -import { FindBar } from './find-bar'; -import { computeInsets } from './fit'; -import { KittyGraphics } from './graphics'; -import { activates, browserNotifications, DesktopNotifier } from './notifications'; -import { nonTextPaste } from './paste'; -import { ReplayWindow } from './replay'; -import { isMacPlatform, shortcutFor } from './shortcuts'; -import { SmoothScroll } from './smooth-scroll'; -import { TabTitle } from './tab-title'; -import { toTerminalTheme } from './theme'; +import { toTerminalTheme } from './app/terminal-theme'; +import { OutputAck } from './connection/output-ack'; +import { ReplayWindow } from './connection/replay-window'; +import { ClipboardWriter } from './features/clipboard/clipboard-writer'; +import { Composer } from './features/compose/composer'; +import { FindBar } from './features/find/find-bar'; +import { isMacPlatform, shortcutFor } from './features/keyboard/shortcut-map'; +import { KittyGraphics } from './features/kitty/graphics'; +import { + activates, + browserNotifications, + DesktopNotifier, +} from './features/notifications/desktop-notifier'; +import { nonTextPaste } from './features/paste/non-text-paste'; +import { SmoothScroll } from './features/scroll/smooth-scroll'; +import { WheelStream } from './features/scroll/wheel-stream'; +import { TabTitle } from './features/tab-title/tab-title'; +import { EdgeExtender } from './layout/edge-extender'; +import { computeInsets } from './layout/fit-insets'; import { FitAddon, Ghostty, Terminal, watchDevicePixelRatio } from './vendor/ghostty-web'; -import { WheelStream } from './wheel-stream'; interface KeyboardBinding { key: string; diff --git a/src/client/edge.test.ts b/src/client/layout/edge-extender.test.ts similarity index 96% rename from src/client/edge.test.ts rename to src/client/layout/edge-extender.test.ts index 8a99b99..0466e25 100644 --- a/src/client/edge.test.ts +++ b/src/client/layout/edge-extender.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { computeEdgeRects } from './edge'; +import { computeEdgeRects } from './edge-extender'; const canvas = { left: 0, top: 0, width: 800, height: 400 }; diff --git a/src/client/edge.ts b/src/client/layout/edge-extender.ts similarity index 100% rename from src/client/edge.ts rename to src/client/layout/edge-extender.ts diff --git a/src/client/fit.test.ts b/src/client/layout/fit-insets.test.ts similarity index 95% rename from src/client/fit.test.ts rename to src/client/layout/fit-insets.test.ts index e311141..ecb6c9c 100644 --- a/src/client/fit.test.ts +++ b/src/client/layout/fit-insets.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { computeInsets } from './fit'; +import { computeInsets } from './fit-insets'; describe('computeInsets', () => { test('with no padding, top and left are flush and the leftover goes right and bottom', () => { diff --git a/src/client/fit.ts b/src/client/layout/fit-insets.ts similarity index 100% rename from src/client/fit.ts rename to src/client/layout/fit-insets.ts From fa49aa9c2234bb2b6fa5e822f5c535b26b49c705 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:13:49 -0400 Subject: [PATCH 159/295] docs(arch): client and kitty specs name the moved module paths --- docs/specs/client.md | 14 +++++++------- docs/specs/kitty-graphics.md | 15 ++++++++------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index 89734b6..39cafb8 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -191,7 +191,7 @@ Ghostty's default copy, paste and clear-screen keys, for the browser's platform | others | Shift+Insert | paste the clipboard (a page cannot read the primary selection) | | | all | Ctrl+V, also with Shift or Alt | paste (ADR 014) | | -Ctrl+C is always the app's. Modifiers match exactly except for Ctrl+V. `shortcuts.ts` maps the `keydown` (`shortcutFor`); one capture listener on the container in `index.ts`, after the `keyboardBindings` listener, performs it and steps aside for any combo `keyboardBindings` has, so a binding (even `"chars": ""`) wins. A paste reads `navigator.clipboard.readText` (a failed read sends Ctrl+V); without the API the key is kept from the app and the browser's own paste may fire the `paste` event. +Ctrl+C is always the app's. Modifiers match exactly except for Ctrl+V. `features/keyboard/shortcut-map.ts` maps the `keydown` (`shortcutFor`); one capture listener on the container in `index.ts`, after the `keyboardBindings` listener, performs it and steps aside for any combo `keyboardBindings` has, so a binding (even `"chars": ""`) wins. A paste reads `navigator.clipboard.readText` (a failed read sends Ctrl+V); without the API the key is kept from the app and the browser's own paste may fire the `paste` event. **Cmd+K** is Ghostty's `clear_screen`, done by `Terminal.clearScreen()` with VT sequences written to the terminal, never the PTY: @@ -216,13 +216,13 @@ The canvas is invisible to the browser's own find, so webtty has Ghostty's searc | all | Escape | close | the app gets the key (bar closed) | | in the field | Enter / Shift+Enter | next / previous match | | -Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `find-bar.ts`). +Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `features/find/find-bar.ts`). - **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. - **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). - **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows, except on the alternate screen. A key or a paste sent to the program scrolls to the bottom, as in Ghostty (`scroll-to-bottom = keystroke, no-output`); mouse reports do not. - **Highlights**: every match in `searchBackground` / `searchForeground`, the selected one in `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults); the selection is drawn over both. -- **The bar** (`find-bar.ts`) is a DOM overlay at the top right, outside `#terminal`, coloured from the theme, with a labelled field, the count in a live region, ↑ (next), ↓ (previous) and × buttons. Typing searches after 150 ms; closing ends the search, clears the highlights and refocuses the terminal, and the field keeps its text for the next open. +- **The bar** (`features/find/find-bar.ts`) is a DOM overlay at the top right, outside `#terminal`, coloured from the theme, with a labelled field, the count in a live region, ↑ (next), ↓ (previous) and × buttons. Typing searches after 150 ms; closing ends the search, clears the highlights and refocuses the terminal, and the field keeps its text for the next open. `Terminal` (vendored) keeps the needle (also across `reset`), runs the search for at most 4 ms a frame before each render, and reads the matches again when they changed, at most every ten times the last read took; the renderer finds the matches in view by binary search on the newest-to-oldest list, and repaints a row only when its matches change. Find has no side effects, so the replay on connect needs no special handling. @@ -294,15 +294,15 @@ Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), | `52;c;?` (query) | no answer, ever | | invalid base64, or a sequence over 8 MiB (about 6 MiB of text) | ignored whole; missing padding is accepted | -`readEffects` reports the last write since the previous call, `Terminal.onClipboardWrite` fires with it, and `index.ts` passes it to `ClipboardWriter` (`clipboard-write.ts`), which calls `navigator.clipboard.writeText`. After each (re)connect it ignores writes until a `keydown`, `mousedown`, `paste` or `drop` in the tab, so the replayed history does not overwrite the clipboard on reload. A missing clipboard API (http other than localhost), a refused write (no focus in Chrome, no recent input in Firefox, most writes in Safari) and a write held back for the replay are each logged to the console once; there is no error UI. +`readEffects` reports the last write since the previous call, `Terminal.onClipboardWrite` fires with it, and `index.ts` passes it to `ClipboardWriter` (`features/clipboard/clipboard-writer.ts`), which calls `navigator.clipboard.writeText`. After each (re)connect it ignores writes until a `keydown`, `mousedown`, `paste` or `drop` in the tab, so the replayed history does not overwrite the clipboard on reload. A missing clipboard API (http other than localhost), a refused write (no focus in Chrome, no recent input in Firefox, most writes in Safari) and a write held back for the replay are each logged to the console once; there is no error UI. ## Focus Reports, Title and Bell Programs hear focus changes, name the tab and ring the bell as in Ghostty ([ADR 053](../adrs/053.client.focus-reports-title-and-bell.md)): - **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Each socket that opens with the mode still on (a reconnect without a reset) sends the current state too (`resyncFocus`), since reports while it was down were dropped. Nothing is sent with `disableStdin`. -- **Title.** `TabTitle` (`tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. -- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. `ReplayWindow` (`replay.ts`) keeps that state, fed by `index.ts` around each message, for the bell and for desktop notifications. +- **Title.** `TabTitle` (`features/tab-title/tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. +- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. `ReplayWindow` (`connection/replay-window.ts`) keeps that state, fed by `index.ts` around each message, for the bell and for desktop notifications. ## Desktop Notifications @@ -316,7 +316,7 @@ Programs ask for desktop notifications with OSC 9 (`ESC ] 9 ; <body> BEL`, iTerm | `9;4`, `9;42 tests passed` (not a full ConEmu form) | a notification, as in Ghostty | | `777;<other>;…`, `777;notify;<title>` (no body) | nothing | -`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and `index.ts` passes it to `DesktopNotifier` (`notifications.ts`), which, as Ghostty on macOS: +`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and `index.ts` passes it to `DesktopNotifier` (`features/notifications/desktop-notifier.ts`), which, as Ghostty on macOS: - drops it while `ReplayWindow` says the output is replay; - shows at most one a second, and the same title and body at most once in five seconds; diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index 95556e1..d93d32d 100644 --- a/docs/specs/kitty-graphics.md +++ b/docs/specs/kitty-graphics.md @@ -24,7 +24,7 @@ the rejected alternatives. ``` PTY ──► server ──► WebSocket ──► ws.onmessage │ - KittyGraphics.feed(data) (src/client/kitty.ts splitter) + KittyGraphics.feed(data) (features/kitty/kitty.ts) │ │ text segments APC commands │ │ @@ -32,7 +32,7 @@ PTY ──► server ──► WebSocket ──► ws.onmessage ▲ │ │ └─ cursor ───┘ ▼ advance / reply overlay canvas - (src/client/graphics.ts) + (features/kitty/graphics.ts) replies (a=q OK) ──► ws.send ──► PTY ``` @@ -125,11 +125,12 @@ reuses the same image id. ``` src/client/ - kitty.ts ← pure: APC splitter, key=value parser, kitty reply builder, clear scanner (unit tested) - kitty.test.ts - graphics.ts ← browser: decode, placement table, overlay canvas, scroll tracking - graphics.test.ts ← state handling against a stubbed DOM and image decoder - index.ts ← wires ws.onmessage through KittyGraphics + features/kitty/ + kitty.ts ← pure: APC splitter, key=value parser, kitty reply builder, clear scanner (unit tested) + kitty.test.ts + graphics.ts ← browser: decode, placement table, overlay canvas, scroll tracking + graphics.test.ts ← state handling against a stubbed DOM and image decoder + index.ts ← wires ws.onmessage through KittyGraphics ``` ## Known limitations From 8a24b28bd0f6fc27b175b9af49e67b78e3f55192 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:14:55 -0400 Subject: [PATCH 160/295] refactor(server): session IDs in sessions/session-id.ts ID_RE, isValidId and generateId move out of session.ts into a pure module under sessions/ (ADR 062); routes.ts imports them from there. Their test cases move with them to sessions/session-id.test.ts, and the createSession, sessionToJson and setLastUsedId cases stay in session.test.ts unchanged. --- src/server/routes.ts | 3 +- src/server/session.test.ts | 45 -------------------------- src/server/session.ts | 23 ------------- src/server/sessions/session-id.test.ts | 45 ++++++++++++++++++++++++++ src/server/sessions/session-id.ts | 28 ++++++++++++++++ 5 files changed, 74 insertions(+), 70 deletions(-) create mode 100644 src/server/sessions/session-id.test.ts create mode 100644 src/server/sessions/session-id.ts diff --git a/src/server/routes.ts b/src/server/routes.ts index a2b19b8..13dc4c0 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -8,13 +8,12 @@ import { getVersion } from '../version'; import { GHOSTTY_WASM, serveFile } from './http/static-files'; import { createSession, - generateId, - isValidId, lastUsedId, sessionRegistry, sessionToJson, setLastUsedId, } from './session'; +import { generateId, isValidId } from './sessions/session-id'; import { broadcastToSubscribers, closeSession } from './websocket'; const MAX_BODY = 64 * 1024; diff --git a/src/server/session.test.ts b/src/server/session.test.ts index 9fa9077..f087060 100644 --- a/src/server/session.test.ts +++ b/src/server/session.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import { createSession, - generateId, - isValidId, lastUsedId, sessionRegistry, sessionToJson, @@ -13,49 +11,6 @@ beforeEach(() => { sessionRegistry.clear(); }); -describe('isValidId', () => { - test('accepts lowercase letters, digits, hyphen, underscore, dot', () => { - expect(isValidId('abc')).toBe(true); - expect(isValidId('my-session')).toBe(true); - expect(isValidId('my_session')).toBe(true); - expect(isValidId('my.session')).toBe(true); - expect(isValidId('abc123')).toBe(true); - }); - - test('rejects uppercase letters', () => { - expect(isValidId('ABC')).toBe(false); - expect(isValidId('MySession')).toBe(false); - }); - - test('rejects spaces and special characters', () => { - expect(isValidId('my session')).toBe(false); - expect(isValidId('my/session')).toBe(false); - expect(isValidId('my@session')).toBe(false); - expect(isValidId('INVALID ID!')).toBe(false); - }); - - test('rejects empty string', () => { - expect(isValidId('')).toBe(false); - }); - - test('rejects id longer than 64 characters', () => { - expect(isValidId('a'.repeat(65))).toBe(false); - expect(isValidId('a'.repeat(64))).toBe(true); - }); -}); - -describe('generateId', () => { - test('returns 8-character lowercase hex string', () => { - const id = generateId(); - expect(id).toMatch(/^[a-f0-9]{8}$/); - }); - - test('returns different values on successive calls', () => { - const ids = new Set(Array.from({ length: 20 }, generateId)); - expect(ids.size).toBeGreaterThan(1); - }); -}); - describe('createSession', () => { test('adds session to registry', () => { createSession('test'); diff --git a/src/server/session.ts b/src/server/session.ts index 8b6d3e1..f233284 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -34,29 +34,6 @@ export function setLastUsedId(id: string | null): void { lastUsedId = id; } -const ID_RE = /^[a-z0-9\-_.]{1,64}$/; - -/** - * Returns `true` if `id` is a valid session identifier (lowercase alphanumeric + `-_.`, max 64 chars). - * - * @param id - The session ID to validate. - * @returns `true` if the ID matches the valid format, `false` otherwise. - */ -export function isValidId(id: string): boolean { - return ID_RE.test(id); -} - -/** - * Generates a random 8-character hex session ID. - * - * @returns A random session ID. - */ -export function generateId(): string { - return Math.floor(Math.random() * 0xffffffff) - .toString(16) - .padStart(8, '0'); -} - /** * Creates a new session, registers it in {@link sessionRegistry}, and returns it. * diff --git a/src/server/sessions/session-id.test.ts b/src/server/sessions/session-id.test.ts new file mode 100644 index 0000000..46be6af --- /dev/null +++ b/src/server/sessions/session-id.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { generateId, isValidId } from './session-id'; + +describe('isValidId', () => { + test('accepts lowercase letters, digits, hyphen, underscore, dot', () => { + expect(isValidId('abc')).toBe(true); + expect(isValidId('my-session')).toBe(true); + expect(isValidId('my_session')).toBe(true); + expect(isValidId('my.session')).toBe(true); + expect(isValidId('abc123')).toBe(true); + }); + + test('rejects uppercase letters', () => { + expect(isValidId('ABC')).toBe(false); + expect(isValidId('MySession')).toBe(false); + }); + + test('rejects spaces and special characters', () => { + expect(isValidId('my session')).toBe(false); + expect(isValidId('my/session')).toBe(false); + expect(isValidId('my@session')).toBe(false); + expect(isValidId('INVALID ID!')).toBe(false); + }); + + test('rejects empty string', () => { + expect(isValidId('')).toBe(false); + }); + + test('rejects id longer than 64 characters', () => { + expect(isValidId('a'.repeat(65))).toBe(false); + expect(isValidId('a'.repeat(64))).toBe(true); + }); +}); + +describe('generateId', () => { + test('returns 8-character lowercase hex string', () => { + const id = generateId(); + expect(id).toMatch(/^[a-f0-9]{8}$/); + }); + + test('returns different values on successive calls', () => { + const ids = new Set(Array.from({ length: 20 }, generateId)); + expect(ids.size).toBeGreaterThan(1); + }); +}); diff --git a/src/server/sessions/session-id.ts b/src/server/sessions/session-id.ts new file mode 100644 index 0000000..2bd90fa --- /dev/null +++ b/src/server/sessions/session-id.ts @@ -0,0 +1,28 @@ +// Session identifiers: the accepted format and the random default (ADR 062). Pure +// utilities shared by the session routes; nothing here touches the session registry. + +/** The accepted session ID: lowercase letters, digits and `-_.`, 1 to 64 characters. */ +export const ID_RE = /^[a-z0-9\-_.]{1,64}$/; + +/** + * Returns `true` if `id` is a valid session identifier, so a route can reject an ID that + * would not survive a URL path or the CLI before it creates or renames a session. + * + * @param id - The session ID to validate. + * @returns `true` if the ID matches {@link ID_RE}, `false` otherwise. + */ +export function isValidId(id: string): boolean { + return ID_RE.test(id); +} + +/** + * Generates a random 8-character hex session ID, used when a client creates a session + * without naming it. + * + * @returns A random session ID. + */ +export function generateId(): string { + return Math.floor(Math.random() * 0xffffffff) + .toString(16) + .padStart(8, '0'); +} From c64390fe4f16ced03f925244b15492d2165c7690 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:15:47 -0400 Subject: [PATCH 161/295] refactor(server): the session banner in sessions/session-banner.ts sessionBanner moves out of websocket.ts into a pure function of the package runner (ADR 062); websocket.ts still resolves packageRunner() once at load and passes it in. A test pins the bunx and npx banners to the bytes the old code produced and checks every boxed line is 50 columns wide. --- src/server/sessions/session-banner.test.ts | 36 ++++++++++++++ src/server/sessions/session-banner.ts | 56 ++++++++++++++++++++++ src/server/websocket.ts | 43 +---------------- 3 files changed, 94 insertions(+), 41 deletions(-) create mode 100644 src/server/sessions/session-banner.test.ts create mode 100644 src/server/sessions/session-banner.ts diff --git a/src/server/sessions/session-banner.test.ts b/src/server/sessions/session-banner.test.ts new file mode 100644 index 0000000..dd85884 --- /dev/null +++ b/src/server/sessions/session-banner.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { sessionBanner } from './session-banner'; + +// The exact bytes the server wrote before the banner moved here; a change to them is a +// change to what every new session prints first. +const BUNX = + '\u001b[1;36m╔════════════════════════════════════════════════╗\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[2m[\u001b[0m \u001b[1m\u001b[1;33mwebtty\u001b[0m \u001b[2m] Terminal UI in the browser\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[2mRun `\u001b[0m\u001b[3mbunx webtty help\u001b[0m\u001b[2m` for more information.\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m╚════════════════════════════════════════════════╝\u001b[0m\r\n\r\n'; +const NPX = + '\u001b[1;36m╔════════════════════════════════════════════════╗\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[2m[\u001b[0m \u001b[1m\u001b[1;33mwebtty\u001b[0m \u001b[2m] Terminal UI in the browser\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[2mRun `\u001b[0m\u001b[3mnpx webtty help\u001b[0m\u001b[2m` for more information.\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m║\u001b[0m \u001b[1;36m║\u001b[0m\r\n\u001b[1;36m╚════════════════════════════════════════════════╝\u001b[0m\r\n\r\n'; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC (\x1b) starts the SGR sequences being stripped +const visible = (line: string) => line.replace(/\x1b\[[0-9;]*m/g, ''); + +describe('sessionBanner', () => { + test.each([ + ['bunx', BUNX], + ['npx', NPX], + ])('pins the %s banner byte for byte', (runner, expected) => { + expect(sessionBanner(runner)).toBe(expected); + }); + + test.each(['bunx', 'npx'])('every %s line is 50 columns wide, borders included', (runner) => { + const lines = sessionBanner(runner).split('\r\n'); + expect(lines.slice(-2)).toEqual(['', '']); + const boxed = lines.slice(0, -2); + expect(boxed).toHaveLength(7); + for (const line of boxed) expect(visible(line)).toHaveLength(50); + }); + + test('names the runner in the help hint', () => { + expect(visible(sessionBanner('npx'))).toContain(' Run `npx webtty help` for more information.'); + expect(visible(sessionBanner('bunx'))).toContain( + ' Run `bunx webtty help` for more information.', + ); + }); +}); diff --git a/src/server/sessions/session-banner.ts b/src/server/sessions/session-banner.ts new file mode 100644 index 0000000..aeff4ed --- /dev/null +++ b/src/server/sessions/session-banner.ts @@ -0,0 +1,56 @@ +// The banner a new session prints before the shell's first prompt (ADR 062). A pure +// function of the package runner, so its exact bytes can be pinned by a test. + +const DIM = '\x1b[2m'; +const RESET = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const ITALIC = '\x1b[3m'; +const YELLOW = '\x1b[1;33m'; +const CYAN = '\x1b[1;36m'; + +/** Inner width of the box in columns; every line pads its visible text to this. */ +const W = 48; + +/** Wraps one line of content in the box's side borders. */ +function pipe(content: string): string { + return `${CYAN}║${RESET}${content}${CYAN}║${RESET}`; +} + +/** + * Builds the boxed welcome banner written to a session's first client and kept as the + * start of its scrollback, so a later client sees it too. + * + * @param runner - The package runner the help hint suggests (`bunx` or `npx`). + * @returns The banner, with `\r\n` line endings and a trailing blank line. + */ +export function sessionBanner(runner: string): string { + const blank = pipe(' '.repeat(W)); + + // Each *Vis string is the line's visible text, used only to measure the padding. + const titleVis = ' [ webtty ] Terminal UI in the browser'; + const titleStr = ` ${DIM}[${RESET} ${BOLD}${YELLOW}webtty${RESET} ${DIM}] Terminal UI in the browser${RESET}`; + const titleLine = pipe(titleStr + ' '.repeat(W - titleVis.length)); + + const cmd = `${runner} webtty help`; + const helpVis = ` Run \`${cmd}\` for more information.`; + const helpStr = ` ${DIM}Run \`${RESET}${ITALIC}${cmd}${RESET}${DIM}\` for more information.${RESET}`; + const helpLine = pipe(helpStr + ' '.repeat(W - helpVis.length)); + + return [ + `${CYAN}╔${'═'.repeat(W)}╗${RESET}`, + '\r\n', + blank, + '\r\n', + titleLine, + '\r\n', + blank, + '\r\n', + helpLine, + '\r\n', + blank, + '\r\n', + `${CYAN}╚${'═'.repeat(W)}╝${RESET}`, + '\r\n', + '\r\n', + ].join(''); +} diff --git a/src/server/websocket.ts b/src/server/websocket.ts index e57084b..458dc8f 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -6,6 +6,7 @@ import { spawnForSession } from '../pty'; import { packageRunner } from '../runtime'; import type { Session } from './session'; import { sessionRegistry, setLastUsedId } from './session'; +import { sessionBanner } from './sessions/session-banner'; import { WS_CLOSE } from './ws/close-codes'; function closeClients(session: Session, code: number, reason: string): void { @@ -54,48 +55,8 @@ export function setLastSessionClosedHandler(handler: () => void): void { onLastSessionClosed = handler; } -const DIM = '\x1b[2m'; -const RESET = '\x1b[0m'; -const BOLD = '\x1b[1m'; -const ITALIC = '\x1b[3m'; -const YELLOW = '\x1b[1;33m'; -const CYAN = '\x1b[1;36m'; - const PKG_RUNNER = packageRunner(); -function sessionBanner(): string { - const W = 48; - const pipe = (content: string) => `${CYAN}║${RESET}${content}${CYAN}║${RESET}`; - const blank = pipe(' '.repeat(W)); - - const titleVis = ' [ webtty ] Terminal UI in the browser'; - const titleStr = ` ${DIM}[${RESET} ${BOLD}${YELLOW}webtty${RESET} ${DIM}] Terminal UI in the browser${RESET}`; - const titleLine = pipe(titleStr + ' '.repeat(W - titleVis.length)); - - const cmd = `${PKG_RUNNER} webtty help`; - const helpVis = ` Run \`${cmd}\` for more information.`; - const helpStr = ` ${DIM}Run \`${RESET}${ITALIC}${cmd}${RESET}${DIM}\` for more information.${RESET}`; - const helpLine = pipe(helpStr + ' '.repeat(W - helpVis.length)); - - return [ - `${CYAN}╔${'═'.repeat(W)}╗${RESET}`, - '\r\n', - blank, - '\r\n', - titleLine, - '\r\n', - blank, - '\r\n', - helpLine, - '\r\n', - blank, - '\r\n', - `${CYAN}╚${'═'.repeat(W)}╝${RESET}`, - '\r\n', - '\r\n', - ].join(''); -} - function handleSubscribe(ws: WS, id: string): void { const session = sessionRegistry.get(id); if (!session) { @@ -218,7 +179,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer }); }); - const banner = sessionBanner(); + const banner = sessionBanner(PKG_RUNNER); session.output.send(ws, banner); session.scrollback = banner; session.pty.write('\n'); From 5c1108e4828083bddd74dd6914275c43a71b857b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:17:20 -0400 Subject: [PATCH 162/295] refactor(client): the find bar takes the shortcut lookup from the root features/find imported features/keyboard/shortcut-map for fieldKey. The root now passes (e) => shortcutFor(e, mac)?.action into the FindBar, so the feature-feature exception leaves the ratchet allowlist instead of moving to find-keys.ts in C4. Same keys, same order: Enter is still decided before the lookup is asked. --- scripts/architecture/ratchet-allowlist.ts | 2 - src/client/features/find/find-bar.test.ts | 47 +++++++++++++++-------- src/client/features/find/find-bar.ts | 24 ++++++++---- src/client/index.ts | 2 +- 4 files changed, 49 insertions(+), 26 deletions(-) diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index f6963fa..56a100d 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -59,7 +59,5 @@ export const RATCHET_ALLOWLIST: Allowlist = { // Upstream's Node fallback in Ghostty.loadFromPath; tests run under Bun, where Bun.file // reads the wasm first, so engine/wasm-loader.ts can drop it. 'client-node src/client/vendor/ghostty-web/ghostty.ts -> fs/promises': ENGINE_SPLIT, - 'feature-feature src/client/features/find/find-bar.ts -> src/client/features/keyboard/shortcut-map.ts': - 'C4: the find keys move out of find-bar.ts, so features/find no longer imports features/keyboard (ADR 060)', }, }; diff --git a/src/client/features/find/find-bar.test.ts b/src/client/features/find/find-bar.test.ts index 26cfa8b..e5e313f 100644 --- a/src/client/features/find/find-bar.test.ts +++ b/src/client/features/find/find-bar.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { countDescription, countLabel, fieldKey } from './find-bar'; +import { shortcutFor } from '../keyboard/shortcut-map'; +import { countDescription, countLabel, type FieldKeyEvent, fieldKey } from './find-bar'; describe('countLabel', () => { test("shows Ghostty's selected/total, 0 before a match is selected", () => { @@ -20,6 +21,8 @@ describe('countDescription', () => { }); describe('fieldKey', () => { + // The lookup the root passes in (ADR 060). + const lookup = (mac: boolean) => (e: FieldKeyEvent) => shortcutFor(e, mac)?.action; const key = (k: string, code: string, mods: string[] = []) => ({ key: k, code, @@ -31,30 +34,42 @@ describe('fieldKey', () => { test('Enter goes to the next match (older), Shift+Enter to the previous', () => { for (const mac of [false, true]) { - expect(fieldKey(key('Enter', 'Enter'), mac)).toBe('next'); - expect(fieldKey(key('Enter', 'NumpadEnter'), mac)).toBe('next'); - expect(fieldKey(key('Enter', 'Enter', ['shift']), mac)).toBe('previous'); - expect(fieldKey(key('Enter', 'Enter', ['ctrl']), mac)).toBeNull(); + expect(fieldKey(key('Enter', 'Enter'), lookup(mac))).toBe('next'); + expect(fieldKey(key('Enter', 'NumpadEnter'), lookup(mac))).toBe('next'); + expect(fieldKey(key('Enter', 'Enter', ['shift']), lookup(mac))).toBe('previous'); + expect(fieldKey(key('Enter', 'Enter', ['ctrl']), lookup(mac))).toBeNull(); } }); test('Escape closes', () => { - expect(fieldKey(key('Escape', 'Escape'), false)).toBe('close'); - expect(fieldKey(key('Escape', 'Escape'), true)).toBe('close'); + expect(fieldKey(key('Escape', 'Escape'), lookup(false))).toBe('close'); + expect(fieldKey(key('Escape', 'Escape'), lookup(true))).toBe('close'); }); test('the find shortcuts work in the field: Cmd+G, Cmd+Shift+F, and the open key selects', () => { - expect(fieldKey(key('g', 'KeyG', ['meta']), true)).toBe('next'); - expect(fieldKey(key('G', 'KeyG', ['meta', 'shift']), true)).toBe('previous'); - expect(fieldKey(key('F', 'KeyF', ['meta', 'shift']), true)).toBe('close'); - expect(fieldKey(key('f', 'KeyF', ['meta']), true)).toBe('select'); - expect(fieldKey(key('F', 'KeyF', ['ctrl', 'shift']), false)).toBe('select'); + expect(fieldKey(key('g', 'KeyG', ['meta']), lookup(true))).toBe('next'); + expect(fieldKey(key('G', 'KeyG', ['meta', 'shift']), lookup(true))).toBe('previous'); + expect(fieldKey(key('F', 'KeyF', ['meta', 'shift']), lookup(true))).toBe('close'); + expect(fieldKey(key('f', 'KeyF', ['meta']), lookup(true))).toBe('select'); + expect(fieldKey(key('F', 'KeyF', ['ctrl', 'shift']), lookup(false))).toBe('select'); + }); + + test('Enter is the field’s own key: the lookup is not asked', () => { + let asked = 0; + const counting = (e: FieldKeyEvent) => { + asked++; + return shortcutFor(e, true)?.action; + }; + expect(fieldKey(key('Enter', 'Enter'), counting)).toBe('next'); + expect(asked).toBe(0); + expect(fieldKey(key('Escape', 'Escape'), counting)).toBe('close'); + expect(asked).toBe(1); }); test('other keys are the field’s: typing, copy, paste, clear', () => { - expect(fieldKey(key('a', 'KeyA'), false)).toBeNull(); - expect(fieldKey(key('v', 'KeyV', ['ctrl']), false)).toBeNull(); - expect(fieldKey(key('k', 'KeyK', ['meta']), true)).toBeNull(); - expect(fieldKey(key('C', 'KeyC', ['ctrl', 'shift']), false)).toBeNull(); + expect(fieldKey(key('a', 'KeyA'), lookup(false))).toBeNull(); + expect(fieldKey(key('v', 'KeyV', ['ctrl']), lookup(false))).toBeNull(); + expect(fieldKey(key('k', 'KeyK', ['meta']), lookup(true))).toBeNull(); + expect(fieldKey(key('C', 'KeyC', ['ctrl', 'shift']), lookup(false))).toBeNull(); }); }); diff --git a/src/client/features/find/find-bar.ts b/src/client/features/find/find-bar.ts index 13d06e0..8fb74b0 100644 --- a/src/client/features/find/find-bar.ts +++ b/src/client/features/find/find-bar.ts @@ -9,8 +9,6 @@ * previous match, Escape closes. The count is Ghostty's `selected/total`: `0/n` until a * match is selected. */ -import { shortcutFor } from '../keyboard/shortcut-map'; - /** The match count and the selected match's index (0 the newest), as the terminal reports them. */ export interface FindState { total: number; @@ -42,15 +40,27 @@ export function countDescription(state: FindState): string { return `Match ${state.selected + 1} of ${state.total}`; } +/** The key fields the find field reads. */ +export type FieldKeyEvent = Pick< + KeyboardEvent, + 'key' | 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey' +>; + +/** + * The shortcut action a key is, if any. The root passes the keyboard feature's lookup in, + * so features/find does not import features/keyboard (ADR 060). + */ +export type ShortcutActionOf = (e: FieldKeyEvent) => string | undefined; + /** What a key in the find field does: the field's own keys, then the find shortcuts. */ export function fieldKey( - e: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>, - mac: boolean, + e: FieldKeyEvent, + shortcutActionOf: ShortcutActionOf, ): 'next' | 'previous' | 'close' | 'select' | null { if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { return e.shiftKey ? 'previous' : 'next'; } - switch (shortcutFor(e, mac)?.action) { + switch (shortcutActionOf(e)) { case 'findClose': return 'close'; case 'findNext': @@ -76,7 +86,7 @@ export class FindBar { constructor( parent: HTMLElement, theme: Record<string, string | undefined>, - private readonly mac: boolean, + private readonly shortcutActionOf: ShortcutActionOf, private readonly actions: FindBarActions, ) { const root = document.createElement('div'); @@ -182,7 +192,7 @@ export class FindBar { private onKey(e: KeyboardEvent): void { // Composing (IME): Enter commits the text, not a search step if (e.isComposing) return; - const action = fieldKey(e, this.mac); + const action = fieldKey(e, this.shortcutActionOf); if (!action) return; e.preventDefault(); if (action === 'close') this.close(); diff --git a/src/client/index.ts b/src/client/index.ts index 8c652ce..9e62fe5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -535,7 +535,7 @@ container.addEventListener( // Find in the scrollback with libghostty-vt's search (ADR 055). The bar is outside the // container: its keys are its own. Closing it ends the search and refocuses the terminal. -const findBar = new FindBar(document.body, terminalTheme, mac, { +const findBar = new FindBar(document.body, terminalTheme, (e) => shortcutFor(e, mac)?.action, { find: (needle) => term.find(needle), next: () => term.findNext(), previous: () => term.findPrevious(), From 490dc95d7344d129a552c62507bdc6585ad250fb Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:17:21 -0400 Subject: [PATCH 163/295] docs(arch): find keys take the shortcut lookup as an argument The SPEC tree and ADR 060 say find-keys.ts gets the lookup from the root, so features/find never imports features/keyboard. --- docs/adrs/060.client.feature-modules.md | 2 +- docs/specs/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adrs/060.client.feature-modules.md b/docs/adrs/060.client.feature-modules.md index 3532e3c..b43115c 100644 --- a/docs/adrs/060.client.feature-modules.md +++ b/docs/adrs/060.client.feature-modules.md @@ -56,7 +56,7 @@ Shortcut actions go through a **command map** (`shortcut-commands.ts`): action - `ack.ts` → `connection/output-ack.ts`, `replay.ts` → `connection/replay-window.ts`, `fit.ts` → `layout/fit-insets.ts`, `edge.ts` → `layout/edge-rects.ts` + `layout/edge-extender.ts`, `theme.ts` → `app/terminal-theme.ts`, `shortcuts.ts` → `features/keyboard/shortcut-map.ts`, `clipboard-write.ts` → `features/clipboard/clipboard-writer.ts`, `compose.ts` → `features/compose/composer.ts`. - `notifications.ts` → `desktop-notifier.ts` (policy) + `browser-notifications.ts` (adapter and `activates`). -- `find-bar.ts` → `find-bar.ts` (view, takes `document`) + `find-format.ts` + `find-keys.ts`. +- `find-bar.ts` → `find-bar.ts` (view, takes `document`) + `find-format.ts` + `find-keys.ts`. The find keys take the shortcut lookup as an argument (`(e) => shortcutFor(e, mac)?.action`, passed in by the root), so `features/find` never imports `features/keyboard`. - `wheel-stream.ts` → `wheel-classifier.ts` (with the VS Code MIT notice) + `wheel-stream.ts`; `smooth-scroll.ts` → `smooth-scroll.ts` + `scroll-split.ts`. - `kitty.ts` → `features/kitty/protocol/` (seven files by concern); `graphics.ts` → `features/kitty/graphics/` (`image-store`, `image-decoder`, `placements`, `kitty-limits`, `overlay-canvas`, and the `kitty-graphics` facade whose `feed` hot path is unchanged). - The terminal-canvas selectors are named once in `layout/terminal-canvas.ts`, keeping each call site's exact string (unifying them would change which canvas some sites find). diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 5434a66..30ea77d 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -143,7 +143,7 @@ src/ notifications/ desktop-notifier.ts (object), browser-notifications.ts (adapter + activates), notifications-feature.ts clipboard/ clipboard-writer.ts (object, was clipboard-write.ts), clipboard-feature.ts paste/ non-text-paste.ts (util), paste-service.ts (service), paste-feature.ts - find/ find-bar.ts (view), find-format.ts (util), find-keys.ts (util), find-feature.ts + find/ find-bar.ts (view), find-format.ts (util), find-keys.ts (util; takes the shortcut lookup, never imports keyboard/), find-feature.ts keyboard/ key-bindings.ts, shortcut-map.ts (was shortcuts.ts), shortcut-commands.ts, font-zoom.ts, key-dispatcher.ts (feature), font-zoom-feature.ts compose/ composer.ts (view + dispose), composer-geometry.ts (util) scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, scroll-feature.ts From 4bd9af8be463e0fb36d04e4c47031e3cb40498c5 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:17:40 -0400 Subject: [PATCH 164/295] refactor(server): parse PTY client messages in ws/client-message.ts The ws URL size, the ack/resize/raw-input classification and the 1..1000 x 1..500 clamp (default 80x24) move out of websocket.ts into pure functions (ADR 048, ADR 062): clampSize, sizeFromQuery and parseClientMessage, which keeps the startsWith('{') fast path. websocket.ts applies the result; a control message whose handling throws is still written to the PTY as raw input, as the old shared try/catch did. Table-driven tests cover acks, resizes, clamp edges, invalid JSON and '{'-prefixed input. --- src/server/websocket.ts | 42 +++++---------- src/server/ws/client-message.test.ts | 60 +++++++++++++++++++++ src/server/ws/client-message.ts | 80 ++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 28 deletions(-) create mode 100644 src/server/ws/client-message.test.ts create mode 100644 src/server/ws/client-message.ts diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 458dc8f..5ed3d85 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -7,6 +7,7 @@ import { packageRunner } from '../runtime'; import type { Session } from './session'; import { sessionRegistry, setLastUsedId } from './session'; import { sessionBanner } from './sessions/session-banner'; +import { parseClientMessage, sizeFromQuery } from './ws/client-message'; import { WS_CLOSE } from './ws/close-codes'; function closeClients(session: Session, code: number, reason: string): void { @@ -121,14 +122,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer return; } - const cols = Math.max( - 1, - Math.min(1000, Number.parseInt(url.searchParams.get('cols') ?? '80', 10) || 80), - ); - const rows = Math.max( - 1, - Math.min(500, Number.parseInt(url.searchParams.get('rows') ?? '24', 10) || 24), - ); + const { cols, rows } = sizeFromQuery(url.searchParams); if (!sessionRegistry.has(id)) { ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); @@ -192,27 +186,19 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer ws.on('message', (data: Buffer) => { const message = data.toString('utf8'); - if (message.startsWith('{')) { - try { - const msg = JSON.parse(message) as { - type: string; - cols: number; - rows: number; - chars: number; - }; - if (msg.type === 'ack') { - session.output.ack(ws, Number(msg.chars)); - return; - } - if (msg.type === 'resize') { - const c = Math.max(1, Math.min(1000, Math.trunc(msg.cols) || 80)); - const r = Math.max(1, Math.min(500, Math.trunc(msg.rows) || 24)); - session.pty?.resize(c, r); - return; - } - } catch { - // fall through + const msg = parseClientMessage(message); + try { + if (msg.type === 'ack') { + session.output.ack(ws, msg.chars); + return; + } + if (msg.type === 'resize') { + session.pty?.resize(msg.cols, msg.rows); + return; } + } catch { + // A control message whose handling throws (node-pty refuses to resize a PTY that + // has exited) is written to the PTY as raw input, as it always has been. } session.pty?.write(message); }); diff --git a/src/server/ws/client-message.test.ts b/src/server/ws/client-message.test.ts new file mode 100644 index 0000000..ddf10a8 --- /dev/null +++ b/src/server/ws/client-message.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import { type ClientMessage, clampSize, parseClientMessage, sizeFromQuery } from './client-message'; + +describe('clampSize', () => { + test.each([ + [80, 24, 80, 24], + [1, 1, 1, 1], + [1000, 500, 1000, 500], + [1001, 501, 1000, 500], + [-5, -5, 1, 1], + [0, 0, 80, 24], + [Number.NaN, Number.NaN, 80, 24], + ])('%p×%p → %p×%p', (cols, rows, wantCols, wantRows) => { + expect(clampSize(cols, rows)).toEqual({ cols: wantCols, rows: wantRows }); + }); +}); + +describe('sizeFromQuery', () => { + test.each([ + ['', 80, 24], + ['cols=120&rows=40', 120, 40], + ['cols=120.9&rows=40x', 120, 40], + ['cols=abc&rows=', 80, 24], + ['cols=0&rows=0', 80, 24], + ['cols=99999&rows=99999', 1000, 500], + ['cols=-3&rows=-3', 1, 1], + ])('?%s → %p×%p', (query, cols, rows) => { + expect(sizeFromQuery(new URLSearchParams(query))).toEqual({ cols, rows }); + }); +}); + +describe('parseClientMessage', () => { + const controls: [string, ClientMessage][] = [ + ['{"type":"ack","chars":1234}', { type: 'ack', chars: 1234 }], + ['{"type":"ack","chars":"77"}', { type: 'ack', chars: 77 }], + ['{"type":"ack"}', { type: 'ack', chars: Number.NaN }], + ['{"type":"resize","cols":100,"rows":30}', { type: 'resize', cols: 100, rows: 30 }], + ['{"type":"resize","cols":100.7,"rows":30.2}', { type: 'resize', cols: 100, rows: 30 }], + ['{"type":"resize","cols":0,"rows":0}', { type: 'resize', cols: 80, rows: 24 }], + ['{"type":"resize"}', { type: 'resize', cols: 80, rows: 24 }], + ['{"type":"resize","cols":5000,"rows":-1}', { type: 'resize', cols: 1000, rows: 1 }], + ]; + + test.each(controls)('%s is a control message', (frame, expected) => { + expect(parseClientMessage(frame)).toEqual(expected); + }); + + test.each([ + ['plain keystrokes', 'ls -la\r'], + ['an empty frame', ''], + ['invalid JSON starting with {', '{ not json'], + ['a { typed by the user', '{'], + ['JSON with an unknown type', '{"type":"other"}'], + ['JSON with no type', '{}'], + ['JSON not starting with {', ' {"type":"ack","chars":1}'], + ['a JSON array', '[{"type":"ack","chars":1}]'], + ])('%s is raw input', (_name, frame) => { + expect(parseClientMessage(frame)).toEqual({ type: 'input', data: frame }); + }); +}); diff --git a/src/server/ws/client-message.ts b/src/server/ws/client-message.ts new file mode 100644 index 0000000..ee6472f --- /dev/null +++ b/src/server/ws/client-message.ts @@ -0,0 +1,80 @@ +// What a PTY client sends the server: the size in its ws URL and the messages on the +// socket (ADR 048, ADR 062). Pure parsing only; websocket.ts applies the result. + +/** Default terminal size when the client sends none, or a non-number. */ +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; +const MAX_COLS = 1000; +const MAX_ROWS = 500; + +/** A terminal size in cells. */ +export interface TermSize { + cols: number; + rows: number; +} + +/** + * One message from a PTY client: an output acknowledgement (ADR 048), a resize, or raw + * keyboard input for the shell. + */ +export type ClientMessage = + | { type: 'ack'; chars: number } + | { type: 'resize'; cols: number; rows: number } + | { type: 'input'; data: string }; + +/** + * Clamps a requested size to 1..1000 columns and 1..500 rows, so a bad client can never + * hand the PTY a zero or absurd size. 0 and NaN fall back to 80×24. + * + * @param cols - Requested columns, already an integer or NaN. + * @param rows - Requested rows, already an integer or NaN. + * @returns The size to give the PTY. + */ +export function clampSize(cols: number, rows: number): TermSize { + return { + cols: Math.max(1, Math.min(MAX_COLS, cols || DEFAULT_COLS)), + rows: Math.max(1, Math.min(MAX_ROWS, rows || DEFAULT_ROWS)), + }; +} + +/** + * Reads the initial size from a PTY socket's URL query (`?cols=&rows=`), which the shell + * is spawned with or the running PTY is resized to. + * + * @param params - The upgrade request's query parameters. + * @returns The clamped size. + */ +export function sizeFromQuery(params: URLSearchParams): TermSize { + return clampSize( + Number.parseInt(params.get('cols') ?? String(DEFAULT_COLS), 10), + Number.parseInt(params.get('rows') ?? String(DEFAULT_ROWS), 10), + ); +} + +/** + * Classifies one text frame from a PTY client. Only a frame starting with `{` is tried + * as JSON, so ordinary keystrokes skip the parse; anything that is not a known control + * message, including invalid JSON, is raw input for the shell. + * + * @param message - The frame, decoded as UTF-8. + * @returns The message to apply. + */ +export function parseClientMessage(message: string): ClientMessage { + if (message.startsWith('{')) { + try { + const msg = JSON.parse(message) as { + type: string; + cols: number; + rows: number; + chars: number; + }; + if (msg.type === 'ack') return { type: 'ack', chars: Number(msg.chars) }; + if (msg.type === 'resize') { + return { type: 'resize', ...clampSize(Math.trunc(msg.cols), Math.trunc(msg.rows)) }; + } + } catch { + // Not JSON: the user typed or pasted something starting with '{'. + } + } + return { type: 'input', data: message }; +} From 22a875a140c0847195e4d6e525be233af2efee70 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:17:40 -0400 Subject: [PATCH 165/295] test(server): a session's output pacer pauses the PTY the session holds --- src/server/session.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/server/session.test.ts b/src/server/session.test.ts index f087060..f4079b2 100644 --- a/src/server/session.test.ts +++ b/src/server/session.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { createSession, lastUsedId, @@ -27,6 +27,18 @@ describe('createSession', () => { }); }); +describe('session output', () => { + test('pauses the PTY the session holds when its only client falls behind', () => { + const session = createSession('test-output'); + const pause = mock(() => {}); + session.pty = { pid: 1, pause } as never; + const ws = { readyState: 1, OPEN: 1, bufferedAmount: 0, send() {}, close() {} }; + session.output.add(ws, true); + session.output.broadcast('x'.repeat(2 * 1024 * 1024)); + expect(pause).toHaveBeenCalledTimes(1); + }); +}); + describe('sessionToJson', () => { test('connected is false when no clients', () => { const session = createSession('test'); From 57b2e4bcb71454186fb69eefb50d143d29b21d7c Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:22:28 -0400 Subject: [PATCH 166/295] refactor(server): SessionStore replaces the module-global registry sessions/session-store.ts owns the session map, lastUsedId, create, get, has, list, findByPid, rename (the last-used ID follows it), remove (an API delete also forgets the last-used ID), removeExited (a shell exit keeps it, as before) and the onEmpty callback that stops the server once the last shell has exited (ADR 062). index.ts creates the one instance and passes it to handleRequest and createWebSocketServer; every reader of lastUsedId switches over here, and routes.ts no longer touches a Map. session.ts keeps the Session record, sessionToJson and newSession, the unregistered factory the store calls. Behaviour is unchanged: GET / still redirects to the last-used session, now pinned end to end through a rename and a delete. --- src/server/index.ts | 13 +- src/server/routes.ts | 48 +++---- src/server/session.test.ts | 58 +++----- src/server/session.ts | 21 +-- src/server/sessions/session-store.test.ts | 155 ++++++++++++++++++++++ src/server/sessions/session-store.ts | 113 ++++++++++++++++ src/server/websocket.test.ts | 24 ++++ src/server/websocket.ts | 46 +++---- 8 files changed, 367 insertions(+), 111 deletions(-) create mode 100644 src/server/sessions/session-store.test.ts create mode 100644 src/server/sessions/session-store.ts diff --git a/src/server/index.ts b/src/server/index.ts index 8ac2956..23f5a8c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,7 +5,8 @@ import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config'; import { findGhosttyWasm } from './http/static-files'; import { handleRequest } from './routes'; -import { closeAllSessions, createWebSocketServer, setLastSessionClosedHandler } from './websocket'; +import { SessionStore } from './sessions/session-store'; +import { closeAllSessions, createWebSocketServer } from './websocket'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -28,8 +29,11 @@ const clientDistPath = fs.existsSync(path.join(builtDistPath, 'client.html')) ? builtDistPath : srcClientPath; +// The one registry of sessions. Once the last shell exits the server stops itself. +const store = new SessionStore({ onEmpty: shutdown }); + function shutdown() { - closeAllSessions(); + closeAllSessions(store); wss.close(); const exit = () => process.exit(0); const shutdownTimeout = setTimeout(exit, 1000); @@ -47,7 +51,7 @@ process.on('unhandledRejection', (reason) => { }); const httpServer = http.createServer((req, res) => { - handleRequest(req, res, wasmPath, clientDistPath, shutdown).catch((err) => { + handleRequest(req, res, store, wasmPath, clientDistPath, shutdown).catch((err) => { console.error('[webtty] unhandled route error:', err); if (!res.headersSent) { res.writeHead(500); @@ -56,8 +60,7 @@ const httpServer = http.createServer((req, res) => { }); }); -const wss = createWebSocketServer(httpServer); -setLastSessionClosedHandler(shutdown); +const wss = createWebSocketServer(httpServer, store); process.on('SIGINT', () => { console.log('\n\nShutting down...'); diff --git a/src/server/routes.ts b/src/server/routes.ts index 13dc4c0..41bbd96 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -6,14 +6,9 @@ import path from 'node:path'; import { loadConfig, toClientConfig } from '../config'; import { getVersion } from '../version'; import { GHOSTTY_WASM, serveFile } from './http/static-files'; -import { - createSession, - lastUsedId, - sessionRegistry, - sessionToJson, - setLastUsedId, -} from './session'; +import { sessionToJson } from './session'; import { generateId, isValidId } from './sessions/session-id'; +import type { SessionStore } from './sessions/session-store'; import { broadcastToSubscribers, closeSession } from './websocket'; const MAX_BODY = 64 * 1024; @@ -71,6 +66,7 @@ export function readJson(req: http.IncomingMessage): Promise<unknown> { * * @param req - The incoming HTTP request. * @param res - The HTTP response object. + * @param store - The server's sessions. * @param wasmPath - Path to the wasm served at `/ghostty-vt-small.wasm`. * @param clientDistPath - Path to the client dist directory. * @param onStop - Callback invoked when `POST /api/server/stop` is received. @@ -78,6 +74,7 @@ export function readJson(req: http.IncomingMessage): Promise<unknown> { export async function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, + store: SessionStore, wasmPath: string, clientDistPath: string, onStop: () => void, @@ -116,7 +113,7 @@ export async function handleRequest( if (pathname === '/api/sessions') { if (req.method === 'GET') { - const list = [...sessionRegistry.values()].map(sessionToJson); + const list = store.list().map(sessionToJson); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(list)); return; @@ -139,7 +136,7 @@ export async function handleRequest( res.end(JSON.stringify({ error: `invalid id: ${id}` })); return; } - if (sessionRegistry.has(id)) { + if (store.has(id)) { res.writeHead(409, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `session already exists: ${id}` })); return; @@ -160,7 +157,7 @@ export async function handleRequest( res.end(JSON.stringify({ error: `baseDir is not a directory: ${baseDir}` })); return; } - const session = createSession(id, baseDir); + const session = store.create(id, baseDir); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(sessionToJson(session))); return; @@ -177,7 +174,7 @@ export async function handleRequest( } if (req.method === 'GET') { - const session = sessionRegistry.get(id); + const session = store.get(id); if (!session) { res.writeHead(404); res.end('Not Found'); @@ -199,7 +196,7 @@ export async function handleRequest( return; } - const session = sessionRegistry.get(id); + const session = store.get(id); if (!session) { res.writeHead(404); res.end('Not Found'); @@ -217,40 +214,35 @@ export async function handleRequest( res.end(JSON.stringify({ error: `invalid id: ${newId}` })); return; } - if (sessionRegistry.has(newId)) { + if (store.has(newId)) { res.writeHead(409, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: `session already exists: ${newId}` })); return; } - sessionRegistry.delete(id); - session.id = newId; - sessionRegistry.set(newId, session); - if (lastUsedId === id) setLastUsedId(newId); + store.rename(id, newId); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(sessionToJson(session))); return; } if (req.method === 'DELETE') { - const session = sessionRegistry.get(id); + const session = store.remove(id); if (!session) { res.writeHead(404); res.end('Not Found'); return; } - sessionRegistry.delete(id); - if (lastUsedId === id) setLastUsedId(null); closeSession(session); - res.writeHead(204, { 'X-Sessions-Remaining': String(sessionRegistry.size) }); + res.writeHead(204, { 'X-Sessions-Remaining': String(store.size) }); res.end(); return; } } if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) { - let targetId = lastUsedId ?? null; - if (!targetId || !sessionRegistry.has(targetId)) { - if (!sessionRegistry.has('main')) createSession('main'); + let targetId = store.lastUsedId; + if (!targetId || !store.has(targetId)) { + if (!store.has('main')) store.create('main'); targetId = 'main'; } res.writeHead(302, { Location: `/s/${targetId}` }); @@ -261,7 +253,7 @@ export async function handleRequest( const clientMatch = pathname.match(/^\/s\/([^/]+)$/); if (req.method === 'GET' && clientMatch) { const id = decodeId(clientMatch[1]); - if (!id || !isValidId(id) || !sessionRegistry.has(id)) { + if (!id || !isValidId(id) || !store.has(id)) { res.writeHead(404); res.end('Not Found'); return; @@ -284,7 +276,7 @@ export async function handleRequest( res.end('Bad Request'); return; } - const session = sessionRegistry.get(id); + const session = store.get(id); if (!session) { res.writeHead(404); res.end('Not Found'); @@ -345,7 +337,7 @@ export async function handleRequest( res.end('Bad Request'); return; } - const session = sessionRegistry.get(id); + const session = store.get(id); if (!session) { res.writeHead(404); res.end('Not Found'); @@ -433,7 +425,7 @@ export async function handleRequest( res.end('Not Found'); return; } - const session = [...sessionRegistry.values()].find((s) => s.pty?.pid === pid); + const session = store.findByPid(pid); if (!session) { res.writeHead(404); res.end('Not Found'); diff --git a/src/server/session.test.ts b/src/server/session.test.ts index f4079b2..15bcb9e 100644 --- a/src/server/session.test.ts +++ b/src/server/session.test.ts @@ -1,35 +1,27 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; -import { - createSession, - lastUsedId, - sessionRegistry, - sessionToJson, - setLastUsedId, -} from './session'; - -beforeEach(() => { - sessionRegistry.clear(); -}); - -describe('createSession', () => { - test('adds session to registry', () => { - createSession('test'); - expect(sessionRegistry.has('test')).toBe(true); - }); +import { describe, expect, mock, test } from 'bun:test'; +import { homedir } from 'node:os'; +import { newSession, sessionToJson } from './session'; +describe('newSession', () => { test('returns session with correct shape', () => { - const session = createSession('test'); + const session = newSession('test', '/tmp'); expect(session.id).toBe('test'); + expect(session.baseDir).toBe('/tmp'); expect(session.pty).toBeNull(); expect(session.clients.size).toBe(0); + expect(session.subscribers.size).toBe(0); expect(session.scrollback).toBe(''); expect(typeof session.createdAt).toBe('number'); }); + + test('defaults baseDir to the home directory', () => { + expect(newSession('test').baseDir).toBe(homedir()); + }); }); describe('session output', () => { test('pauses the PTY the session holds when its only client falls behind', () => { - const session = createSession('test-output'); + const session = newSession('test-output'); const pause = mock(() => {}); session.pty = { pid: 1, pause } as never; const ws = { readyState: 1, OPEN: 1, bufferedAmount: 0, send() {}, close() {} }; @@ -41,44 +33,32 @@ describe('session output', () => { describe('sessionToJson', () => { test('connected is false when no clients', () => { - const session = createSession('test'); + const session = newSession('test'); expect(sessionToJson(session).connected).toBe(false); }); test('connected is true when clients set is non-empty', () => { - const session = createSession('test'); + const session = newSession('test'); session.clients.add({} as never); expect(sessionToJson(session).connected).toBe(true); }); - test('includes id and createdAt', () => { - const session = createSession('test'); + test('includes id, createdAt and baseDir', () => { + const session = newSession('test', '/tmp'); const json = sessionToJson(session); expect(json.id).toBe('test'); + expect(json.baseDir).toBe('/tmp'); expect(typeof json.createdAt).toBe('number'); }); test('pid is null when pty is not yet spawned', () => { - const session = createSession('test-pid-null'); + const session = newSession('test-pid-null'); expect(sessionToJson(session).pid).toBeNull(); }); test('pid reflects pty pid when pty is set', () => { - const session = createSession('test-pid-set'); + const session = newSession('test-pid-set'); session.pty = { pid: 12345 } as never; expect(sessionToJson(session).pid).toBe(12345); }); }); - -describe('setLastUsedId', () => { - test('updates lastUsedId to the given value', () => { - setLastUsedId('my-session'); - expect(lastUsedId).toBe('my-session'); - }); - - test('accepts null to clear lastUsedId', () => { - setLastUsedId('my-session'); - setLastUsedId(null); - expect(lastUsedId).toBeNull(); - }); -}); diff --git a/src/server/session.ts b/src/server/session.ts index f233284..bacf98c 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,3 +1,6 @@ +// The Session record: one terminal session's PTY, clients, subscribers, scrollback and +// output pacer, plus its JSON view for the API. Types and pure factories; the registry +// of sessions is SessionStore in sessions/session-store.ts (ADR 062). import { homedir } from 'node:os'; import type { WebSocket as WS } from 'ws'; import type { PtyProcess } from '../pty'; @@ -23,25 +26,14 @@ export interface Session { output: OutputPacer; } -/** All active sessions, keyed by session ID. */ -export const sessionRegistry = new Map<string, Session>(); - -/** ID of the most recently opened session, used for `GET /` redirect. */ -export let lastUsedId: string | null = null; - -/** Updates {@link lastUsedId}. */ -export function setLastUsedId(id: string | null): void { - lastUsedId = id; -} - /** - * Creates a new session, registers it in {@link sessionRegistry}, and returns it. + * Builds a session that has no shell yet; `SessionStore.create` registers it. * * @param id - The session ID. * @param baseDir - Working directory for the PTY shell (default: `homedir()`). - * @returns The newly created {@link Session}. + * @returns The new, unregistered {@link Session}. */ -export function createSession(id: string, baseDir = homedir()): Session { +export function newSession(id: string, baseDir = homedir()): Session { const session: Session = { id, createdAt: Date.now(), @@ -52,7 +44,6 @@ export function createSession(id: string, baseDir = homedir()): Session { scrollback: '', output: new OutputPacer(() => session.pty), }; - sessionRegistry.set(id, session); return session; } diff --git a/src/server/sessions/session-store.test.ts b/src/server/sessions/session-store.test.ts new file mode 100644 index 0000000..8580dfd --- /dev/null +++ b/src/server/sessions/session-store.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { SessionStore } from './session-store'; + +describe('SessionStore.create and get', () => { + test('registers the new session under its ID', () => { + const store = new SessionStore(); + const session = store.create('a', '/tmp'); + expect(session.id).toBe('a'); + expect(session.baseDir).toBe('/tmp'); + expect(store.get('a')).toBe(session); + expect(store.has('a')).toBe(true); + expect(store.size).toBe(1); + }); + + test('an unknown ID is absent', () => { + const store = new SessionStore(); + expect(store.get('nope')).toBeUndefined(); + expect(store.has('nope')).toBe(false); + expect(store.size).toBe(0); + }); + + test('list returns the sessions in creation order', () => { + const store = new SessionStore(); + store.create('b'); + store.create('a'); + expect(store.list().map((s) => s.id)).toEqual(['b', 'a']); + }); + + test('two stores do not share sessions', () => { + const one = new SessionStore(); + one.create('a'); + expect(new SessionStore().has('a')).toBe(false); + }); +}); + +describe('SessionStore.lastUsedId', () => { + test('starts null and follows markUsed', () => { + const store = new SessionStore(); + expect(store.lastUsedId).toBeNull(); + store.markUsed('a'); + expect(store.lastUsedId).toBe('a'); + }); +}); + +describe('SessionStore.rename', () => { + test('moves the session to the new ID and updates its id field', () => { + const store = new SessionStore(); + const session = store.create('old'); + expect(store.rename('old', 'new')).toBe(session); + expect(session.id).toBe('new'); + expect(store.has('old')).toBe(false); + expect(store.get('new')).toBe(session); + }); + + test('the last-used ID follows the rename', () => { + const store = new SessionStore(); + store.create('old'); + store.markUsed('old'); + store.rename('old', 'new'); + expect(store.lastUsedId).toBe('new'); + }); + + test('another last-used ID is left alone', () => { + const store = new SessionStore(); + store.create('old'); + store.create('other'); + store.markUsed('other'); + store.rename('old', 'new'); + expect(store.lastUsedId).toBe('other'); + }); + + test('an unknown ID returns undefined and changes nothing', () => { + const store = new SessionStore(); + store.markUsed('ghost'); + expect(store.rename('ghost', 'new')).toBeUndefined(); + expect(store.has('new')).toBe(false); + expect(store.lastUsedId).toBe('ghost'); + }); +}); + +describe('SessionStore.remove', () => { + test('unregisters the session and returns it', () => { + const store = new SessionStore(); + const session = store.create('a'); + store.create('b'); + expect(store.remove('a')).toBe(session); + expect(store.has('a')).toBe(false); + expect(store.size).toBe(1); + }); + + test('forgets the session as the last-used one', () => { + const store = new SessionStore(); + store.create('a'); + store.markUsed('a'); + store.remove('a'); + expect(store.lastUsedId).toBeNull(); + }); + + test('keeps another last-used ID', () => { + const store = new SessionStore(); + store.create('a'); + store.create('b'); + store.markUsed('b'); + store.remove('a'); + expect(store.lastUsedId).toBe('b'); + }); + + test('an unknown ID returns undefined', () => { + expect(new SessionStore().remove('nope')).toBeUndefined(); + }); + + test('a session whose shell exited is unregistered but stays the last-used ID', () => { + const store = new SessionStore(); + store.create('a'); + store.markUsed('a'); + store.removeExited('a'); + expect(store.has('a')).toBe(false); + expect(store.lastUsedId).toBe('a'); + }); +}); + +describe('SessionStore.notifyIfEmpty', () => { + test('calls onEmpty once when the last session has gone', () => { + const onEmpty = mock(() => {}); + const store = new SessionStore({ onEmpty }); + store.create('a'); + store.create('b'); + store.removeExited('a'); + store.notifyIfEmpty(); + expect(onEmpty).not.toHaveBeenCalled(); + store.removeExited('b'); + store.notifyIfEmpty(); + expect(onEmpty).toHaveBeenCalledTimes(1); + }); + + test('without onEmpty an empty store does nothing', () => { + expect(() => new SessionStore().notifyIfEmpty()).not.toThrow(); + }); +}); + +describe('SessionStore.findByPid', () => { + test('finds the session whose PTY has the process ID', () => { + const store = new SessionStore(); + store.create('idle'); + const running = store.create('running'); + running.pty = { pid: 4242 } as never; + expect(store.findByPid(4242)).toBe(running); + }); + + test('returns undefined when no PTY has the process ID', () => { + const store = new SessionStore(); + store.create('idle'); + expect(store.findByPid(4242)).toBeUndefined(); + }); +}); diff --git a/src/server/sessions/session-store.ts b/src/server/sessions/session-store.ts new file mode 100644 index 0000000..0065e11 --- /dev/null +++ b/src/server/sessions/session-store.ts @@ -0,0 +1,113 @@ +// SessionStore: the server's one registry of sessions, keyed by ID, plus the last-used +// ID that `GET /` redirects to (ADR 062). An object created once in index.ts and passed +// to the routes and the WebSocket server instead of a module-global Map. +import { newSession, type Session } from '../session'; + +/** What the store needs from its owner: who to tell once the last session has gone. */ +export interface SessionStoreOptions { + /** Called by {@link SessionStore.notifyIfEmpty} when no session is left (stops the server). */ + onEmpty?: () => void; +} + +/** Owns every live session so routes and sockets share one registry that tests can build fresh. */ +export class SessionStore { + private readonly sessions = new Map<string, Session>(); + private readonly onEmpty: (() => void) | undefined; + private lastUsed: string | null = null; + + constructor(options: SessionStoreOptions = {}) { + this.onEmpty = options.onEmpty; + } + + /** The ID of the session a client attached to most recently, for the `GET /` redirect. */ + get lastUsedId(): string | null { + return this.lastUsed; + } + + /** The number of registered sessions (the `X-Sessions-Remaining` header). */ + get size(): number { + return this.sessions.size; + } + + /** + * Creates a session and registers it under `id`. Callers check {@link has} first and + * answer 409 themselves. + * + * @param id - The session ID. + * @param baseDir - Working directory for the PTY shell (default: the user's home). + * @returns The new session. + */ + create(id: string, baseDir?: string): Session { + const session = newSession(id, baseDir); + this.sessions.set(id, session); + return session; + } + + /** Looks a session up by ID. */ + get(id: string): Session | undefined { + return this.sessions.get(id); + } + + /** Whether a session is registered under `id`. */ + has(id: string): boolean { + return this.sessions.has(id); + } + + /** Every session in creation order, for `GET /api/sessions` and shutdown. */ + list(): Session[] { + return [...this.sessions.values()]; + } + + /** The first session whose PTY has process ID `pid`, for the `/p/:pid` redirect. */ + findByPid(pid: number): Session | undefined { + return this.list().find((s) => s.pty?.pid === pid); + } + + /** Records `id` as the last-used session; a client has just attached to it. */ + markUsed(id: string): void { + this.lastUsed = id; + } + + /** + * Re-registers the session `id` under `newId` and updates its `id` field. The last-used + * ID follows the rename. The caller has already checked that `newId` is free. + * + * @returns The renamed session, or `undefined` if `id` is not registered. + */ + rename(id: string, newId: string): Session | undefined { + const session = this.sessions.get(id); + if (!session) return undefined; + this.sessions.delete(id); + session.id = newId; + this.sessions.set(newId, session); + if (this.lastUsed === id) this.lastUsed = newId; + return session; + } + + /** + * Unregisters a session deleted through the API and forgets it as the last-used one. + * + * @returns The removed session, or `undefined` if `id` is not registered. + */ + remove(id: string): Session | undefined { + const session = this.sessions.get(id); + if (!session) return undefined; + this.sessions.delete(id); + if (this.lastUsed === id) this.lastUsed = null; + return session; + } + + /** + * Unregisters a session whose shell exited. Unlike {@link remove} the last-used ID is + * kept, as it always was: `GET /` skips an ID that is no longer registered, and still + * returns to it if a session of the same name is created again. + */ + removeExited(id: string): void { + this.sessions.delete(id); + } + + /** Calls `onEmpty` if no session is left; the PTY exit path calls this after its flush. */ + notifyIfEmpty(): void { + if (this.sessions.size === 0) this.onEmpty?.(); + } +} diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index 85f4127..da6108a 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -329,6 +329,30 @@ describe('websocket', () => { expect(res.headers.get('location')).toBe('/s/ws-test-pid-route'); }); + test('GET / redirects to the last-used session, through a rename and until it is deleted', async () => { + const redirect = async () => + (await fetch(`${baseUrl}/`, { redirect: 'manual' })).headers.get('location'); + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'ws-test-last-used' }), + }); + const { ws, messages } = await connectWs(`${wsBase}/ws/ws-test-last-used/pty?cols=80&rows=24`); + await waitForMessages(messages, 1); + await closeWs(ws); + expect(await redirect()).toBe('/s/ws-test-last-used'); + + await fetch(`${baseUrl}/api/sessions/ws-test-last-used`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'ws-test-last-renamed' }), + }); + expect(await redirect()).toBe('/s/ws-test-last-renamed'); + + await fetch(`${baseUrl}/api/sessions/ws-test-last-renamed`, { method: 'DELETE' }); + expect(await redirect()).toBe('/s/main'); + }); + test('GET /ws/:id/events rejects with 4001 for non-existent session', async () => { const ws = new WebSocket(`${wsBase}/ws/no-such-session/events`); const code = await new Promise<number>((resolve) => ws.on('close', (c) => resolve(c))); diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 5ed3d85..1a423b2 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -5,8 +5,8 @@ import { loadConfig } from '../config'; import { spawnForSession } from '../pty'; import { packageRunner } from '../runtime'; import type { Session } from './session'; -import { sessionRegistry, setLastUsedId } from './session'; import { sessionBanner } from './sessions/session-banner'; +import type { SessionStore } from './sessions/session-store'; import { parseClientMessage, sizeFromQuery } from './ws/client-message'; import { WS_CLOSE } from './ws/close-codes'; @@ -26,9 +26,14 @@ export function closeSession(session: Session): void { closeClients(session, WS_CLOSE.SESSION_GONE, 'session deleted'); } -/** Closes all WebSocket clients across every active session with the SERVER_STOPPED close code. */ -export function closeAllSessions(): void { - for (const session of sessionRegistry.values()) { +/** + * Closes all WebSocket clients across every session in `store` with the SERVER_STOPPED + * close code. + * + * @param store - The server's sessions. + */ +export function closeAllSessions(store: SessionStore): void { + for (const session of store.list()) { closeClients(session, WS_CLOSE.SERVER_STOPPED, 'server stopped'); } } @@ -45,21 +50,10 @@ export function broadcastToSubscribers(session: Session, line: string): void { } } -let onLastSessionClosed: (() => void) | null = null; - -/** - * Registers a callback invoked once the last session closes (e.g. to stop the HTTP server). - * - * @param handler - Callback to invoke when the last session closes. - */ -export function setLastSessionClosedHandler(handler: () => void): void { - onLastSessionClosed = handler; -} - const PKG_RUNNER = packageRunner(); -function handleSubscribe(ws: WS, id: string): void { - const session = sessionRegistry.get(id); +function handleSubscribe(store: SessionStore, ws: WS, id: string): void { + const session = store.get(id); if (!session) { ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); return; @@ -81,9 +75,13 @@ function handleSubscribe(ws: WS, id: string): void { * and event subscriptions for `/ws/:id/events` connections. * * @param httpServer - The HTTP server to attach the WebSocket server to. + * @param store - The server's sessions; its `onEmpty` runs once the last shell has exited. * @returns The configured {@link WebSocketServer}. */ -export function createWebSocketServer(httpServer: http.Server): WebSocketServer { +export function createWebSocketServer( + httpServer: http.Server, + store: SessionStore, +): WebSocketServer { const wss = new WebSocketServer({ noServer: true }); httpServer.on('upgrade', (req, socket, head) => { @@ -118,18 +116,18 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer } if (eventsMatch) { - handleSubscribe(ws, id); + handleSubscribe(store, ws, id); return; } const { cols, rows } = sizeFromQuery(url.searchParams); - if (!sessionRegistry.has(id)) { + if (!store.has(id)) { ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); return; } - const session = sessionRegistry.get(id); + const session = store.get(id); if (!session) { ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); return; @@ -138,7 +136,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer // A client that acknowledges output says so in the URL; one without it predates // ADR 048 (a tab loaded before an upgrade) and is sent output unpaced. session.output.add(ws, url.searchParams.get('flow') === '1'); - setLastUsedId(id); + store.markUsed(id); if (!session.pty) { const config = loadConfig(); @@ -158,7 +156,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer }); session.pty.onExit(() => { - sessionRegistry.delete(session.id); + store.removeExited(session.id); for (const sub of session.subscribers) { if (sub.readyState === sub.OPEN) { sub.close(WS_CLOSE.SESSION_GONE, 'shell exited'); @@ -169,7 +167,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer // A client behind the output is sent what is queued for it before it is closed, // so the last lines the shell wrote are not lost (ADR 048). session.output.end(WS_CLOSE.SESSION_GONE, 'shell exited').then(() => { - if (sessionRegistry.size === 0) onLastSessionClosed?.(); + store.notifyIfEmpty(); }); }); From 27c1f0f010953306cc706867f05fb06b49958efe Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:22:39 -0400 Subject: [PATCH 167/295] refactor(client): split the find bar into find-format, find-keys and a view that takes its document find-format.ts holds the pure count wording, find-keys.ts the pure field key mapping (it takes the shortcut lookup, so features/find imports no other feature), and find-bar.ts is the view only. The bar takes its document and timers as deps with the browser's as defaults, registers its listeners through listen() into a DisposableStore and gains an idempotent dispose(); index.ts does not call it, so nothing changes at runtime. The old find-bar tests move to find-keys.test.ts and find-format.test.ts (table-driven); find-bar.test.ts is new and runs the view on an injected happy-dom document with a ManualClock: open/close/go, the 150 ms debounce, the isComposing skip, the re-search on reopen, the live region and dispose. createDomWindow's window is typed as the global window so tests reach its event constructors. --- docs/specs/client.md | 2 +- src/client/features/find/find-bar.test.ts | 279 +++++++++++++++---- src/client/features/find/find-bar.ts | 190 ++++++------- src/client/features/find/find-format.test.ts | 26 ++ src/client/features/find/find-format.ts | 22 ++ src/client/features/find/find-keys.test.ts | 58 ++++ src/client/features/find/find-keys.ts | 36 +++ src/client/test-support/dom-window.ts | 5 +- 8 files changed, 454 insertions(+), 164 deletions(-) create mode 100644 src/client/features/find/find-format.test.ts create mode 100644 src/client/features/find/find-format.ts create mode 100644 src/client/features/find/find-keys.test.ts create mode 100644 src/client/features/find/find-keys.ts diff --git a/docs/specs/client.md b/docs/specs/client.md index 39cafb8..e559eb2 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -216,7 +216,7 @@ The canvas is invisible to the browser's own find, so webtty has Ghostty's searc | all | Escape | close | the app gets the key (bar closed) | | in the field | Enter / Shift+Enter | next / previous match | | -Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `features/find/find-bar.ts`). +Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `features/find/find-keys.ts`). - **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. - **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). diff --git a/src/client/features/find/find-bar.test.ts b/src/client/features/find/find-bar.test.ts index e5e313f..6934113 100644 --- a/src/client/features/find/find-bar.test.ts +++ b/src/client/features/find/find-bar.test.ts @@ -1,75 +1,238 @@ -import { describe, expect, test } from 'bun:test'; -import { shortcutFor } from '../keyboard/shortcut-map'; -import { countDescription, countLabel, type FieldKeyEvent, fieldKey } from './find-bar'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../test-support/dom-window'; +import { ManualClock } from '../../test-support/manual-clock'; +import { FIND_DELAY_MS, FindBar } from './find-bar'; +import type { FieldKeyEvent } from './find-keys'; -describe('countLabel', () => { - test("shows Ghostty's selected/total, 0 before a match is selected", () => { - expect(countLabel({ total: 0, selected: null })).toBe('0/0'); - expect(countLabel({ total: 12, selected: null })).toBe('0/12'); - expect(countLabel({ total: 12, selected: 0 })).toBe('1/12'); - expect(countLabel({ total: 12, selected: 11 })).toBe('12/12'); +let dom: DomWindow; +afterEach(() => dom.close()); + +/** A bar in a fresh happy-dom body, its requests logged, its delay on a manual clock. */ +function setup(theme: Record<string, string | undefined> = {}) { + dom = createDomWindow(); + const { document, window } = dom; + const clock = new ManualClock(); + const calls: string[] = []; + // Ctrl+G and Ctrl+Shift+G stand in for the keyboard feature's find shortcuts. + const lookup = (e: FieldKeyEvent) => + e.ctrlKey && e.code === 'KeyG' ? (e.shiftKey ? 'findPrevious' : 'findNext') : undefined; + const bar = new FindBar( + document.body, + theme, + lookup, + { + find: (needle) => calls.push(`find:${needle}`), + next: () => calls.push('next'), + previous: () => calls.push('previous'), + close: () => calls.push('close'), + }, + { + document, + setTimeout: clock.setTimeout, + clearTimeout: (id) => clock.clearTimeout(id as number), + }, + ); + const root = document.querySelector('.find-bar') as HTMLElement; + const input = document.getElementById('find-bar-input') as HTMLInputElement; + const buttons = [...root.querySelectorAll('button')]; + const count = root.querySelector('.find-bar-count') as HTMLElement; + const status = root.querySelector('[role="status"]') as HTMLElement; + const type = (text: string) => { + input.value = text; + input.dispatchEvent(new window.Event('input')); + }; + const key = (init: KeyboardEventInit) => { + const event = new window.KeyboardEvent('keydown', { cancelable: true, ...init }); + input.dispatchEvent(event); + return event; + }; + return { document, bar, clock, calls, root, input, buttons, count, status, type, key }; +} + +describe('FindBar', () => { + test('builds a hidden, labelled search region in the parent, coloured from the theme', () => { + const { document, root, input, buttons, count, status } = setup({ + background: '#000', + foreground: '#fff', + brightBlack: '#888', + }); + expect(root.parentElement).toBe(document.body); + expect(root.hidden).toBe(true); + expect(root.getAttribute('role')).toBe('search'); + expect(document.querySelector('label')?.htmlFor).toBe('find-bar-input'); + expect(input.placeholder).toBe('Find…'); + expect(buttons.map((b) => b.getAttribute('aria-label'))).toEqual([ + 'Next match (older)', + 'Previous match (newer)', + 'Close', + ]); + expect(root.style.getPropertyValue('--find-bg')).toBe('#000'); + expect(root.style.getPropertyValue('--find-fg')).toBe('#fff'); + expect(root.style.getPropertyValue('--find-border')).toBe('#888'); + expect(count.textContent).toBe('0/0'); + expect(status.textContent).toBe('No matches'); }); -}); -describe('countDescription', () => { - test('says the count in words for a screen reader', () => { - expect(countDescription({ total: 0, selected: null })).toBe('No matches'); - expect(countDescription({ total: 1, selected: null })).toBe('1 match'); - expect(countDescription({ total: 3, selected: null })).toBe('3 matches'); - expect(countDescription({ total: 3, selected: 1 })).toBe('Match 2 of 3'); + test('the border falls back to the foreground, and a theme without colours sets none', async () => { + expect(setup({ foreground: '#eee' }).root.style.getPropertyValue('--find-border')).toBe('#eee'); + await dom.close(); + const { root } = setup(); + expect(root.getAttribute('style') ?? '').toBe(''); }); -}); -describe('fieldKey', () => { - // The lookup the root passes in (ADR 060). - const lookup = (mac: boolean) => (e: FieldKeyEvent) => shortcutFor(e, mac)?.action; - const key = (k: string, code: string, mods: string[] = []) => ({ - key: k, - code, - ctrlKey: mods.includes('ctrl'), - shiftKey: mods.includes('shift'), - altKey: mods.includes('alt'), - metaKey: mods.includes('meta'), + test('open shows the bar with the field focused; close hides it and ends the search', () => { + const { document, bar, calls, root, input } = setup(); + bar.open(); + expect([bar.isOpen, root.hidden]).toEqual([true, false]); + expect(document.activeElement).toBe(input); + // An empty field is not searched. + expect(calls).toEqual([]); + bar.close(); + expect([bar.isOpen, root.hidden]).toEqual([false, true]); + expect(calls).toEqual(['close']); + bar.close(); + expect(calls).toEqual(['close']); }); - test('Enter goes to the next match (older), Shift+Enter to the previous', () => { - for (const mac of [false, true]) { - expect(fieldKey(key('Enter', 'Enter'), lookup(mac))).toBe('next'); - expect(fieldKey(key('Enter', 'NumpadEnter'), lookup(mac))).toBe('next'); - expect(fieldKey(key('Enter', 'Enter', ['shift']), lookup(mac))).toBe('previous'); - expect(fieldKey(key('Enter', 'Enter', ['ctrl']), lookup(mac))).toBeNull(); - } + test('typing searches after the delay, restarted by each keystroke', () => { + const { bar, clock, calls, type } = setup(); + bar.open(); + type('a'); + clock.advance(FIND_DELAY_MS - 1); + type('ab'); + clock.advance(FIND_DELAY_MS - 1); + expect(calls).toEqual([]); + clock.advance(1); + expect(calls).toEqual(['find:ab']); + }); + + test('reopened, it searches the text it kept again', () => { + const { bar, clock, calls, type } = setup(); + bar.open(); + type('abc'); + clock.advance(FIND_DELAY_MS); + bar.close(); + bar.open(); + expect(calls).toEqual(['find:abc', 'close', 'find:abc']); + // Open while open only refocuses. + bar.open(); + expect(calls).toHaveLength(3); + }); + + test('closing drops a pending search', () => { + const { bar, clock, calls, type } = setup(); + bar.open(); + type('abc'); + bar.close(); + clock.advance(FIND_DELAY_MS); + expect(calls).toEqual(['close']); + }); + + test('go searches pending text first, then steps; it does nothing while closed', () => { + const { bar, clock, calls, type } = setup(); + bar.go('next'); + expect(calls).toEqual([]); + bar.open(); + type('x'); + bar.go('next'); + bar.go('previous'); + expect(calls).toEqual(['find:x', 'next', 'previous']); + clock.advance(FIND_DELAY_MS); + expect(calls).toHaveLength(3); + }); + + test('Enter, Shift+Enter, the find shortcuts and Escape in the field', () => { + const { bar, calls, key } = setup(); + bar.open(); + expect(key({ key: 'Enter', code: 'Enter' }).defaultPrevented).toBe(true); + key({ key: 'Enter', code: 'Enter', shiftKey: true }); + key({ key: 'g', code: 'KeyG', ctrlKey: true }); + key({ key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true }); + expect(calls).toEqual(['next', 'previous', 'next', 'previous']); + // A key that is the field's own is left alone. + expect(key({ key: 'a', code: 'KeyA' }).defaultPrevented).toBe(false); + key({ key: 'Escape', code: 'Escape', ctrlKey: false }); + expect(calls.at(-1)).toBe('previous'); + }); + + test('Escape closes, through the lookup', () => { + dom = createDomWindow(); + const { document, window } = dom; + const calls: string[] = []; + const bar = new FindBar( + document.body, + {}, + (e) => (e.key === 'Escape' ? 'findClose' : e.key === 'F3' ? 'find' : undefined), + { + find: () => calls.push('find'), + next: () => calls.push('next'), + previous: () => calls.push('previous'), + close: () => calls.push('close'), + }, + { document, setTimeout: () => 0, clearTimeout: () => {} }, + ); + bar.open(); + const input = document.getElementById('find-bar-input') as HTMLInputElement; + input.value = 'abc'; + input.setSelectionRange(3, 3); + input.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'F3', cancelable: true })); + expect([input.selectionStart, input.selectionEnd]).toEqual([0, 3]); + input.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + expect(calls).toEqual(['close']); + expect(bar.isOpen).toBe(false); }); - test('Escape closes', () => { - expect(fieldKey(key('Escape', 'Escape'), lookup(false))).toBe('close'); - expect(fieldKey(key('Escape', 'Escape'), lookup(true))).toBe('close'); + test('while composing (IME), Enter commits the text and is not a search step', () => { + const { bar, calls, key } = setup(); + bar.open(); + const event = key({ key: 'Enter', code: 'Enter', isComposing: true }); + expect(event.defaultPrevented).toBe(false); + expect(calls).toEqual([]); }); - test('the find shortcuts work in the field: Cmd+G, Cmd+Shift+F, and the open key selects', () => { - expect(fieldKey(key('g', 'KeyG', ['meta']), lookup(true))).toBe('next'); - expect(fieldKey(key('G', 'KeyG', ['meta', 'shift']), lookup(true))).toBe('previous'); - expect(fieldKey(key('F', 'KeyF', ['meta', 'shift']), lookup(true))).toBe('close'); - expect(fieldKey(key('f', 'KeyF', ['meta']), lookup(true))).toBe('select'); - expect(fieldKey(key('F', 'KeyF', ['ctrl', 'shift']), lookup(false))).toBe('select'); + test('the buttons step and close', () => { + const { bar, calls, buttons } = setup(); + bar.open(); + for (const b of buttons) b.click(); + expect(calls).toEqual(['next', 'previous', 'close']); }); - test('Enter is the field’s own key: the lookup is not asked', () => { - let asked = 0; - const counting = (e: FieldKeyEvent) => { - asked++; - return shortcutFor(e, true)?.action; - }; - expect(fieldKey(key('Enter', 'Enter'), counting)).toBe('next'); - expect(asked).toBe(0); - expect(fieldKey(key('Escape', 'Escape'), counting)).toBe('close'); - expect(asked).toBe(1); + test('update writes the count, and the live region only when the words change', () => { + const { bar, count, status } = setup(); + const writes: string[] = []; + // Log every write to the live region: a screen reader announces each one. + let proto = Object.getPrototypeOf(status); + while (proto && !Object.getOwnPropertyDescriptor(proto, 'textContent')) { + proto = Object.getPrototypeOf(proto); + } + const descriptor = Object.getOwnPropertyDescriptor(proto, 'textContent'); + Object.defineProperty(status, 'textContent', { + get: () => descriptor?.get?.call(status), + set: (value: string) => { + writes.push(value); + descriptor?.set?.call(status, value); + }, + }); + bar.update({ total: 3, selected: null }); + bar.update({ total: 3, selected: null }); + bar.update({ total: 3, selected: 1 }); + expect(count.textContent).toBe('2/3'); + expect(status.textContent).toBe('Match 2 of 3'); + expect(writes).toEqual(['3 matches', 'Match 2 of 3']); }); - test('other keys are the field’s: typing, copy, paste, clear', () => { - expect(fieldKey(key('a', 'KeyA'), lookup(false))).toBeNull(); - expect(fieldKey(key('v', 'KeyV', ['ctrl']), lookup(false))).toBeNull(); - expect(fieldKey(key('k', 'KeyK', ['meta']), lookup(true))).toBeNull(); - expect(fieldKey(key('C', 'KeyC', ['ctrl', 'shift']), lookup(false))).toBeNull(); + test('dispose removes the bar and its listeners and drops a pending search; twice is a no-op', () => { + const { document, bar, clock, calls, input, buttons, type } = setup(); + bar.open(); + type('abc'); + bar.dispose(); + expect(document.querySelector('.find-bar')).toBeNull(); + expect(clock.pending.timers).toBe(0); + type('abcd'); + buttons[0].click(); + expect(clock.pending.timers).toBe(0); + expect(calls).toEqual([]); + bar.dispose(); + expect(input.isConnected).toBe(false); }); }); diff --git a/src/client/features/find/find-bar.ts b/src/client/features/find/find-bar.ts index 8fb74b0..cadd0e3 100644 --- a/src/client/features/find/find-bar.ts +++ b/src/client/features/find/find-bar.ts @@ -1,5 +1,5 @@ /** - * The find bar (ADR 055): Ghostty's search overlay as a small DOM element at the top + * The find bar (view, ADR 055): Ghostty's search overlay as a small DOM element at the top * right of the terminal. The canvas is invisible to the browser's own find, so this * finds with libghostty-vt's search through the terminal. * @@ -9,12 +9,11 @@ * previous match, Escape closes. The count is Ghostty's `selected/total`: `0/n` until a * match is selected. */ -/** The match count and the selected match's index (0 the newest), as the terminal reports them. */ -export interface FindState { - total: number; - selected: number | null; -} +import { DisposableStore, listen, toDisposable } from '../../vendor/ghostty-web'; +import { countDescription, countLabel, type FindState } from './find-format'; +import { fieldKey, type ShortcutActionOf } from './find-keys'; +/** What the bar asks the terminal to do. */ export interface FindBarActions { find(needle: string): void; next(): void; @@ -23,130 +22,78 @@ export interface FindBarActions { close(): void; } -/** GtkSearchEntry's default search delay. */ -export const FIND_DELAY_MS = 150; - -/** The count as Ghostty's search overlay shows it. */ -export function countLabel(state: FindState): string { - if (state.total === 0) return '0/0'; - return `${state.selected === null ? 0 : state.selected + 1}/${state.total}`; +/** The browser objects the bar builds its DOM in and times its search delay with. */ +export interface FindBarDeps { + document: Document; + setTimeout(run: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; } -/** The count for a screen reader. */ -export function countDescription(state: FindState): string { - if (state.total === 0) return 'No matches'; - const matches = state.total === 1 ? '1 match' : `${state.total} matches`; - if (state.selected === null) return matches; - return `Match ${state.selected + 1} of ${state.total}`; -} +/** The colours the bar takes from the terminal theme. */ +export type FindBarTheme = Record<string, string | undefined>; -/** The key fields the find field reads. */ -export type FieldKeyEvent = Pick< - KeyboardEvent, - 'key' | 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey' ->; - -/** - * The shortcut action a key is, if any. The root passes the keyboard feature's lookup in, - * so features/find does not import features/keyboard (ADR 060). - */ -export type ShortcutActionOf = (e: FieldKeyEvent) => string | undefined; - -/** What a key in the find field does: the field's own keys, then the find shortcuts. */ -export function fieldKey( - e: FieldKeyEvent, - shortcutActionOf: ShortcutActionOf, -): 'next' | 'previous' | 'close' | 'select' | null { - if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { - return e.shiftKey ? 'previous' : 'next'; - } - switch (shortcutActionOf(e)) { - case 'findClose': - return 'close'; - case 'findNext': - return 'next'; - case 'findPrevious': - return 'previous'; - case 'find': - return 'select'; - } - return null; -} +/** GtkSearchEntry's default search delay. */ +export const FIND_DELAY_MS = 150; +/** The find bar: built in the constructor, appended to `parent`, removed by `dispose()`. */ export class FindBar { private readonly root: HTMLElement; private readonly input: HTMLInputElement; private readonly count: HTMLElement; private readonly status: HTMLElement; - private timer: ReturnType<typeof setTimeout> | null = null; + private readonly store = new DisposableStore(); + private timer: unknown = null; /** The text the terminal searches for: the field's, once the delay has passed. */ private searched = ''; private shown = false; constructor( parent: HTMLElement, - theme: Record<string, string | undefined>, + theme: FindBarTheme, private readonly shortcutActionOf: ShortcutActionOf, private readonly actions: FindBarActions, + private readonly deps: FindBarDeps = { + document: parent.ownerDocument, + setTimeout: (run, ms) => setTimeout(run, ms), + clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>), + }, ) { - const root = document.createElement('div'); - root.className = 'find-bar'; - root.setAttribute('role', 'search'); - root.hidden = true; - const style = root.style; - if (theme.background) style.setProperty('--find-bg', theme.background); - if (theme.foreground) style.setProperty('--find-fg', theme.foreground); - const border = theme.brightBlack ?? theme.foreground; - if (border) style.setProperty('--find-border', border); - - const label = document.createElement('label'); + const doc = deps.document; + this.root = doc.createElement('div'); + this.root.className = 'find-bar'; + this.root.setAttribute('role', 'search'); + this.root.hidden = true; + applyTheme(this.root.style, theme); + + const label = doc.createElement('label'); label.className = 'find-bar-hidden'; label.htmlFor = 'find-bar-input'; label.textContent = 'Find in terminal'; - const input = document.createElement('input'); - input.id = 'find-bar-input'; - input.type = 'text'; - input.placeholder = 'Find…'; - input.autocomplete = 'off'; - input.spellcheck = false; - input.addEventListener('input', () => this.schedule()); - input.addEventListener('keydown', (e) => this.onKey(e)); - - const count = document.createElement('span'); - count.className = 'find-bar-count'; - count.setAttribute('aria-hidden', 'true'); - const status = document.createElement('span'); - status.className = 'find-bar-hidden'; - status.setAttribute('role', 'status'); + this.input = this.createInput(); + this.count = doc.createElement('span'); + this.count.className = 'find-bar-count'; + this.count.setAttribute('aria-hidden', 'true'); + this.status = doc.createElement('span'); + this.status.className = 'find-bar-hidden'; + this.status.setAttribute('role', 'status'); // ↑ is next (older output), as in Ghostty - const button = (text: string, title: string, run: () => void) => { - const b = document.createElement('button'); - b.type = 'button'; - b.textContent = text; - b.title = title; - b.setAttribute('aria-label', title); - b.addEventListener('click', run); - return b; - }; - root.append( + this.root.append( label, - input, - count, - status, - button('↑', 'Next match (older)', () => this.go('next')), - button('↓', 'Previous match (newer)', () => this.go('previous')), - button('×', 'Close', () => this.close()), + this.input, + this.count, + this.status, + this.button('↑', 'Next match (older)', () => this.go('next')), + this.button('↓', 'Previous match (newer)', () => this.go('previous')), + this.button('×', 'Close', () => this.close()), ); - parent.appendChild(root); - this.root = root; - this.input = input; - this.count = count; - this.status = status; + parent.appendChild(this.root); + this.store.add(toDisposable(() => this.root.remove())); this.update({ total: 0, selected: null }); } + /** Whether the bar is showing, so the shortcuts know whether next/previous apply. */ get isOpen(): boolean { return this.shown; } @@ -183,12 +130,41 @@ export class FindBar { else this.actions.previous(); } + /** Shows the terminal's latest count; the live region is written only when it changes. */ update(state: FindState): void { this.count.textContent = countLabel(state); const description = countDescription(state); if (this.status.textContent !== description) this.status.textContent = description; } + /** Removes the bar and its listeners and drops a pending search. Idempotent. */ + dispose(): void { + this.cancel(); + this.store.dispose(); + } + + private createInput(): HTMLInputElement { + const input = this.deps.document.createElement('input'); + input.id = 'find-bar-input'; + input.type = 'text'; + input.placeholder = 'Find…'; + input.autocomplete = 'off'; + input.spellcheck = false; + this.store.add(listen(input, 'input', () => this.schedule())); + this.store.add(listen(input, 'keydown', (e) => this.onKey(e))); + return input; + } + + private button(text: string, title: string, run: () => void): HTMLButtonElement { + const b = this.deps.document.createElement('button'); + b.type = 'button'; + b.textContent = text; + b.title = title; + b.setAttribute('aria-label', title); + this.store.add(listen(b, 'click', run)); + return b; + } + private onKey(e: KeyboardEvent): void { // Composing (IME): Enter commits the text, not a search step if (e.isComposing) return; @@ -202,11 +178,11 @@ export class FindBar { private schedule(): void { this.cancel(); - this.timer = setTimeout(() => this.flush(), FIND_DELAY_MS); + this.timer = this.deps.setTimeout(() => this.flush(), FIND_DELAY_MS); } private cancel(): void { - if (this.timer !== null) clearTimeout(this.timer); + if (this.timer !== null) this.deps.clearTimeout(this.timer); this.timer = null; } @@ -219,3 +195,11 @@ export class FindBar { this.actions.find(text); } } + +/** Colours the bar from the theme through the CSS variables index.css reads. */ +function applyTheme(style: CSSStyleDeclaration, theme: FindBarTheme): void { + if (theme.background) style.setProperty('--find-bg', theme.background); + if (theme.foreground) style.setProperty('--find-fg', theme.foreground); + const border = theme.brightBlack ?? theme.foreground; + if (border) style.setProperty('--find-border', border); +} diff --git a/src/client/features/find/find-format.test.ts b/src/client/features/find/find-format.test.ts new file mode 100644 index 0000000..ab69697 --- /dev/null +++ b/src/client/features/find/find-format.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { countDescription, countLabel, type FindState } from './find-format'; + +const state = (total: number, selected: number | null): FindState => ({ total, selected }); + +describe('countLabel', () => { + test.each([ + [state(0, null), '0/0'], + [state(12, null), '0/12'], + [state(12, 0), '1/12'], + [state(12, 11), '12/12'], + ])("shows Ghostty's selected/total, 0 before a match is selected: %o", (s, label) => { + expect(countLabel(s)).toBe(label); + }); +}); + +describe('countDescription', () => { + test.each([ + [state(0, null), 'No matches'], + [state(1, null), '1 match'], + [state(3, null), '3 matches'], + [state(3, 1), 'Match 2 of 3'], + ])('says the count in words for a screen reader: %o', (s, words) => { + expect(countDescription(s)).toBe(words); + }); +}); diff --git a/src/client/features/find/find-format.ts b/src/client/features/find/find-format.ts new file mode 100644 index 0000000..3902217 --- /dev/null +++ b/src/client/features/find/find-format.ts @@ -0,0 +1,22 @@ +// How the find bar words the match count (util, pure, ADR 055): Ghostty's `selected/total` +// for the eye and a sentence for a screen reader. + +/** The match count and the selected match's index (0 the newest), as the terminal reports them. */ +export interface FindState { + total: number; + selected: number | null; +} + +/** The count as Ghostty's search overlay shows it: `0/n` until a match is selected. */ +export function countLabel(state: FindState): string { + if (state.total === 0) return '0/0'; + return `${state.selected === null ? 0 : state.selected + 1}/${state.total}`; +} + +/** The count for a screen reader, announced through the bar's live region. */ +export function countDescription(state: FindState): string { + if (state.total === 0) return 'No matches'; + const matches = state.total === 1 ? '1 match' : `${state.total} matches`; + if (state.selected === null) return matches; + return `Match ${state.selected + 1} of ${state.total}`; +} diff --git a/src/client/features/find/find-keys.test.ts b/src/client/features/find/find-keys.test.ts new file mode 100644 index 0000000..34f7974 --- /dev/null +++ b/src/client/features/find/find-keys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; +import { shortcutFor } from '../keyboard/shortcut-map'; +import { type FieldKeyEvent, fieldKey } from './find-keys'; + +describe('fieldKey', () => { + // The lookup the root passes in (ADR 060). A test may import across features; the + // module itself only sees the lookup. + const lookup = (mac: boolean) => (e: FieldKeyEvent) => shortcutFor(e, mac)?.action; + const key = (k: string, code: string, mods: string[] = []) => ({ + key: k, + code, + ctrlKey: mods.includes('ctrl'), + shiftKey: mods.includes('shift'), + altKey: mods.includes('alt'), + metaKey: mods.includes('meta'), + }); + + test('Enter goes to the next match (older), Shift+Enter to the previous', () => { + for (const mac of [false, true]) { + expect(fieldKey(key('Enter', 'Enter'), lookup(mac))).toBe('next'); + expect(fieldKey(key('Enter', 'NumpadEnter'), lookup(mac))).toBe('next'); + expect(fieldKey(key('Enter', 'Enter', ['shift']), lookup(mac))).toBe('previous'); + expect(fieldKey(key('Enter', 'Enter', ['ctrl']), lookup(mac))).toBeNull(); + } + }); + + test('Escape closes', () => { + expect(fieldKey(key('Escape', 'Escape'), lookup(false))).toBe('close'); + expect(fieldKey(key('Escape', 'Escape'), lookup(true))).toBe('close'); + }); + + test('the find shortcuts work in the field: Cmd+G, Cmd+Shift+F, and the open key selects', () => { + expect(fieldKey(key('g', 'KeyG', ['meta']), lookup(true))).toBe('next'); + expect(fieldKey(key('G', 'KeyG', ['meta', 'shift']), lookup(true))).toBe('previous'); + expect(fieldKey(key('F', 'KeyF', ['meta', 'shift']), lookup(true))).toBe('close'); + expect(fieldKey(key('f', 'KeyF', ['meta']), lookup(true))).toBe('select'); + expect(fieldKey(key('F', 'KeyF', ['ctrl', 'shift']), lookup(false))).toBe('select'); + }); + + test('Enter is the field’s own key: the lookup is not asked', () => { + let asked = 0; + const counting = (e: FieldKeyEvent) => { + asked++; + return shortcutFor(e, true)?.action; + }; + expect(fieldKey(key('Enter', 'Enter'), counting)).toBe('next'); + expect(asked).toBe(0); + expect(fieldKey(key('Escape', 'Escape'), counting)).toBe('close'); + expect(asked).toBe(1); + }); + + test('other keys are the field’s: typing, copy, paste, clear', () => { + expect(fieldKey(key('a', 'KeyA'), lookup(false))).toBeNull(); + expect(fieldKey(key('v', 'KeyV', ['ctrl']), lookup(false))).toBeNull(); + expect(fieldKey(key('k', 'KeyK', ['meta']), lookup(true))).toBeNull(); + expect(fieldKey(key('C', 'KeyC', ['ctrl', 'shift']), lookup(false))).toBeNull(); + }); +}); diff --git a/src/client/features/find/find-keys.ts b/src/client/features/find/find-keys.ts new file mode 100644 index 0000000..ef22c46 --- /dev/null +++ b/src/client/features/find/find-keys.ts @@ -0,0 +1,36 @@ +// What a key typed in the find field does (util, pure, ADR 055, ADR 060): the field's own +// Enter keys first, then the find shortcuts through a lookup the root passes in, so +// features/find never imports features/keyboard. + +/** The key fields the find field reads. */ +export type FieldKeyEvent = Pick< + KeyboardEvent, + 'key' | 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey' +>; + +/** The shortcut action a key is, if any: the keyboard feature's lookup, passed in by the root. */ +export type ShortcutActionOf = (e: FieldKeyEvent) => string | undefined; + +/** What a key in the find field does, or null when it is the field's to type. */ +export type FieldKeyAction = 'next' | 'previous' | 'close' | 'select'; + +/** What a key in the find field does: the field's own keys, then the find shortcuts. */ +export function fieldKey( + e: FieldKeyEvent, + shortcutActionOf: ShortcutActionOf, +): FieldKeyAction | null { + if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { + return e.shiftKey ? 'previous' : 'next'; + } + switch (shortcutActionOf(e)) { + case 'findClose': + return 'close'; + case 'findNext': + return 'next'; + case 'findPrevious': + return 'previous'; + case 'find': + return 'select'; + } + return null; +} diff --git a/src/client/test-support/dom-window.ts b/src/client/test-support/dom-window.ts index 196df9a..798fb17 100644 --- a/src/client/test-support/dom-window.ts +++ b/src/client/test-support/dom-window.ts @@ -6,7 +6,8 @@ import { Window as HappyWindow } from 'happy-dom'; /** One test's DOM, typed as the browser's own, and the call that closes it. */ export interface DomWindow { - window: Window; + /** Typed as the browser's global `window`, so tests reach its constructors (`window.KeyboardEvent`). */ + window: Window & typeof globalThis; document: Document; /** Aborts happy-dom's pending tasks and closes the window; call it in the test's teardown. */ close(): Promise<void>; @@ -17,7 +18,7 @@ export function createDomWindow(url = 'http://localhost/s/test'): DomWindow { const happy = new HappyWindow({ url, width: 1024, height: 768 }); // happy-dom implements the DOM interfaces the code under test uses; lib.dom's types // name the full browser API, so the window is typed as the browser's here, once. - const window = happy as unknown as Window; + const window = happy as unknown as Window & typeof globalThis; return { window, document: window.document, From 0c503ee7d65dac3654c179929be9fe8613aa2f30 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:23:55 -0400 Subject: [PATCH 168/295] refactor(client): wheel classifier and scroll arithmetic get modules of their own WheelClassifier, adapted from VS Code's MouseWheelClassifier, moves to wheel-classifier.ts with the Microsoft MIT notice in its header, so wheel-stream.ts holds only the stream's pacing. splitScroll and wheelLines, the pure arithmetic of pixel-smooth scrolling, move to scroll-split.ts, so smooth-scroll.ts holds only SmoothScroll. No logic changes; the tests move with their code (table cases unchanged) plus one for the classifier's five-delta window. --- .../features/scroll/scroll-split.test.ts | 38 +++++++++++ src/client/features/scroll/scroll-split.ts | 31 +++++++++ .../features/scroll/smooth-scroll.test.ts | 38 +---------- src/client/features/scroll/smooth-scroll.ts | 31 +-------- .../features/scroll/wheel-classifier.test.ts | 63 ++++++++++++++++++ .../features/scroll/wheel-classifier.ts | 55 ++++++++++++++++ .../features/scroll/wheel-stream.test.ts | 47 +------------- src/client/features/scroll/wheel-stream.ts | 65 ++----------------- 8 files changed, 199 insertions(+), 169 deletions(-) create mode 100644 src/client/features/scroll/scroll-split.test.ts create mode 100644 src/client/features/scroll/scroll-split.ts create mode 100644 src/client/features/scroll/wheel-classifier.test.ts create mode 100644 src/client/features/scroll/wheel-classifier.ts diff --git a/src/client/features/scroll/scroll-split.test.ts b/src/client/features/scroll/scroll-split.test.ts new file mode 100644 index 0000000..69da26c --- /dev/null +++ b/src/client/features/scroll/scroll-split.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { splitScroll, wheelLines } from './scroll-split'; + +describe('splitScroll', () => { + test('a whole-line position has no offset', () => { + expect(splitScroll(3, 14, 2)).toEqual({ line: 3, offset: 0 }); + }); + + test('the fraction becomes a pixel offset below the whole line', () => { + expect(splitScroll(3.5, 14, 2)).toEqual({ line: 3, offset: 7 }); + }); + + test('snaps the offset to device pixels', () => { + // 0.3 x 14 = 4.2 CSS px = 8.4 device px at 2x, snapped to 8 device px. + expect(splitScroll(0.3, 14, 2)).toEqual({ line: 0, offset: 4 }); + expect(splitScroll(0.3, 14, 1)).toEqual({ line: 0, offset: 4 }); + expect(splitScroll(0.25, 14, 2)).toEqual({ line: 0, offset: 3.5 }); + }); + + test('rounding up to a full cell moves to the next line', () => { + expect(splitScroll(2.99, 14, 1)).toEqual({ line: 3, offset: 0 }); + }); +}); + +describe('wheelLines', () => { + test('converts pixel deltas by the cell height', () => { + expect(wheelLines({ deltaY: 7, deltaMode: 0 }, 14, 40)).toBe(0.5); + expect(wheelLines({ deltaY: -28, deltaMode: 0 }, 14, 40)).toBe(-2); + }); + + test('passes line deltas through', () => { + expect(wheelLines({ deltaY: 3, deltaMode: 1 }, 14, 40)).toBe(3); + }); + + test('scales page deltas by the row count', () => { + expect(wheelLines({ deltaY: -1, deltaMode: 2 }, 14, 40)).toBe(-40); + }); +}); diff --git a/src/client/features/scroll/scroll-split.ts b/src/client/features/scroll/scroll-split.ts new file mode 100644 index 0000000..77f21a0 --- /dev/null +++ b/src/client/features/scroll/scroll-split.ts @@ -0,0 +1,31 @@ +// The arithmetic of pixel-smooth scrolling (util, pure, ADR 040, ADR 043): a fractional +// scroll position as whole lines plus a device-pixel offset, and a wheel event's delta in +// lines. + +/** A scroll position as ghostty-web's whole-line viewport and the renderer's pixel offset. */ +export interface ScrollSplit { + /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ + line: number; + /** Extra offset in CSS pixels, snapped to device pixels, in `[0, cellHeight)`. */ + offset: number; +} + +/** Splits a fractional scroll `position` (lines up from the bottom) into whole lines and a pixel offset. */ +export function splitScroll(position: number, cellHeight: number, dpr: number): ScrollSplit { + const line = Math.floor(position); + const offset = Math.round((position - line) * cellHeight * dpr) / dpr; + // Rounding can reach a full cell; that is the next whole line with no offset. + if (offset >= cellHeight) return { line: line + 1, offset: 0 }; + return { line, offset }; +} + +/** Lines a wheel event scrolls toward the bottom (negative: toward the top of scrollback). */ +export function wheelLines( + e: Pick<WheelEvent, 'deltaY' | 'deltaMode'>, + cellHeight: number, + rows: number, +): number { + if (e.deltaMode === 1) return e.deltaY; // DOM_DELTA_LINE + if (e.deltaMode === 2) return e.deltaY * rows; // DOM_DELTA_PAGE + return e.deltaY / cellHeight; +} diff --git a/src/client/features/scroll/smooth-scroll.test.ts b/src/client/features/scroll/smooth-scroll.test.ts index 558b57e..dbc272e 100644 --- a/src/client/features/scroll/smooth-scroll.test.ts +++ b/src/client/features/scroll/smooth-scroll.test.ts @@ -1,44 +1,8 @@ import { describe, expect, test } from 'bun:test'; import type { Terminal } from '../../vendor/ghostty-web'; -import { SmoothScroll, splitScroll, wheelLines } from './smooth-scroll'; +import { SmoothScroll } from './smooth-scroll'; import { WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; -describe('splitScroll', () => { - test('a whole-line position has no offset', () => { - expect(splitScroll(3, 14, 2)).toEqual({ line: 3, offset: 0 }); - }); - - test('the fraction becomes a pixel offset below the whole line', () => { - expect(splitScroll(3.5, 14, 2)).toEqual({ line: 3, offset: 7 }); - }); - - test('snaps the offset to device pixels', () => { - // 0.3 x 14 = 4.2 CSS px = 8.4 device px at 2x, snapped to 8 device px. - expect(splitScroll(0.3, 14, 2)).toEqual({ line: 0, offset: 4 }); - expect(splitScroll(0.3, 14, 1)).toEqual({ line: 0, offset: 4 }); - expect(splitScroll(0.25, 14, 2)).toEqual({ line: 0, offset: 3.5 }); - }); - - test('rounding up to a full cell moves to the next line', () => { - expect(splitScroll(2.99, 14, 1)).toEqual({ line: 3, offset: 0 }); - }); -}); - -describe('wheelLines', () => { - test('converts pixel deltas by the cell height', () => { - expect(wheelLines({ deltaY: 7, deltaMode: 0 }, 14, 40)).toBe(0.5); - expect(wheelLines({ deltaY: -28, deltaMode: 0 }, 14, 40)).toBe(-2); - }); - - test('passes line deltas through', () => { - expect(wheelLines({ deltaY: 3, deltaMode: 1 }, 14, 40)).toBe(3); - }); - - test('scales page deltas by the row count', () => { - expect(wheelLines({ deltaY: -1, deltaMode: 2 }, 14, 40)).toBe(-40); - }); -}); - /** Scrolls made by `fake`, whose `frame` the render loop stand-in calls. */ const live: SmoothScroll[] = []; diff --git a/src/client/features/scroll/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts index 094fbf6..a48b7fb 100644 --- a/src/client/features/scroll/smooth-scroll.ts +++ b/src/client/features/scroll/smooth-scroll.ts @@ -1,5 +1,5 @@ /** - * Pixel-smooth scrollback scrolling. See ADR 040, ADR 043 and ADR 045. + * Pixel-smooth scrollback scrolling (object over the terminal, ADR 040, ADR 043, ADR 045). * * ghostty-web draws whole rows only: its viewport is a line count, and a fractional * viewportY is floored when rows are picked. A trackpad reports pixel deltas, so @@ -20,35 +20,10 @@ * when the app tracks the mouse, the app moves its own view in whole lines. */ import { Glide, type Terminal } from '../../vendor/ghostty-web'; +import { splitScroll, wheelLines } from './scroll-split'; import type { WheelStream } from './wheel-stream'; -export interface ScrollSplit { - /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ - line: number; - /** Extra offset in CSS pixels, snapped to device pixels, in `[0, cellHeight)`. */ - offset: number; -} - -/** Splits a fractional scroll `position` (lines up from the bottom) into whole lines and a pixel offset. */ -export function splitScroll(position: number, cellHeight: number, dpr: number): ScrollSplit { - const line = Math.floor(position); - const offset = Math.round((position - line) * cellHeight * dpr) / dpr; - // Rounding can reach a full cell; that is the next whole line with no offset. - if (offset >= cellHeight) return { line: line + 1, offset: 0 }; - return { line, offset }; -} - -/** Lines a wheel event scrolls toward the bottom (negative: toward the top of scrollback). */ -export function wheelLines( - e: Pick<WheelEvent, 'deltaY' | 'deltaMode'>, - cellHeight: number, - rows: number, -): number { - if (e.deltaMode === 1) return e.deltaY; // DOM_DELTA_LINE - if (e.deltaMode === 2) return e.deltaY * rows; // DOM_DELTA_PAGE - return e.deltaY / cellHeight; -} - +/** Scrolls the terminal's scrollback by pixels: a fractional position that glides. */ export class SmoothScroll { /** The position in lines scrolled up from the bottom, fractional between rows. */ private readonly glide = new Glide(); diff --git a/src/client/features/scroll/wheel-classifier.test.ts b/src/client/features/scroll/wheel-classifier.test.ts new file mode 100644 index 0000000..74c5105 --- /dev/null +++ b/src/client/features/scroll/wheel-classifier.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test'; +import { WheelClassifier } from './wheel-classifier'; + +describe('WheelClassifier', () => { + const feed = (deltas: [number, number][]) => { + const c = new WheelClassifier(); + for (const [x, y] of deltas) c.accept(x, y); + return c.isPhysicalMouseWheel(); + }; + + test('steady integer notches are a wheel', () => { + expect( + feed([ + [0, 4], + [0, 4], + [0, 8], + [0, 4], + ]), + ).toBe(true); + }); + + test('fractional, accelerating deltas are a trackpad', () => { + expect( + feed([ + [0, 1.5], + [0, 3.25], + [0, 7.75], + [0, 12.5], + ]), + ).toBe(false); + }); + + test('both axes at once are a trackpad', () => { + expect( + feed([ + [0, 4], + [0, 4], + [1, 4], + [2, 6], + ]), + ).toBe(false); + }); + + test('no events is not a wheel', () => { + expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); + }); + + test('only the latest five deltas count', () => { + // Three trackpad-like deltas, then five steady notches: the notches outweigh them. + expect( + feed([ + [1, 1.5], + [2, 3.25], + [3, 7.75], + [0, 4], + [0, 4], + [0, 4], + [0, 4], + [0, 4], + ]), + ).toBe(true); + }); +}); diff --git a/src/client/features/scroll/wheel-classifier.ts b/src/client/features/scroll/wheel-classifier.ts new file mode 100644 index 0000000..ee1c102 --- /dev/null +++ b/src/client/features/scroll/wheel-classifier.ts @@ -0,0 +1,55 @@ +// Tells a physical mouse wheel from a trackpad by its recent pixel deltas (object, ADR 045). +// Adapted from VS Code's `MouseWheelClassifier` (src/vs/base/browser/ui/scrollbar/ +// scrollableElement.ts), Copyright (c) Microsoft Corporation, MIT License. + +/** + * Scores each pixel delta as wheel-like or trackpad-like and weighs the latest ones, so + * `WheelStream` can tell a mouse wheel's notches from a trackpad's smooth deltas. + */ +export class WheelClassifier { + private static readonly CAPACITY = 5; + /** Scores of the latest events, newest last: 0 looks like a wheel, 1 like a trackpad. */ + private scores: number[] = []; + private previous: { deltaX: number; deltaY: number } | null = null; + + /** Takes one pixel delta (a `deltaMode` 0 event) into the recent history. */ + accept(deltaX: number, deltaY: number): void { + this.scores.push(this.score(deltaX, deltaY)); + if (this.scores.length > WheelClassifier.CAPACITY) this.scores.shift(); + this.previous = { deltaX, deltaY }; + } + + /** The newest score weighs 1/2, the next 1/4, and so on; the oldest takes the rest. */ + isPhysicalMouseWheel(): boolean { + if (this.scores.length === 0) return false; + let remaining = 1; + let total = 0; + for (let i = this.scores.length - 1; i >= 0; i--) { + const influence = i === 0 ? remaining : 2 ** -(this.scores.length - i); + remaining -= influence; + total += this.scores[i] * influence; + } + return total <= 0.5; + } + + private score(deltaX: number, deltaY: number): number { + // Both axes at once: a trackpad. + if (deltaX !== 0 && deltaY !== 0) return 1; + let score = 0.5; + if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; + // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. + const prev = this.previous; + if (prev) { + const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; + const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; + const minX = Math.max(Math.min(ax, px), 1); + const minY = Math.max(Math.min(ay, py), 1); + if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; + } + return Math.min(Math.max(score, 0), 1); + } +} + +function isAlmostInt(value: number): boolean { + return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; +} diff --git a/src/client/features/scroll/wheel-stream.test.ts b/src/client/features/scroll/wheel-stream.test.ts index 1419cf3..1e24f59 100644 --- a/src/client/features/scroll/wheel-stream.test.ts +++ b/src/client/features/scroll/wheel-stream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { STREAM_OMEGA, WHEEL_GLIDE_MS, WheelClassifier, WheelStream } from './wheel-stream'; +import { STREAM_OMEGA, WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; describe('WheelStream.forApp', () => { const scroll = () => new WheelStream(); @@ -63,51 +63,6 @@ describe('WheelStream.forApp', () => { }); }); -describe('WheelClassifier', () => { - const feed = (deltas: [number, number][]) => { - const c = new WheelClassifier(); - for (const [x, y] of deltas) c.accept(x, y); - return c.isPhysicalMouseWheel(); - }; - - test('steady integer notches are a wheel', () => { - expect( - feed([ - [0, 4], - [0, 4], - [0, 8], - [0, 4], - ]), - ).toBe(true); - }); - - test('fractional, accelerating deltas are a trackpad', () => { - expect( - feed([ - [0, 1.5], - [0, 3.25], - [0, 7.75], - [0, 12.5], - ]), - ).toBe(false); - }); - - test('both axes at once are a trackpad', () => { - expect( - feed([ - [0, 4], - [0, 4], - [1, 4], - [2, 6], - ]), - ).toBe(false); - }); - - test('no events is not a wheel', () => { - expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); - }); -}); - describe('WheelStream at the start of a gesture', () => { const at = (deltaY: number, timeStamp: number) => ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; diff --git a/src/client/features/scroll/wheel-stream.ts b/src/client/features/scroll/wheel-stream.ts index 899b87e..fe8ff84 100644 --- a/src/client/features/scroll/wheel-stream.ts +++ b/src/client/features/scroll/wheel-stream.ts @@ -1,15 +1,15 @@ /** - * How a wheel event should move: tells a mouse wheel, a trackpad and a sparse - * stream such as a TrackPoint's apart, and picks the `Pace` that the scrollback - * glide (smooth-scroll.ts) and the ticks paced to apps (TickPacer) follow. See - * ADR 045. + * How a wheel event should move (object, ADR 045): tells a mouse wheel, a trackpad + * and a sparse stream such as a TrackPoint's apart, and picks the `Pace` that the + * scrollback glide (smooth-scroll.ts) and the ticks paced to apps (TickPacer) follow. * * - A wheel notch eases out over the wheel glide time, or, soon after one in the * same direction, continues linearly over its gap plus a frame, so a spinning * wheel does not pulse. * - A sparse delta, 25 ms or more after the previous one (from the second such gap - * in a row), glides linearly over that gap plus a frame, so a stream scrolls at the speed it is sent at; the - * extra frame keeps the glide going when the next delta is a little late. A + * in a row), glides linearly over that gap plus a frame, so a stream scrolls at + * the speed it is sent at; the extra frame keeps the glide going when the next + * delta is a little late. A * stream stays sparse until two short gaps in a row, so one delta that comes a * little early does not snap or flush. * - While a sparse stream's deltas come every 60 ms or faster on average, it @@ -18,6 +18,7 @@ * - Trackpad deltas, a frame apart, move at once. */ import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from '../../vendor/ghostty-web'; +import { WheelClassifier } from './wheel-classifier'; /** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ export const WHEEL_GLIDE_MS = 125; @@ -46,58 +47,6 @@ export const FAST_STREAM_MS = 60; /** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ export const STREAM_OMEGA = 28; -/** - * Tells a physical mouse wheel from a trackpad by its recent pixel deltas. - * Adapted from VS Code's `MouseWheelClassifier` (scrollableElement.ts), - * Copyright (c) Microsoft Corporation, MIT License. - */ -export class WheelClassifier { - private static readonly CAPACITY = 5; - /** Scores of the latest events, newest last: 0 looks like a wheel, 1 like a trackpad. */ - private scores: number[] = []; - private previous: { deltaX: number; deltaY: number } | null = null; - - accept(deltaX: number, deltaY: number): void { - this.scores.push(this.score(deltaX, deltaY)); - if (this.scores.length > WheelClassifier.CAPACITY) this.scores.shift(); - this.previous = { deltaX, deltaY }; - } - - /** The newest score weighs 1/2, the next 1/4, and so on; the oldest takes the rest. */ - isPhysicalMouseWheel(): boolean { - if (this.scores.length === 0) return false; - let remaining = 1; - let total = 0; - for (let i = this.scores.length - 1; i >= 0; i--) { - const influence = i === 0 ? remaining : 2 ** -(this.scores.length - i); - remaining -= influence; - total += this.scores[i] * influence; - } - return total <= 0.5; - } - - private score(deltaX: number, deltaY: number): number { - // Both axes at once: a trackpad. - if (deltaX !== 0 && deltaY !== 0) return 1; - let score = 0.5; - if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; - // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. - const prev = this.previous; - if (prev) { - const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; - const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; - const minX = Math.max(Math.min(ax, px), 1); - const minY = Math.max(Math.min(ay, py), 1); - if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; - } - return Math.min(Math.max(score, 0), 1); - } -} - -function isAlmostInt(value: number): boolean { - return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; -} - /** The OS asks for less motion. */ function reducedMotion(): boolean { return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; From d51917ec3bdd721fdace656404ae5b088eee7fbc Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:25:09 -0400 Subject: [PATCH 169/295] refactor(client): edge rects split from the edge extender view, which takes its document computeEdgeRects and its Rect types move to layout/edge-rects.ts (pure); its tests move with it (git mv) plus a negative-padding case. EdgeExtender is the view only: it takes its document and the window's devicePixelRatio and animation frames as deps with the browser's as defaults, and gains an idempotent dispose() that cancels the paint frame and removes the strips (ADR 060; nothing calls it at runtime). The new edge-extender.test.ts runs it on an injected happy-dom document with a ManualClock: placement in device pixels, the per-frame edge copies, skipping an unchanged version, and dispose. --- src/client/layout/edge-extender.test.ts | 185 ++++++++++++++++++++---- src/client/layout/edge-extender.ts | 80 ++++------ src/client/layout/edge-rects.test.ts | 43 ++++++ src/client/layout/edge-rects.ts | 43 ++++++ 4 files changed, 277 insertions(+), 74 deletions(-) create mode 100644 src/client/layout/edge-rects.test.ts create mode 100644 src/client/layout/edge-rects.ts diff --git a/src/client/layout/edge-extender.test.ts b/src/client/layout/edge-extender.test.ts index 0466e25..1ad582f 100644 --- a/src/client/layout/edge-extender.test.ts +++ b/src/client/layout/edge-extender.test.ts @@ -1,37 +1,170 @@ -import { describe, expect, test } from 'bun:test'; -import { computeEdgeRects } from './edge-extender'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../test-support/dom-window'; +import { ManualClock } from '../test-support/manual-clock'; +import { EdgeExtender, type VisibleArea } from './edge-extender'; -const canvas = { left: 0, top: 0, width: 800, height: 400 }; +let dom: DomWindow; +afterEach(() => dom.close()); -describe('computeEdgeRects', () => { - test('puts a strip under and to the right of the canvas', () => { - expect(computeEdgeRects(canvas, { width: 809, height: 407 }, 0)).toEqual({ - bottom: { x: 0, y: 400, width: 800, height: 7 }, - right: { x: 800, y: 0, width: 9, height: 400 }, - }); +/** Gives `el` a layout box: happy-dom does no layout, so the test injects the sizes. */ +function measure(el: HTMLElement, sizes: Record<string, number>): void { + for (const [name, value] of Object.entries(sizes)) { + Object.defineProperty(el, name, { value, configurable: true }); + } +} + +/** + * A container with an 800x400 terminal canvas in a 809x407 box, so both strips show, and + * an extender over it. happy-dom has no 2D context, so this document's canvases get a + * recording one; `copies` logs each drawImage as `strip:sx,sy,sw,sh`. + */ +function setup(options: { visibleArea?: () => VisibleArea | undefined; noContext?: boolean } = {}) { + dom = createDomWindow(); + const { document } = dom; + const copies: string[] = []; + const create = document.createElement.bind(document); + document.createElement = ((tag: string) => { + const el = create(tag); + if (el instanceof dom.window.HTMLCanvasElement && !options.noContext) { + const ctx = { + imageSmoothingEnabled: true, + drawImage: (_src: unknown, sx: number, sy: number, sw: number, sh: number) => + copies.push(`${el.dataset.which}:${sx},${sy},${sw},${sh}`), + }; + el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']; + } + return el; + }) as Document['createElement']; + const container = document.createElement('div'); + document.body.append(container); + const canvas = create('canvas'); + canvas.width = 1600; + canvas.height = 800; + measure(canvas, { offsetLeft: 0, offsetTop: 0, offsetWidth: 800, offsetHeight: 400 }); + container.append(canvas); + measure(container, { clientWidth: 809, clientHeight: 407 }); + const clock = new ManualClock(); + const win = { + devicePixelRatio: 2, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }; + const edges = new EdgeExtender(container, 0, options.visibleArea, { document, window: win }); + const strips = [...container.querySelectorAll<HTMLCanvasElement>('canvas[data-edge]')]; + if (strips.length === 2) { + strips[0].dataset.which = 'bottom'; + strips[1].dataset.which = 'right'; + } + return { container, canvas, edges, strips, clock, win, copies }; +} + +describe('EdgeExtender', () => { + test('adds two hidden, marked strips that the canvas lookups skip', () => { + const { container, canvas, strips } = setup(); + expect(strips).toHaveLength(2); + for (const strip of strips) { + expect(strip.style.display).toBe('none'); + expect(strip.style.position).toBe('absolute'); + expect(strip.style.pointerEvents).toBe('none'); + } + expect(container.querySelector('canvas:not([data-edge])')).toBe(canvas); }); - test('has no strip when the canvas fills the container exactly', () => { - expect(computeEdgeRects(canvas, { width: 800, height: 400 }, 0)).toEqual({ - bottom: null, - right: null, - }); + test('update places each strip in the leftover, sized in device pixels', () => { + const { edges, strips } = setup(); + edges.update(); + const [bottom, right] = strips; + expect(bottom.style.display).toBe('block'); + expect([bottom.style.left, bottom.style.top, bottom.style.width, bottom.style.height]).toEqual([ + '0px', + '400px', + '800px', + '7px', + ]); + expect([bottom.width, bottom.height]).toEqual([1600, 14]); + expect([right.style.left, right.style.top, right.style.width, right.style.height]).toEqual([ + '800px', + '0px', + '9px', + '400px', + ]); + expect([right.width, right.height]).toEqual([18, 800]); }); - test('leaves the configured padding on the far side to the padding colour', () => { - // padding 10: canvas at (10, 10); 16 left below the canvas, 10 of it padding. - const padded = { left: 10, top: 10, width: 800, height: 400 }; - expect(computeEdgeRects(padded, { width: 826, height: 426 }, 10)).toEqual({ - bottom: { x: 10, y: 410, width: 800, height: 6 }, - right: { x: 810, y: 10, width: 6, height: 400 }, - }); + test('a strip with nothing left over is hidden, and no frame is asked for', () => { + const { container, edges, strips, clock } = setup(); + measure(container, { clientWidth: 800, clientHeight: 400 }); + edges.update(); + expect(strips.map((s) => s.style.display)).toEqual(['none', 'none']); + expect(clock.pending.frames).toBe(0); + }); + + test('each frame copies the canvas edge row and column into the strips', () => { + const { edges, clock, copies } = setup(); + edges.update(); + clock.frame(); + expect(copies).toEqual(['bottom:0,799,1600,1', 'right:1599,0,1,800']); + // Without a version the canvas may have changed: copied again every frame. + clock.frame(); + expect(copies).toHaveLength(4); }); - test('has no strip when only the padding is left', () => { - const padded = { left: 10, top: 10, width: 800, height: 400 }; - expect(computeEdgeRects(padded, { width: 820, height: 420 }, 10)).toEqual({ - bottom: null, - right: null, + test('copies the part in view of the canvas the visible area names', () => { + let version = 1; + const { edges, clock, copies, canvas } = setup({ + visibleArea: () => ({ canvas, y: 40, height: 760, lastLine: 790, version }), }); + edges.update(); + clock.frame(); + expect(copies).toEqual(['bottom:0,790,1600,1', 'right:1599,40,1,760']); + // An unchanged version copies nothing but keeps watching. + clock.frame(); + expect(copies).toHaveLength(2); + expect(clock.pending.frames).toBe(1); + version = 2; + clock.frame(); + expect(copies).toHaveLength(4); + // A new placement repaints even when the version did not change. + edges.update(); + clock.frame(); + expect(copies).toHaveLength(6); + }); + + test('nothing to copy from stops the loop', () => { + const { edges, clock, copies, canvas } = setup(); + edges.update(); + canvas.width = 0; + clock.frame(); + expect(copies).toEqual([]); + expect(clock.pending.frames).toBe(0); + }); + + test('update without a terminal canvas does nothing', () => { + const { edges, canvas, clock, strips } = setup(); + canvas.remove(); + edges.update(); + expect(strips.map((s) => s.style.display)).toEqual(['none', 'none']); + expect(clock.pending.frames).toBe(0); + }); + + test('without a 2D context there are no strips', () => { + const { edges, strips, clock } = setup({ noContext: true }); + expect(strips).toHaveLength(0); + edges.update(); + expect(clock.pending.frames).toBe(0); + }); + + test('dispose removes the strips and cancels the frame; twice is a no-op', () => { + const { container, edges, clock, copies } = setup(); + edges.update(); + expect(clock.pending.frames).toBe(1); + edges.dispose(); + expect(container.querySelectorAll('canvas[data-edge]')).toHaveLength(0); + expect(clock.pending.frames).toBe(0); + clock.frame(); + edges.update(); + expect(copies).toEqual([]); + expect(clock.pending.frames).toBe(0); + edges.dispose(); }); }); diff --git a/src/client/layout/edge-extender.ts b/src/client/layout/edge-extender.ts index bd1f7a3..6d2856a 100644 --- a/src/client/layout/edge-extender.ts +++ b/src/client/layout/edge-extender.ts @@ -1,51 +1,13 @@ /** * Extends the terminal's edge colours into the leftover strip at the right and bottom, - * like Ghostty's `window-padding-color = extend`. See ADR 035. + * like Ghostty's `window-padding-color = extend` (view, ADR 035). * * The canvas is fitted to whole cells, so less than one cell of the container is left * over. Each strip repaints the canvas' last pixel row (bottom) or column (right) * stretched across the leftover, so the strip takes on whatever background the edge * cells have: a dark sidebar stays dark, the main area stays light. */ - -export interface Rect { - x: number; - y: number; - width: number; - height: number; -} - -export interface EdgeRects { - bottom: Rect | null; - right: Rect | null; -} - -/** - * Where the strips go, in CSS pixels relative to the container's padding box. `canvas` is - * the terminal canvas box and `container` its container's client size. The configured - * `padding` on the far side of each strip is left to the padding colour. - */ -export function computeEdgeRects( - canvas: { left: number; top: number; width: number; height: number }, - container: { width: number; height: number }, - padding: number, -): EdgeRects { - const p = Math.max(0, padding); - const bottomY = canvas.top + canvas.height; - const rightX = canvas.left + canvas.width; - const bottomHeight = container.height - bottomY - p; - const rightWidth = container.width - rightX - p; - return { - bottom: - bottomHeight > 0 - ? { x: canvas.left, y: bottomY, width: canvas.width, height: bottomHeight } - : null, - right: - rightWidth > 0 - ? { x: rightX, y: canvas.top, width: rightWidth, height: canvas.height } - : null, - }; -} +import { computeEdgeRects, type Rect } from './edge-rects'; /** A canvas and the rows of it in view, in canvas pixels. */ export interface VisibleArea { @@ -61,14 +23,20 @@ export interface VisibleArea { version?: number; } +/** The browser objects the strips are built in and painted with. */ +export interface EdgeExtenderDeps { + document: Document; + window: Pick<Window, 'devicePixelRatio' | 'requestAnimationFrame' | 'cancelAnimationFrame'>; +} + interface Strip { el: HTMLCanvasElement; ctx: CanvasRenderingContext2D; visible: boolean; } -function createStrip(): Strip | null { - const el = document.createElement('canvas'); +function createStrip(doc: Document): Strip | null { + const el = doc.createElement('canvas'); const ctx = el.getContext('2d'); if (!ctx) return null; el.style.position = 'absolute'; @@ -95,12 +63,14 @@ function place(strip: Strip, rect: Rect | null, dpr: number): void { strip.ctx.imageSmoothingEnabled = false; } +/** The two strips, placed after each fit and repainted from the canvas edges each frame. */ export class EdgeExtender { - private readonly bottom = createStrip(); - private readonly right = createStrip(); + private readonly bottom: Strip | null; + private readonly right: Strip | null; private frame: number | null = null; /** What the strips were last copied from, so an unchanged frame copies nothing. */ private painted = ''; + private disposed = false; constructor( private readonly container: HTMLElement, @@ -110,7 +80,10 @@ export class EdgeExtender { * that is not the terminal canvas (ADR 043). Undefined: all of the terminal canvas. */ private readonly visibleArea: () => VisibleArea | undefined = () => undefined, + private readonly deps: EdgeExtenderDeps = { document: container.ownerDocument, window }, ) { + this.bottom = createStrip(deps.document); + this.right = createStrip(deps.document); // Marked so the lookups of the terminal canvas skip them. for (const strip of [this.bottom, this.right]) { if (strip) container.append(strip.el); @@ -119,6 +92,7 @@ export class EdgeExtender { /** Reposition the strips. Call after every fit, when the canvas or padding may have moved. */ update(): void { + if (this.disposed) return; const canvas = this.terminalCanvas(); if (!canvas) return; const rects = computeEdgeRects( @@ -131,15 +105,25 @@ export class EdgeExtender { { width: this.container.clientWidth, height: this.container.clientHeight }, this.padding, ); - const dpr = window.devicePixelRatio || 1; + const dpr = this.deps.window.devicePixelRatio || 1; if (this.bottom) place(this.bottom, rects.bottom, dpr); if (this.right) place(this.right, rects.right, dpr); this.painted = ''; if (this.frame === null && (this.bottom?.visible || this.right?.visible)) { - this.frame = window.requestAnimationFrame(this.paint); + this.frame = this.deps.window.requestAnimationFrame(this.paint); } } + /** Stops the paint loop and removes the strips. Idempotent. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.frame !== null) this.deps.window.cancelAnimationFrame(this.frame); + this.frame = null; + this.bottom?.el.remove(); + this.right?.el.remove(); + } + private terminalCanvas(): HTMLCanvasElement | null { return this.container.querySelector('canvas:not([data-edge])'); } @@ -162,7 +146,7 @@ export class EdgeExtender { const unchanged = from !== '' && from === this.painted; this.painted = from; if (unchanged) { - this.frame = window.requestAnimationFrame(this.paint); + this.frame = this.deps.window.requestAnimationFrame(this.paint); return; } if (this.bottom?.visible) { @@ -174,7 +158,7 @@ export class EdgeExtender { ctx.drawImage(src, src.width - 1, area.y, 1, area.height, 0, 0, el.width, el.height); } if (this.bottom?.visible || this.right?.visible) { - this.frame = window.requestAnimationFrame(this.paint); + this.frame = this.deps.window.requestAnimationFrame(this.paint); } }; } diff --git a/src/client/layout/edge-rects.test.ts b/src/client/layout/edge-rects.test.ts new file mode 100644 index 0000000..a001764 --- /dev/null +++ b/src/client/layout/edge-rects.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import { computeEdgeRects } from './edge-rects'; + +const canvas = { left: 0, top: 0, width: 800, height: 400 }; + +describe('computeEdgeRects', () => { + test('puts a strip under and to the right of the canvas', () => { + expect(computeEdgeRects(canvas, { width: 809, height: 407 }, 0)).toEqual({ + bottom: { x: 0, y: 400, width: 800, height: 7 }, + right: { x: 800, y: 0, width: 9, height: 400 }, + }); + }); + + test('has no strip when the canvas fills the container exactly', () => { + expect(computeEdgeRects(canvas, { width: 800, height: 400 }, 0)).toEqual({ + bottom: null, + right: null, + }); + }); + + test('leaves the configured padding on the far side to the padding colour', () => { + // padding 10: canvas at (10, 10); 16 left below the canvas, 10 of it padding. + const padded = { left: 10, top: 10, width: 800, height: 400 }; + expect(computeEdgeRects(padded, { width: 826, height: 426 }, 10)).toEqual({ + bottom: { x: 10, y: 410, width: 800, height: 6 }, + right: { x: 810, y: 10, width: 6, height: 400 }, + }); + }); + + test('has no strip when only the padding is left', () => { + const padded = { left: 10, top: 10, width: 800, height: 400 }; + expect(computeEdgeRects(padded, { width: 820, height: 420 }, 10)).toEqual({ + bottom: null, + right: null, + }); + }); + + test('a negative padding counts as none', () => { + expect(computeEdgeRects(canvas, { width: 809, height: 407 }, -4)).toEqual( + computeEdgeRects(canvas, { width: 809, height: 407 }, 0), + ); + }); +}); diff --git a/src/client/layout/edge-rects.ts b/src/client/layout/edge-rects.ts new file mode 100644 index 0000000..d790a64 --- /dev/null +++ b/src/client/layout/edge-rects.ts @@ -0,0 +1,43 @@ +// Where the edge strips go (util, pure, ADR 035): the leftover of the container at the +// right and bottom of the whole-cell canvas, less the configured padding. + +/** A box in CSS pixels, relative to the container's padding box. */ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** The two strips; null where nothing is left over. */ +export interface EdgeRects { + bottom: Rect | null; + right: Rect | null; +} + +/** + * Where the strips go, in CSS pixels relative to the container's padding box. `canvas` is + * the terminal canvas box and `container` its container's client size. The configured + * `padding` on the far side of each strip is left to the padding colour. + */ +export function computeEdgeRects( + canvas: { left: number; top: number; width: number; height: number }, + container: { width: number; height: number }, + padding: number, +): EdgeRects { + const p = Math.max(0, padding); + const bottomY = canvas.top + canvas.height; + const rightX = canvas.left + canvas.width; + const bottomHeight = container.height - bottomY - p; + const rightWidth = container.width - rightX - p; + return { + bottom: + bottomHeight > 0 + ? { x: canvas.left, y: bottomY, width: canvas.width, height: bottomHeight } + : null, + right: + rightWidth > 0 + ? { x: rightX, y: canvas.top, width: rightWidth, height: canvas.height } + : null, + }; +} From 026807e052eaec4c1db0c7710c54a05be2661aa7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:25:42 -0400 Subject: [PATCH 170/295] refactor(server): move session.ts into sessions/ A git mv with import paths updated; the Session record now sits beside the SessionStore, the ID and the banner modules (ADR 062). --- src/server/routes.ts | 2 +- src/server/sessions/session-store.ts | 2 +- src/server/{ => sessions}/session.test.ts | 0 src/server/{ => sessions}/session.ts | 4 ++-- src/server/websocket.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/server/{ => sessions}/session.test.ts (100%) rename src/server/{ => sessions}/session.ts (95%) diff --git a/src/server/routes.ts b/src/server/routes.ts index 41bbd96..961476a 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { loadConfig, toClientConfig } from '../config'; import { getVersion } from '../version'; import { GHOSTTY_WASM, serveFile } from './http/static-files'; -import { sessionToJson } from './session'; +import { sessionToJson } from './sessions/session'; import { generateId, isValidId } from './sessions/session-id'; import type { SessionStore } from './sessions/session-store'; import { broadcastToSubscribers, closeSession } from './websocket'; diff --git a/src/server/sessions/session-store.ts b/src/server/sessions/session-store.ts index 0065e11..b9b65e1 100644 --- a/src/server/sessions/session-store.ts +++ b/src/server/sessions/session-store.ts @@ -1,7 +1,7 @@ // SessionStore: the server's one registry of sessions, keyed by ID, plus the last-used // ID that `GET /` redirects to (ADR 062). An object created once in index.ts and passed // to the routes and the WebSocket server instead of a module-global Map. -import { newSession, type Session } from '../session'; +import { newSession, type Session } from './session'; /** What the store needs from its owner: who to tell once the last session has gone. */ export interface SessionStoreOptions { diff --git a/src/server/session.test.ts b/src/server/sessions/session.test.ts similarity index 100% rename from src/server/session.test.ts rename to src/server/sessions/session.test.ts diff --git a/src/server/session.ts b/src/server/sessions/session.ts similarity index 95% rename from src/server/session.ts rename to src/server/sessions/session.ts index bacf98c..14321d3 100644 --- a/src/server/session.ts +++ b/src/server/sessions/session.ts @@ -3,8 +3,8 @@ // of sessions is SessionStore in sessions/session-store.ts (ADR 062). import { homedir } from 'node:os'; import type { WebSocket as WS } from 'ws'; -import type { PtyProcess } from '../pty'; -import { OutputPacer } from './output-pacer'; +import type { PtyProcess } from '../../pty'; +import { OutputPacer } from '../output-pacer'; /** A running (or recently created) terminal session. */ export interface Session { diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 1a423b2..f6ac080 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -4,7 +4,7 @@ import { WebSocketServer } from 'ws'; import { loadConfig } from '../config'; import { spawnForSession } from '../pty'; import { packageRunner } from '../runtime'; -import type { Session } from './session'; +import type { Session } from './sessions/session'; import { sessionBanner } from './sessions/session-banner'; import type { SessionStore } from './sessions/session-store'; import { parseClientMessage, sizeFromQuery } from './ws/client-message'; From a45bd26a1714994c1e03b0fc52108877478d3230 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:26:49 -0400 Subject: [PATCH 171/295] refactor(client): composer geometry split from the composer view, which takes its document overlayBox, the overlay's place over the cursor cell, moves to composer-geometry.ts (pure, table-tested). Composer is the view only: it reads a narrow ComposerTerminal, takes its document and animation frames as deps with the browser's as defaults, registers its four container listeners through listen() into a DisposableStore (same targets, types and order) and gains an idempotent dispose() (ADR 060; nothing calls it at runtime). The new composer.test.ts runs it on an injected happy-dom container with a ManualClock: overlay placement, theme colours, following the cursor per frame, the end and focusout, and dispose. --- .../compose/composer-geometry.test.ts | 20 +++ .../features/compose/composer-geometry.ts | 30 ++++ src/client/features/compose/composer.test.ts | 143 ++++++++++++++++++ src/client/features/compose/composer.ts | 77 ++++++---- 4 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 src/client/features/compose/composer-geometry.test.ts create mode 100644 src/client/features/compose/composer-geometry.ts create mode 100644 src/client/features/compose/composer.test.ts diff --git a/src/client/features/compose/composer-geometry.test.ts b/src/client/features/compose/composer-geometry.test.ts new file mode 100644 index 0000000..d9b3961 --- /dev/null +++ b/src/client/features/compose/composer-geometry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { overlayBox } from './composer-geometry'; + +describe('overlayBox', () => { + const cell = { width: 9, height: 18 }; + + test.each([ + ['up to the canvas right edge', { left: 90, top: 36 }, 800, 710], + ['at least a cell at the right edge', { left: 795, top: 36 }, 800, 9], + ['a cell without a canvas', { left: 90, top: 36 }, null, 9], + ])('sits over the cursor cell, %s', (_name, spot, canvasRight, maxWidth) => { + expect(overlayBox(spot, cell, canvasRight)).toEqual({ + left: spot.left, + top: spot.top, + height: 18, + lineHeight: 18, + maxWidth, + }); + }); +}); diff --git a/src/client/features/compose/composer-geometry.ts b/src/client/features/compose/composer-geometry.ts new file mode 100644 index 0000000..604ee4d --- /dev/null +++ b/src/client/features/compose/composer-geometry.ts @@ -0,0 +1,30 @@ +// Where the composition overlay goes (util, pure, ADR 038): over the cursor cell, one cell +// high, and no wider than the canvas leaves to the right of the cursor. + +/** The overlay's box in CSS pixels, relative to the terminal container. */ +export interface OverlayBox { + left: number; + top: number; + height: number; + lineHeight: number; + maxWidth: number; +} + +/** + * The overlay's box for the cursor cell at `spot`, cells `cell` in size, with the canvas' + * right edge at `canvasRight` (null without a canvas: the overlay is at least a cell wide). + */ +export function overlayBox( + spot: { left: number; top: number }, + cell: { width: number; height: number }, + canvasRight: number | null, +): OverlayBox { + const right = canvasRight ?? spot.left; + return { + left: spot.left, + top: spot.top, + height: cell.height, + lineHeight: cell.height, + maxWidth: Math.max(cell.width, right - spot.left), + }; +} diff --git a/src/client/features/compose/composer.test.ts b/src/client/features/compose/composer.test.ts new file mode 100644 index 0000000..643f945 --- /dev/null +++ b/src/client/features/compose/composer.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../test-support/dom-window'; +import { ManualClock } from '../../test-support/manual-clock'; +import { Composer, type ComposerTerminal } from './composer'; + +let dom: DomWindow; +afterEach(() => dom.close()); + +/** + * A composer on an injected happy-dom container holding an 800 px wide terminal canvas, + * over a terminal whose cursor cell the test moves. + */ +function setup(theme: { foreground?: string; background?: string } = {}) { + dom = createDomWindow(); + const { document, window } = dom; + const container = document.createElement('div'); + document.body.append(container); + const canvas = document.createElement('canvas'); + Object.defineProperty(canvas, 'offsetLeft', { value: 4 }); + Object.defineProperty(canvas, 'offsetWidth', { value: 800 }); + container.append(canvas); + const cursor = { spot: { left: 94, top: 40 } as { left: number; top: number } | undefined }; + const term: ComposerTerminal = { + getCursorCellOffset: () => cursor.spot, + options: { fontFamily: 'Menlo', fontSize: 14 }, + renderer: { getMetrics: () => ({ width: 9, height: 20 }) }, + }; + const clock = new ManualClock(); + const composer = new Composer(term, container, theme, { + document, + window: { + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }, + }); + const overlay = container.querySelector('[data-compose]') as HTMLElement; + // happy-dom has no CompositionEvent of its own (its constructor is Event's), so the + // event carries its data as a plain field. + const fire = (type: string, data?: string) => + container.dispatchEvent( + type === 'focusout' + ? new window.FocusEvent('focusout') + : Object.assign(new window.Event(type), { data }), + ); + return { container, cursor, term, clock, composer, overlay, fire }; +} + +describe('Composer', () => { + test('adds a hidden overlay to the container', () => { + const { overlay } = setup(); + expect(overlay.style.display).toBe('none'); + expect(overlay.style.position).toBe('absolute'); + expect(overlay.style.pointerEvents).toBe('none'); + }); + + test('shows the composition over the cursor cell, in the font and theme colours', () => { + const { overlay, fire } = setup({ foreground: '#111111', background: '#eeeeee' }); + fire('compositionstart'); + fire('compositionupdate', 'に'); + expect(overlay.style.display).toBe('block'); + expect(overlay.textContent).toBe('に'); + const s = overlay.style; + expect([s.left, s.top, s.height, s.lineHeight, s.maxWidth]).toEqual([ + '94px', + '40px', + '20px', + '20px', + '710px', + ]); + expect([s.fontFamily, s.fontSize]).toEqual(['Menlo', '14px']); + expect([s.color, s.background]).toEqual(['#111111', '#eeeeee']); + }); + + test("without theme colours it uses ghostty-web's defaults", () => { + const { overlay, fire } = setup(); + fire('compositionupdate', 'a'); + expect([overlay.style.color, overlay.style.background]).toEqual(['#d4d4d4', '#1e1e1e']); + }); + + test('follows the cursor every frame while composing, and stops after the end', () => { + const { cursor, clock, overlay, fire } = setup(); + fire('compositionstart'); + expect(clock.pending.frames).toBe(1); + cursor.spot = { left: 103, top: 40 }; + clock.frame(); + expect(overlay.style.left).toBe('103px'); + fire('compositionend'); + expect([overlay.style.display, overlay.textContent]).toEqual(['none', '']); + clock.frame(); + expect(clock.pending.frames).toBe(0); + }); + + test('an empty update hides the overlay; focus leaving ends the composition', () => { + const { overlay, fire } = setup(); + fire('compositionupdate', 'ab'); + fire('compositionupdate', ''); + expect(overlay.style.display).toBe('none'); + fire('compositionupdate', 'abc'); + fire('focusout'); + expect([overlay.style.display, overlay.textContent]).toEqual(['none', '']); + }); + + test('without a cursor cell the overlay stays where it was', () => { + const { cursor, overlay, fire, composer } = setup(); + fire('compositionupdate', 'a'); + cursor.spot = undefined; + composer.update(); + expect(overlay.style.left).toBe('94px'); + }); + + test('without renderer metrics or a canvas the overlay is one empty cell', () => { + dom = createDomWindow(); + const container = dom.document.createElement('div'); + const term: ComposerTerminal = { + getCursorCellOffset: () => ({ left: 10, top: 0 }), + options: { fontFamily: 'Menlo', fontSize: 14 }, + }; + const composer = new Composer( + term, + container, + {}, + { + document: dom.document, + window: { requestAnimationFrame: () => 1, cancelAnimationFrame: () => {} }, + }, + ); + composer.update(); + const overlay = container.querySelector('[data-compose]') as HTMLElement; + expect([overlay.style.height, overlay.style.maxWidth]).toEqual(['0px', '0px']); + }); + + test('dispose removes the overlay and its listeners and cancels the frame; twice is a no-op', () => { + const { container, clock, composer, overlay, fire } = setup(); + fire('compositionstart'); + composer.dispose(); + expect(container.contains(overlay)).toBe(false); + expect(clock.pending.frames).toBe(0); + fire('compositionupdate', 'a'); + expect(overlay.textContent).toBe(''); + expect(clock.pending.frames).toBe(0); + composer.dispose(); + }); +}); diff --git a/src/client/features/compose/composer.ts b/src/client/features/compose/composer.ts index 3d664c8..e5da47b 100644 --- a/src/client/features/compose/composer.ts +++ b/src/client/features/compose/composer.ts @@ -1,5 +1,5 @@ /** - * Shows IME / dictation composition text at the terminal cursor. See ADR 038. + * Shows IME / dictation composition text at the terminal cursor (view, ADR 038). * * The vendored terminal keeps focus on its hidden textarea and anchors the textarea at * the cursor cell, so the OS dictation and candidate panels follow the cursor. The @@ -8,23 +8,44 @@ * ghostty-web's own compositionend handling. */ -import type { Terminal } from '../../vendor/ghostty-web'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import { type OverlayBox, overlayBox } from './composer-geometry'; // ghostty-web's defaults for a theme without foreground/background. const DEFAULT_FOREGROUND = '#d4d4d4'; const DEFAULT_BACKGROUND = '#1e1e1e'; +/** The box's properties, in the order they are written. */ +const BOX_PROPS = ['left', 'top', 'height', 'lineHeight', 'maxWidth'] as const; + +/** The part of the terminal the overlay reads: the cursor cell, the font and the cell size. */ +export interface ComposerTerminal { + getCursorCellOffset(): { left: number; top: number } | undefined; + readonly options: { fontFamily: string; fontSize: number }; + readonly renderer?: { getMetrics(): { width: number; height: number } | undefined }; +} + +/** The browser objects the overlay is built in and follows the cursor with. */ +export interface ComposerDeps { + document: Document; + window: Pick<Window, 'requestAnimationFrame' | 'cancelAnimationFrame'>; +} + +/** The composition overlay: follows the cursor every frame while a composition is open. */ export class Composer { - private readonly overlay = document.createElement('div'); + private readonly overlay: HTMLDivElement; + private readonly store = new DisposableStore(); private composing = false; private frame = 0; constructor( - private readonly term: Terminal, + private readonly term: ComposerTerminal, private readonly container: HTMLElement, private readonly theme: { foreground?: string; background?: string }, + private readonly deps: ComposerDeps = { document: container.ownerDocument, window }, ) { - const o = this.overlay; + const o = deps.document.createElement('div'); + this.overlay = o; o.dataset.compose = 'true'; o.style.position = 'absolute'; o.style.display = 'none'; @@ -35,21 +56,20 @@ export class Composer { o.style.textDecoration = 'underline'; container.append(o); - container.addEventListener('compositionstart', this.onCompositionStart); - container.addEventListener('compositionupdate', this.onCompositionUpdate); - container.addEventListener('compositionend', this.onCompositionEnd); - container.addEventListener('focusout', this.onCompositionEnd); + this.store.add(listen(container, 'compositionstart', this.onCompositionStart)); + this.store.add(listen(container, 'compositionupdate', this.onCompositionUpdate)); + this.store.add(listen(container, 'compositionend', this.onCompositionEnd)); + this.store.add(listen(container, 'focusout', this.onCompositionEnd)); } // The terminal has no per-frame cursor event, so the overlay follows the cursor - // every frame while composing. setPx only writes - // styles that changed. + // every frame while composing. setPx only writes styles that changed. private readonly tick = (): void => { if (!this.composing) { this.frame = 0; return; } - this.frame = window.requestAnimationFrame(this.tick); + this.frame = this.deps.window.requestAnimationFrame(this.tick); this.update(); }; @@ -59,15 +79,25 @@ export class Composer { if (spot) this.placeOverlay(spot); } + /** Removes the overlay and its listeners and stops following the cursor. Idempotent. */ + dispose(): void { + if (this.store.isDisposed) return; + this.store.dispose(); + this.composing = false; + if (this.frame) this.deps.window.cancelAnimationFrame(this.frame); + this.frame = 0; + this.overlay.remove(); + } + private readonly onCompositionStart = (): void => { this.composing = true; this.update(); - if (!this.frame) this.frame = window.requestAnimationFrame(this.tick); + if (!this.frame) this.frame = this.deps.window.requestAnimationFrame(this.tick); }; private readonly onCompositionUpdate = (e: CompositionEvent): void => { this.composing = true; - if (!this.frame) this.frame = window.requestAnimationFrame(this.tick); + if (!this.frame) this.frame = this.deps.window.requestAnimationFrame(this.tick); const text = e.data ?? ''; if (!text) { this.overlay.style.display = 'none'; @@ -86,14 +116,13 @@ export class Composer { private placeOverlay(spot: { left: number; top: number }): void { const o = this.overlay; - const cell = this.cell(); const canvas = this.terminalCanvas(); - const right = canvas ? canvas.offsetLeft + canvas.offsetWidth : spot.left; - setPx(o, 'left', spot.left); - setPx(o, 'top', spot.top); - setPx(o, 'height', cell.height); - setPx(o, 'lineHeight', cell.height); - setPx(o, 'maxWidth', Math.max(cell.width, right - spot.left)); + const box = overlayBox( + spot, + this.cell(), + canvas ? canvas.offsetLeft + canvas.offsetWidth : null, + ); + for (const prop of BOX_PROPS) setPx(o, prop, box[prop]); o.style.fontFamily = this.term.options.fontFamily; o.style.fontSize = `${this.term.options.fontSize}px`; o.style.color = this.theme.foreground ?? DEFAULT_FOREGROUND; @@ -110,11 +139,7 @@ export class Composer { } } -function setPx( - el: HTMLElement, - prop: 'left' | 'top' | 'height' | 'lineHeight' | 'maxWidth', - value: number, -): void { +function setPx(el: HTMLElement, prop: keyof OverlayBox, value: number): void { const px = `${value}px`; if (el.style[prop] !== px) el.style[prop] = px; } From af2623e7abd398699b6a63560a62ce9b2b76384b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:27:58 -0400 Subject: [PATCH 172/295] refactor(client): browser notifications adapter split from the desktop notifier browserNotifications and activates, the two places that touch the browser's Notification API and navigator.userActivation, move to browser-notifications.ts (adapter). browserNotifications takes the Notification constructor, narrowed to NotificationConstructor, with the page's as the default; desktop-notifier.ts keeps the DesktopNotifier object and the NotificationApi port it reads. The activates tests move with it; new tests drive the adapter with a fake Notification, and the notifier's default console log and swallowed permission rejection are now covered. --- docs/specs/client.md | 2 +- .../browser-notifications.test.ts | 79 +++++++++++++++++++ .../notifications/browser-notifications.ts | 51 ++++++++++++ .../notifications/desktop-notifier.test.ts | 52 +++++++++--- .../notifications/desktop-notifier.ts | 33 +------- src/client/index.ts | 7 +- 6 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 src/client/features/notifications/browser-notifications.test.ts create mode 100644 src/client/features/notifications/browser-notifications.ts diff --git a/docs/specs/client.md b/docs/specs/client.md index e559eb2..65e2b6f 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -316,7 +316,7 @@ Programs ask for desktop notifications with OSC 9 (`ESC ] 9 ; <body> BEL`, iTerm | `9;4`, `9;42 tests passed` (not a full ConEmu form) | a notification, as in Ghostty | | `777;<other>;…`, `777;notify;<title>` (no body) | nothing | -`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and `index.ts` passes it to `DesktopNotifier` (`features/notifications/desktop-notifier.ts`), which, as Ghostty on macOS: +`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and `index.ts` passes it to `DesktopNotifier` (`features/notifications/desktop-notifier.ts`, with the browser's API behind `browser-notifications.ts`), which, as Ghostty on macOS: - drops it while `ReplayWindow` says the output is replay; - shows at most one a second, and the same title and body at most once in five seconds; diff --git a/src/client/features/notifications/browser-notifications.test.ts b/src/client/features/notifications/browser-notifications.test.ts new file mode 100644 index 0000000..3a11659 --- /dev/null +++ b/src/client/features/notifications/browser-notifications.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { activates, browserNotifications } from './browser-notifications'; + +/** A stand-in for the browser's Notification constructor that logs what it is asked. */ +function fakeNotification(permission: NotificationPermission = 'default') { + const created: FakeNotification[] = []; + class FakeNotification { + static permission = permission; + static requests = 0; + static async requestPermission(): Promise<NotificationPermission> { + FakeNotification.requests++; + FakeNotification.permission = 'granted'; + return 'granted'; + } + onclick: ((event: Event) => unknown) | null = null; + closed = false; + constructor( + readonly title: string, + readonly options: { body: string; tag: string }, + ) { + created.push(this); + } + close(): void { + this.closed = true; + } + } + return { FakeNotification, created }; +} + +describe('browserNotifications', () => { + test('is undefined without the API', () => { + expect(browserNotifications(undefined)).toBeUndefined(); + }); + + test('reads the permission live and asks the constructor for it', async () => { + const { FakeNotification } = fakeNotification('default'); + const api = browserNotifications(FakeNotification); + expect(api?.permission()).toBe('default'); + expect(await api?.requestPermission()).toBe('granted'); + expect(FakeNotification.requests).toBe(1); + expect(api?.permission()).toBe('granted'); + }); + + test('shows a notification with its body and tag, and wires the click', () => { + const { FakeNotification, created } = fakeNotification('granted'); + let clicks = 0; + const shown = browserNotifications(FakeNotification)?.show( + 'vim', + { body: 'done', tag: 'main\0vim' }, + () => clicks++, + ); + expect(created).toHaveLength(1); + expect([created[0].title, created[0].options]).toEqual([ + 'vim', + { body: 'done', tag: 'main\0vim' }, + ]); + created[0].onclick?.(new Event('click')); + expect(clicks).toBe(1); + shown?.close(); + expect(created[0].closed).toBe(true); + }); + + test("defaults to the page's Notification, which Bun does not have", () => { + expect(browserNotifications()).toBeUndefined(); + }); +}); + +describe('activates', () => { + test('follows navigator.userActivation where there is one', () => { + expect(activates({ key: 'a' }, { isActive: false })).toBe(false); + expect(activates({ key: 'Escape' }, { isActive: true })).toBe(true); + }); + + test('without it, every input but Esc', () => { + expect(activates({ key: 'Escape' }, undefined)).toBe(false); + expect(activates({ key: 'a' }, undefined)).toBe(true); + expect(activates({}, undefined)).toBe(true); + }); +}); diff --git a/src/client/features/notifications/browser-notifications.ts b/src/client/features/notifications/browser-notifications.ts new file mode 100644 index 0000000..d37200e --- /dev/null +++ b/src/client/features/notifications/browser-notifications.ts @@ -0,0 +1,51 @@ +// The browser's side of desktop notifications (adapter, ADR 056): the Notification API +// behind the notifier's narrow NotificationApi, and whether an input gave the page the +// user activation a permission prompt needs. + +import type { NotificationApi } from './desktop-notifier'; + +/** The part of the browser's `Notification` constructor the adapter uses. */ +export interface NotificationConstructor { + readonly permission: NotificationPermission; + requestPermission(): Promise<NotificationPermission>; + new ( + title: string, + options: { body: string; tag: string }, + ): { onclick: ((event: Event) => unknown) | null; close(): void }; +} + +/** + * The browser's Notification API as the notifier sees it, or undefined where there is + * none (http, iOS Safari). `ctor` is the global `Notification` in the page. + */ +export function browserNotifications( + ctor: NotificationConstructor | undefined = typeof Notification === 'undefined' + ? undefined + : Notification, +): NotificationApi | undefined { + if (!ctor) return undefined; + return { + permission: () => ctor.permission, + requestPermission: () => ctor.requestPermission(), + show: (title, options, onClick) => { + const notification = new ctor(title, options); + notification.onclick = onClick; + return notification; + }, + }; +} + +/** + * Whether a key or click gave the page user activation, so a permission request from + * it can prompt. Esc and keys the browser reserves do not (HTML's activation-triggering + * input events); `navigator.userActivation` knows, and without it only Esc is ruled out. + */ +export function activates( + event: { key?: string }, + activation: { readonly isActive: boolean } | undefined = typeof navigator === 'undefined' + ? undefined + : navigator.userActivation, +): boolean { + if (activation) return activation.isActive; + return event.key !== 'Escape'; +} diff --git a/src/client/features/notifications/desktop-notifier.test.ts b/src/client/features/notifications/desktop-notifier.test.ts index edd2fca..9198ae9 100644 --- a/src/client/features/notifications/desktop-notifier.test.ts +++ b/src/client/features/notifications/desktop-notifier.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, spyOn, test } from 'bun:test'; import { - activates, DesktopNotifier, NOTIFY_INTERVAL_MS, NOTIFY_REPEAT_MS, @@ -183,17 +182,48 @@ describe('DesktopNotifier', () => { expect(throws.state.marks).toBe(1); expect(throws.logs[0]).toContain('Illegal constructor.'); }); -}); -describe('activates', () => { - test('follows navigator.userActivation where there is one', () => { - expect(activates({ key: 'a' }, { isActive: false })).toBe(false); - expect(activates({ key: 'Escape' }, { isActive: true })).toBe(true); + test('logs to the console by default', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + try { + const notifier = new DesktopNotifier( + undefined, + 'main', + { active: false }, + { + focused: () => false, + name: () => 'vim', + mark: () => {}, + focus: () => {}, + }, + ); + notifier.notify({ title: '', body: 'a' }, 0); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('no Notification API'); + } finally { + warn.mockRestore(); + } }); - test('without it, every input but Esc', () => { - expect(activates({ key: 'Escape' }, undefined)).toBe(false); - expect(activates({ key: 'a' }, undefined)).toBe(true); - expect(activates({}, undefined)).toBe(true); + test('a refused permission request is swallowed', async () => { + let asked = 0; + const notifier = new DesktopNotifier( + { + permission: () => 'default', + requestPermission: () => { + asked++; + return Promise.reject(new Error('blocked')); + }, + show: () => ({ close: () => {} }), + }, + 'main', + { active: false }, + { focused: () => false, name: () => 'vim', mark: () => {}, focus: () => {} }, + () => {}, + ); + notifier.notify({ title: '', body: 'a' }, 0); + notifier.userInput(true); + await Promise.resolve(); + expect(asked).toBe(1); }); }); diff --git a/src/client/features/notifications/desktop-notifier.ts b/src/client/features/notifications/desktop-notifier.ts index 02e134f..b6bf896 100644 --- a/src/client/features/notifications/desktop-notifier.ts +++ b/src/client/features/notifications/desktop-notifier.ts @@ -1,3 +1,7 @@ +// Shows a program's desktop notifications as Ghostty does (object, ADR 053, ADR 056): +// rate limits, the replay and focus checks, and the tab-title fallback. The browser's +// API comes in through NotificationApi (browser-notifications.ts). + /** A desktop notification the program asked for (OSC 9: no title; OSC 777). */ export interface NotificationRequest { title: string; @@ -39,35 +43,6 @@ export const NOTIFY_INTERVAL_MS = 1000; /** The same title and body at most once in five seconds, as Ghostty does. */ export const NOTIFY_REPEAT_MS = 5000; -/** The browser's Notification API, or undefined where there is none (http, iOS Safari). */ -export function browserNotifications(): NotificationApi | undefined { - if (typeof Notification === 'undefined') return undefined; - return { - permission: () => Notification.permission, - requestPermission: () => Notification.requestPermission(), - show: (title, options, onClick) => { - const notification = new Notification(title, options); - notification.onclick = onClick; - return notification; - }, - }; -} - -/** - * Whether a key or click gave the page user activation, so a permission request from - * it can prompt. Esc and keys the browser reserves do not (HTML's activation-triggering - * input events); `navigator.userActivation` knows, and without it only Esc is ruled out. - */ -export function activates( - event: { key?: string }, - activation: { readonly isActive: boolean } | undefined = typeof navigator === 'undefined' - ? undefined - : navigator.userActivation, -): boolean { - if (activation) return activation.isActive; - return event.key !== 'Escape'; -} - /** * Shows a program's desktop notifications (OSC 9, OSC 777) as Ghostty on macOS does * (ADR 056): not for the replay, at most one a second and the same one once in five diff --git a/src/client/index.ts b/src/client/index.ts index 9e62fe5..331aa9e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,11 +6,8 @@ import { Composer } from './features/compose/composer'; import { FindBar } from './features/find/find-bar'; import { isMacPlatform, shortcutFor } from './features/keyboard/shortcut-map'; import { KittyGraphics } from './features/kitty/graphics'; -import { - activates, - browserNotifications, - DesktopNotifier, -} from './features/notifications/desktop-notifier'; +import { activates, browserNotifications } from './features/notifications/browser-notifications'; +import { DesktopNotifier } from './features/notifications/desktop-notifier'; import { nonTextPaste } from './features/paste/non-text-paste'; import { SmoothScroll } from './features/scroll/smooth-scroll'; import { WheelStream } from './features/scroll/wheel-stream'; From a1e6c234347d6af23818077cd84109bc68988a03 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:31:17 -0400 Subject: [PATCH 173/295] test(server): a fake PTY and a fake socket in test-support/ Scriptable stand-ins that write every call to a shared log, so the session and WebSocket handler tests can assert the ADR 062 order without a shell or a network (ADR 064). --- src/server/websocket.ts | 213 -------------------------------- src/test-support/fake-pty.ts | 49 ++++++++ src/test-support/fake-socket.ts | 65 ++++++++++ 3 files changed, 114 insertions(+), 213 deletions(-) delete mode 100644 src/server/websocket.ts create mode 100644 src/test-support/fake-pty.ts create mode 100644 src/test-support/fake-socket.ts diff --git a/src/server/websocket.ts b/src/server/websocket.ts deleted file mode 100644 index f6ac080..0000000 --- a/src/server/websocket.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type http from 'node:http'; -import type { WebSocket as WS } from 'ws'; -import { WebSocketServer } from 'ws'; -import { loadConfig } from '../config'; -import { spawnForSession } from '../pty'; -import { packageRunner } from '../runtime'; -import type { Session } from './sessions/session'; -import { sessionBanner } from './sessions/session-banner'; -import type { SessionStore } from './sessions/session-store'; -import { parseClientMessage, sizeFromQuery } from './ws/client-message'; -import { WS_CLOSE } from './ws/close-codes'; - -function closeClients(session: Session, code: number, reason: string): void { - session.pty?.kill(); - for (const client of session.clients) client.close(code, reason); - for (const sub of session.subscribers) sub.close(code, reason); - session.subscribers.clear(); -} - -/** - * Closes all WebSocket clients for `session` with the SESSION_GONE close code. - * - * @param session - The session to close. - */ -export function closeSession(session: Session): void { - closeClients(session, WS_CLOSE.SESSION_GONE, 'session deleted'); -} - -/** - * Closes all WebSocket clients across every session in `store` with the SERVER_STOPPED - * close code. - * - * @param store - The server's sessions. - */ -export function closeAllSessions(store: SessionStore): void { - for (const session of store.list()) { - closeClients(session, WS_CLOSE.SERVER_STOPPED, 'server stopped'); - } -} - -/** - * Sends `line` to all open event subscribers of `session`. - * - * @param session - The session whose subscribers to notify. - * @param line - The line to send. - */ -export function broadcastToSubscribers(session: Session, line: string): void { - for (const sub of session.subscribers) { - if (sub.readyState === sub.OPEN) sub.send(line); - } -} - -const PKG_RUNNER = packageRunner(); - -function handleSubscribe(store: SessionStore, ws: WS, id: string): void { - const session = store.get(id); - if (!session) { - ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - return; - } - if (session.pty === null) { - ws.close(WS_CLOSE.PTY_NOT_RUNNING, 'PTY not running'); - return; - } - session.subscribers.add(ws); - ws.on('close', () => { - session.subscribers.delete(ws); - }); - ws.on('error', () => {}); -} - -/** - * Attaches a WebSocket server to `httpServer`, handling PTY I/O, session management, - * scrollback replay, and terminal resize for all `/ws/:id/pty` connections, - * and event subscriptions for `/ws/:id/events` connections. - * - * @param httpServer - The HTTP server to attach the WebSocket server to. - * @param store - The server's sessions; its `onEmpty` runs once the last shell has exited. - * @returns The configured {@link WebSocketServer}. - */ -export function createWebSocketServer( - httpServer: http.Server, - store: SessionStore, -): WebSocketServer { - const wss = new WebSocketServer({ noServer: true }); - - httpServer.on('upgrade', (req, socket, head) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); - if ( - url.pathname.match(/^\/ws\/([^/]+)\/pty$/) || - url.pathname.match(/^\/ws\/([^/]+)\/events$/) - ) { - wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); - } else { - socket.destroy(); - } - }); - - wss.on('connection', (ws: WS, req: http.IncomingMessage) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); - const ptyMatch = url.pathname.match(/^\/ws\/([^/]+)\/pty$/); - const eventsMatch = url.pathname.match(/^\/ws\/([^/]+)\/events$/); - const match = ptyMatch ?? eventsMatch; - - if (!match) { - ws.close(); - return; - } - - let id: string; - try { - id = decodeURIComponent(match[1]); - } catch { - ws.close(WS_CLOSE.BAD_REQUEST, 'Bad Request'); - return; - } - - if (eventsMatch) { - handleSubscribe(store, ws, id); - return; - } - - const { cols, rows } = sizeFromQuery(url.searchParams); - - if (!store.has(id)) { - ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - return; - } - - const session = store.get(id); - if (!session) { - ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - return; - } - session.clients.add(ws); - // A client that acknowledges output says so in the URL; one without it predates - // ADR 048 (a tab loaded before an upgrade) and is sent output unpaced. - session.output.add(ws, url.searchParams.get('flow') === '1'); - store.markUsed(id); - - if (!session.pty) { - const config = loadConfig(); - session.pty = spawnForSession( - cols, - rows, - config.shell, - config.term, - config.colorTerm, - session.baseDir, - config.env, - ); - - session.pty.onData((data: string) => { - session.scrollback = (session.scrollback + data).slice(-config.scrollback); - session.output.broadcast(data); - }); - - session.pty.onExit(() => { - store.removeExited(session.id); - for (const sub of session.subscribers) { - if (sub.readyState === sub.OPEN) { - sub.close(WS_CLOSE.SESSION_GONE, 'shell exited'); - } - } - session.subscribers.clear(); - session.pty = null; - // A client behind the output is sent what is queued for it before it is closed, - // so the last lines the shell wrote are not lost (ADR 048). - session.output.end(WS_CLOSE.SESSION_GONE, 'shell exited').then(() => { - store.notifyIfEmpty(); - }); - }); - - const banner = sessionBanner(PKG_RUNNER); - session.output.send(ws, banner); - session.scrollback = banner; - session.pty.write('\n'); - } else { - if (session.scrollback) { - session.output.send(ws, session.scrollback); - } - session.pty.resize(cols, rows); - } - - ws.on('message', (data: Buffer) => { - const message = data.toString('utf8'); - const msg = parseClientMessage(message); - try { - if (msg.type === 'ack') { - session.output.ack(ws, msg.chars); - return; - } - if (msg.type === 'resize') { - session.pty?.resize(msg.cols, msg.rows); - return; - } - } catch { - // A control message whose handling throws (node-pty refuses to resize a PTY that - // has exited) is written to the PTY as raw input, as it always has been. - } - session.pty?.write(message); - }); - - ws.on('close', () => { - session.clients.delete(ws); - session.output.remove(ws); - }); - - ws.on('error', () => {}); - }); - - return wss; -} diff --git a/src/test-support/fake-pty.ts b/src/test-support/fake-pty.ts new file mode 100644 index 0000000..7defe71 --- /dev/null +++ b/src/test-support/fake-pty.ts @@ -0,0 +1,49 @@ +// A scriptable stand-in for a PTY: records writes, resizes and kills in a shared log and +// lets a test emit output and exit. A test-support object for the session and socket tests +// (ADR 064). +import type { PtyProcess } from '../pty'; + +/** A PtyProcess that runs no shell, so session lifecycle tests are fast and deterministic. */ +export class FakePty implements PtyProcess { + private dataListener: ((data: string) => void) | null = null; + private exitListener: ((e: { exitCode: number }) => void) | null = null; + + /** + * @param log - Shared event log; entries read `pty.write <json>`, `pty.resize CxR`, `pty.kill`. + * @param pid - The process ID to report. + */ + constructor( + readonly log: string[] = [], + readonly pid = 4242, + ) {} + + onData(cb: (data: string) => void): void { + this.dataListener = cb; + } + + onExit(cb: (e: { exitCode: number }) => void): void { + this.exitListener = cb; + } + + write(data: string): void { + this.log.push(`pty.write ${JSON.stringify(data)}`); + } + + resize(cols: number, rows: number): void { + this.log.push(`pty.resize ${cols}x${rows}`); + } + + kill(): void { + this.log.push('pty.kill'); + } + + /** Delivers `data` as if the shell had written it. */ + emitData(data: string): void { + this.dataListener?.(data); + } + + /** Reports that the shell exited. */ + emitExit(exitCode = 0): void { + this.exitListener?.({ exitCode }); + } +} diff --git a/src/test-support/fake-socket.ts b/src/test-support/fake-socket.ts new file mode 100644 index 0000000..3b5d5f3 --- /dev/null +++ b/src/test-support/fake-socket.ts @@ -0,0 +1,65 @@ +// A scriptable stand-in for a `ws` WebSocket: records what is sent and how it closed in a +// shared log, and lets a test emit `message`, `close` and `error`. A test-support object for +// the session and socket handler tests (ADR 064). + +type Listener = (...args: never[]) => void; + +/** Ready states a test can set, matching the `ws` constants. */ +export const OPEN = 1; +export const CLOSED = 3; + +/** A socket with the surface the session, the pacer and the ws handlers use, and no network. */ +export class FakeSocket { + readyState = OPEN; + readonly OPEN = OPEN; + bufferedAmount = 0; + /** Every message sent, in order. */ + readonly sent: string[] = []; + /** How the socket was last closed, or `null` while open. */ + closed: { code?: number; reason?: string } | null = null; + private readonly listeners = new Map<string, Listener[]>(); + + /** + * @param log - Shared event log; entries read `<name>.send <first 20 chars>` and `<name>.close <code>`. + * @param name - The name used in the log. + */ + constructor( + readonly log: string[] = [], + readonly name = 'ws', + ) {} + + send(data: string): void { + this.sent.push(data); + this.log.push(`${this.name}.send ${JSON.stringify(data.slice(0, 20))}`); + } + + close(code?: number, reason?: string): void { + this.closed = { code, reason }; + this.readyState = CLOSED; + this.log.push(`${this.name}.close ${code ?? ''}`.trimEnd()); + } + + on(event: 'message', listener: (data: Buffer) => void): this; + on(event: 'close' | 'error', listener: (err?: Error) => void): this; + on(event: string, listener: Listener): this { + const list = this.listeners.get(event) ?? []; + list.push(listener); + this.listeners.set(event, list); + return this; + } + + /** The number of listeners registered for `event`. */ + listenerCount(event: string): number { + return this.listeners.get(event)?.length ?? 0; + } + + /** Calls the listeners for `event` with `args`, as `ws` would. */ + emit(event: string, ...args: unknown[]): void { + for (const listener of this.listeners.get(event) ?? []) listener(...(args as never[])); + } + + /** Delivers a text frame from the client, as a Buffer like `ws` does. */ + receive(text: string): void { + this.emit('message', Buffer.from(text, 'utf8')); + } +} From 1c67320c38cef14efd2179dc90c16fbd1310b91e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:31:17 -0400 Subject: [PATCH 174/295] refactor(server): split websocket.ts into sessions/session.ts and ws/* websocket.ts mixed the session's PTY lifecycle with the socket handlers (ADR 062). Now: - sessions/session.ts: Session is a class that owns the lifecycle. attach and detach a client; ensurePty spawns through an injected SpawnPty (banner, then scrollback = banner, then pty.write('\n')) or replays the scrollback before the resize; the scrollback ring; exit (removeExited, close the subscribers, output.end, then notifyIfEmpty after the flush); close; broadcastEvent. newSession becomes `new Session`, and sessionToJson stays. - ws/ws-upgrade.ts: the noServer upgrade router. createWebSocketServer takes PtySpawnDeps, and the production ones come from defaultPtySpawnDeps. - ws/pty-socket.ts and ws/events-socket.ts: the two handlers, each over a narrow socket interface. - closeSession, closeAllSessions and broadcastToSubscribers become Session.close, SessionStore.closeAll and Session.broadcastEvent. The per-chunk onData path is unchanged: it appends to the ring and calls output.broadcast, with no new allocation. The wire behaviour, close codes and reasons are byte-identical, and the subprocess websocket tests pass unchanged. New tests use FakePty and FakeSocket to assert the ADR 062 order: spawn on the first attach, the ring limit, the exit order and the flush before onEmpty, banner -> pty.write('\n'), and replay before resize. ws-upgrade.test.ts runs the router in process on port 0. --- docs/specs/deep-link.md | 2 +- src/server/index.ts | 5 +- src/server/routes.ts | 8 +- src/server/sessions/session-store.test.ts | 15 ++ src/server/sessions/session-store.ts | 9 +- src/server/sessions/session.test.ts | 226 ++++++++++++++++++++-- src/server/sessions/session.ts | 195 +++++++++++++++---- src/server/ws/client-message.ts | 2 +- src/server/ws/events-socket.test.ts | 38 ++++ src/server/ws/events-socket.ts | 36 ++++ src/server/ws/pty-socket.test.ts | 137 +++++++++++++ src/server/ws/pty-socket.ts | 69 +++++++ src/server/ws/ws-upgrade.test.ts | 108 +++++++++++ src/server/ws/ws-upgrade.ts | 76 ++++++++ 14 files changed, 859 insertions(+), 67 deletions(-) create mode 100644 src/server/ws/events-socket.test.ts create mode 100644 src/server/ws/events-socket.ts create mode 100644 src/server/ws/pty-socket.test.ts create mode 100644 src/server/ws/pty-socket.ts create mode 100644 src/server/ws/ws-upgrade.test.ts create mode 100644 src/server/ws/ws-upgrade.ts diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index b94d6ae..58cc5a4 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -127,7 +127,7 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would - `src/pty/node.ts`: return `pid: ptyProc.pid` - `src/pty/bun.ts`: return `pid: proc.pid` -**`Session`** (`src/server/session.ts`): no change to the Session struct — `pty.pid` is read directly from the PtyProcess when serializing. +**`Session`** (`src/server/sessions/session.ts`): no change to the Session struct — `pty.pid` is read directly from the PtyProcess when serializing. **`sessionToJson`**: include `pid: s.pty?.pid ?? null`. diff --git a/src/server/index.ts b/src/server/index.ts index 23f5a8c..a8dd62e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,7 +6,8 @@ import { loadConfig } from '../config'; import { findGhosttyWasm } from './http/static-files'; import { handleRequest } from './routes'; import { SessionStore } from './sessions/session-store'; -import { closeAllSessions, createWebSocketServer } from './websocket'; +import { WS_CLOSE } from './ws/close-codes'; +import { createWebSocketServer } from './ws/ws-upgrade'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -33,7 +34,7 @@ const clientDistPath = fs.existsSync(path.join(builtDistPath, 'client.html')) const store = new SessionStore({ onEmpty: shutdown }); function shutdown() { - closeAllSessions(store); + store.closeAll(WS_CLOSE.SERVER_STOPPED, 'server stopped'); wss.close(); const exit = () => process.exit(0); const shutdownTimeout = setTimeout(exit, 1000); diff --git a/src/server/routes.ts b/src/server/routes.ts index 961476a..681dfc0 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -9,7 +9,7 @@ import { GHOSTTY_WASM, serveFile } from './http/static-files'; import { sessionToJson } from './sessions/session'; import { generateId, isValidId } from './sessions/session-id'; import type { SessionStore } from './sessions/session-store'; -import { broadcastToSubscribers, closeSession } from './websocket'; +import { WS_CLOSE } from './ws/close-codes'; const MAX_BODY = 64 * 1024; @@ -232,7 +232,7 @@ export async function handleRequest( res.end('Not Found'); return; } - closeSession(session); + session.close(WS_CLOSE.SESSION_GONE, 'session deleted'); res.writeHead(204, { 'X-Sessions-Remaining': String(store.size) }); res.end(); return; @@ -298,7 +298,7 @@ export async function handleRequest( if (!trimmed) continue; try { JSON.parse(trimmed); - broadcastToSubscribers(session, trimmed); + session.broadcastEvent(trimmed); } catch { // skip invalid JSON lines } @@ -309,7 +309,7 @@ export async function handleRequest( if (trimmed) { try { JSON.parse(trimmed); - broadcastToSubscribers(session, trimmed); + session.broadcastEvent(trimmed); } catch { // skip invalid JSON } diff --git a/src/server/sessions/session-store.test.ts b/src/server/sessions/session-store.test.ts index 8580dfd..68435bd 100644 --- a/src/server/sessions/session-store.test.ts +++ b/src/server/sessions/session-store.test.ts @@ -1,4 +1,5 @@ import { describe, expect, mock, test } from 'bun:test'; +import { FakeSocket } from '../../test-support/fake-socket'; import { SessionStore } from './session-store'; describe('SessionStore.create and get', () => { @@ -153,3 +154,17 @@ describe('SessionStore.findByPid', () => { expect(store.findByPid(4242)).toBeUndefined(); }); }); + +describe('SessionStore.closeAll', () => { + test('closes every session with the code and keeps them registered', () => { + const store = new SessionStore(); + const a = new FakeSocket([], 'a'); + const b = new FakeSocket([], 'b'); + store.create('a').clients.add(a); + store.create('b').subscribers.add(b); + store.closeAll(1001, 'server stopped'); + expect(a.closed).toEqual({ code: 1001, reason: 'server stopped' }); + expect(b.closed).toEqual({ code: 1001, reason: 'server stopped' }); + expect(store.size).toBe(2); + }); +}); diff --git a/src/server/sessions/session-store.ts b/src/server/sessions/session-store.ts index b9b65e1..791c6f9 100644 --- a/src/server/sessions/session-store.ts +++ b/src/server/sessions/session-store.ts @@ -1,7 +1,7 @@ // SessionStore: the server's one registry of sessions, keyed by ID, plus the last-used // ID that `GET /` redirects to (ADR 062). An object created once in index.ts and passed // to the routes and the WebSocket server instead of a module-global Map. -import { newSession, type Session } from './session'; +import { Session } from './session'; /** What the store needs from its owner: who to tell once the last session has gone. */ export interface SessionStoreOptions { @@ -38,7 +38,7 @@ export class SessionStore { * @returns The new session. */ create(id: string, baseDir?: string): Session { - const session = newSession(id, baseDir); + const session = new Session(id, baseDir); this.sessions.set(id, session); return session; } @@ -106,6 +106,11 @@ export class SessionStore { this.sessions.delete(id); } + /** Kills every shell and closes every socket with `code`, as the server stops. */ + closeAll(code: number, reason: string): void { + for (const session of this.list()) session.close(code, reason); + } + /** Calls `onEmpty` if no session is left; the PTY exit path calls this after its flush. */ notifyIfEmpty(): void { if (this.sessions.size === 0) this.onEmpty?.(); diff --git a/src/server/sessions/session.test.ts b/src/server/sessions/session.test.ts index 15bcb9e..e236791 100644 --- a/src/server/sessions/session.test.ts +++ b/src/server/sessions/session.test.ts @@ -1,10 +1,39 @@ import { describe, expect, mock, test } from 'bun:test'; import { homedir } from 'node:os'; -import { newSession, sessionToJson } from './session'; +import { DEFAULT_CONFIG } from '../../config'; +import { FakePty } from '../../test-support/fake-pty'; +import { FakeSocket } from '../../test-support/fake-socket'; +import { WS_CLOSE } from '../ws/close-codes'; +import { type PtySpawnDeps, Session, type SessionExitHost, sessionToJson } from './session'; +import { sessionBanner } from './session-banner'; -describe('newSession', () => { - test('returns session with correct shape', () => { - const session = newSession('test', '/tmp'); +/** Lets the pacer's end() promise chain run. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** A session wired to a FakePty, with every step it takes written to one log. */ +function fixture(scrollback = DEFAULT_CONFIG.scrollback) { + const log: string[] = []; + const pty = new FakePty(log); + const spawnPty = mock((cols: number, rows: number, ...rest: unknown[]) => { + log.push(`spawn ${cols}x${rows} ${JSON.stringify(rest)}`); + return pty; + }); + const deps: PtySpawnDeps = { + loadConfig: () => ({ ...DEFAULT_CONFIG, scrollback, shell: '/bin/sh', env: { A: '1' } }), + spawnPty, + runner: 'bunx', + }; + const host: SessionExitHost = { + removeExited: (id) => log.push(`removeExited ${id}`), + notifyIfEmpty: () => log.push('notifyIfEmpty'), + }; + const session = new Session('s', '/work'); + return { log, pty, spawnPty, deps, host, session }; +} + +describe('new Session', () => { + test('starts with no shell, no clients and no scrollback', () => { + const session = new Session('test', '/tmp'); expect(session.id).toBe('test'); expect(session.baseDir).toBe('/tmp'); expect(session.pty).toBeNull(); @@ -15,13 +44,13 @@ describe('newSession', () => { }); test('defaults baseDir to the home directory', () => { - expect(newSession('test').baseDir).toBe(homedir()); + expect(new Session('test').baseDir).toBe(homedir()); }); }); describe('session output', () => { test('pauses the PTY the session holds when its only client falls behind', () => { - const session = newSession('test-output'); + const session = new Session('test-output'); const pause = mock(() => {}); session.pty = { pid: 1, pause } as never; const ws = { readyState: 1, OPEN: 1, bufferedAmount: 0, send() {}, close() {} }; @@ -31,34 +60,199 @@ describe('session output', () => { }); }); +describe('Session.ensurePty', () => { + test('the first client spawns the shell with the config, then gets the banner, then the PTY a newline', () => { + const { log, deps, host, session } = fixture(); + const ws = new FakeSocket(log); + session.attach(ws, false); + session.ensurePty(ws, 100, 30, deps, host); + expect(log).toEqual([ + `spawn 100x30 ${JSON.stringify(['/bin/sh', DEFAULT_CONFIG.term, DEFAULT_CONFIG.colorTerm, '/work', { A: '1' }])}`, + `ws.send ${JSON.stringify(sessionBanner('bunx').slice(0, 20))}`, + 'pty.write "\\n"', + ]); + expect(ws.sent).toEqual([sessionBanner('bunx')]); + expect(session.scrollback).toBe(sessionBanner('bunx')); + }); + + test('a later client is sent the scrollback before the PTY is resized, and nothing is spawned', () => { + const { log, deps, host, session, spawnPty, pty } = fixture(); + const first = new FakeSocket(log, 'first'); + session.attach(first, false); + session.ensurePty(first, 80, 24, deps, host); + pty.emitData('hello'); + log.length = 0; + + const second = new FakeSocket(log, 'second'); + session.attach(second, false); + session.ensurePty(second, 120, 40, deps, host); + expect(spawnPty).toHaveBeenCalledTimes(1); + expect(log).toEqual([ + `second.send ${JSON.stringify(session.scrollback.slice(0, 20))}`, + 'pty.resize 120x40', + ]); + expect(second.sent).toEqual([`${sessionBanner('bunx')}hello`]); + }); + + test('a later client with no scrollback is only resized', () => { + const { log, deps, host, session } = fixture(); + session.pty = new FakePty(log); + const ws = new FakeSocket(log); + session.attach(ws, false); + session.ensurePty(ws, 90, 20, deps, host); + expect(log).toEqual(['pty.resize 90x20']); + }); + + test('output is broadcast to every client and kept in a ring of config.scrollback characters', () => { + const { deps, host, session, pty } = fixture(8); + const a = new FakeSocket(); + const b = new FakeSocket(); + session.attach(a, false); + session.ensurePty(a, 80, 24, deps, host); + session.attach(b, false); + pty.emitData('0123456789'); + expect(session.scrollback).toBe('23456789'); + pty.emitData('ab'); + expect(session.scrollback).toBe('456789ab'); + expect(a.sent.slice(1)).toEqual(['0123456789', 'ab']); + expect(b.sent).toEqual(['0123456789', 'ab']); + }); + + test('the config is read when the shell spawns, not before', () => { + const { deps, host, session } = fixture(); + const loadConfig = mock(deps.loadConfig); + const ws = new FakeSocket(); + session.attach(ws, false); + expect(loadConfig).not.toHaveBeenCalled(); + session.ensurePty(ws, 80, 24, { ...deps, loadConfig }, host); + expect(loadConfig).toHaveBeenCalledTimes(1); + }); +}); + +describe('Session exit', () => { + test('unregisters, closes open subscribers, clears the PTY, closes clients, then reports empty', async () => { + const { log, deps, host, session, pty } = fixture(); + const ws = new FakeSocket(log, 'client'); + const sub = new FakeSocket(log, 'sub'); + const closedSub = new FakeSocket(log, 'gone'); + closedSub.readyState = 3; + session.attach(ws, true); + session.ensurePty(ws, 80, 24, deps, host); + session.subscribers.add(sub); + session.subscribers.add(closedSub); + log.length = 0; + + pty.emitExit(); + expect(session.pty).toBeNull(); + expect(session.subscribers.size).toBe(0); + expect(sub.closed).toEqual({ code: WS_CLOSE.SESSION_GONE, reason: 'shell exited' }); + expect(closedSub.closed).toBeNull(); + expect(log).toEqual([ + 'removeExited s', + `sub.close ${WS_CLOSE.SESSION_GONE}`, + `client.close ${WS_CLOSE.SESSION_GONE}`, + ]); + await settle(); + expect(log.at(-1)).toBe('notifyIfEmpty'); + }); + + test('a client behind the output is flushed before it is closed and before onEmpty', async () => { + const { log, deps, host, session, pty } = fixture(); + const ws = new FakeSocket(log, 'client'); + session.attach(ws, true); + session.ensurePty(ws, 80, 24, deps, host); + const big = 'x'.repeat(2 * 1024 * 1024); + pty.emitData(big); + pty.emitData('tail'); + pty.emitExit(); + await settle(); + expect(log).not.toContain('notifyIfEmpty'); + expect(ws.closed).toBeNull(); + + session.output.ack(ws, sessionBanner('bunx').length + big.length); + expect(ws.sent.at(-1)).toBe('tail'); + expect(ws.closed).toEqual({ code: WS_CLOSE.SESSION_GONE, reason: 'shell exited' }); + await settle(); + expect(log.indexOf('removeExited s')).toBeLessThan( + log.indexOf(`client.close ${WS_CLOSE.SESSION_GONE}`), + ); + expect(log.at(-1)).toBe('notifyIfEmpty'); + }); +}); + +describe('Session.attach and detach', () => { + test('detach removes the client from the set and the pacer', () => { + const session = new Session('s'); + const ws = new FakeSocket(); + session.attach(ws, false); + expect(session.clients.has(ws)).toBe(true); + session.detach(ws); + expect(session.clients.size).toBe(0); + session.output.broadcast('after'); + expect(ws.sent).toEqual([]); + }); +}); + +describe('Session.close', () => { + test('kills the shell and closes every client and subscriber with the code', () => { + const log: string[] = []; + const session = new Session('s'); + session.pty = new FakePty(log); + session.clients.add(new FakeSocket(log, 'client')); + session.subscribers.add(new FakeSocket(log, 'sub')); + session.close(WS_CLOSE.SERVER_STOPPED, 'server stopped'); + expect(log).toEqual(['pty.kill', 'client.close 1001', 'sub.close 1001']); + expect(session.subscribers.size).toBe(0); + }); + + test('a session without a shell just closes its sockets', () => { + const session = new Session('s'); + const ws = new FakeSocket(); + session.clients.add(ws); + session.close(WS_CLOSE.SESSION_GONE, 'session deleted'); + expect(ws.closed).toEqual({ code: 4001, reason: 'session deleted' }); + }); +}); + +describe('Session.broadcastEvent', () => { + test('sends the line to open subscribers only', () => { + const session = new Session('s'); + const open = new FakeSocket(); + const closed = new FakeSocket(); + closed.readyState = 3; + session.subscribers.add(open); + session.subscribers.add(closed); + session.broadcastEvent('{"a":1}'); + expect(open.sent).toEqual(['{"a":1}']); + expect(closed.sent).toEqual([]); + }); +}); + describe('sessionToJson', () => { test('connected is false when no clients', () => { - const session = newSession('test'); - expect(sessionToJson(session).connected).toBe(false); + expect(sessionToJson(new Session('test')).connected).toBe(false); }); test('connected is true when clients set is non-empty', () => { - const session = newSession('test'); - session.clients.add({} as never); + const session = new Session('test'); + session.clients.add(new FakeSocket()); expect(sessionToJson(session).connected).toBe(true); }); test('includes id, createdAt and baseDir', () => { - const session = newSession('test', '/tmp'); - const json = sessionToJson(session); + const json = sessionToJson(new Session('test', '/tmp')); expect(json.id).toBe('test'); expect(json.baseDir).toBe('/tmp'); expect(typeof json.createdAt).toBe('number'); }); test('pid is null when pty is not yet spawned', () => { - const session = newSession('test-pid-null'); - expect(sessionToJson(session).pid).toBeNull(); + expect(sessionToJson(new Session('test-pid-null')).pid).toBeNull(); }); test('pid reflects pty pid when pty is set', () => { - const session = newSession('test-pid-set'); - session.pty = { pid: 12345 } as never; + const session = new Session('test-pid-set'); + session.pty = new FakePty([], 12345); expect(sessionToJson(session).pid).toBe(12345); }); }); diff --git a/src/server/sessions/session.ts b/src/server/sessions/session.ts index 14321d3..b7d5577 100644 --- a/src/server/sessions/session.ts +++ b/src/server/sessions/session.ts @@ -1,50 +1,163 @@ -// The Session record: one terminal session's PTY, clients, subscribers, scrollback and -// output pacer, plus its JSON view for the API. Types and pure factories; the registry -// of sessions is SessionStore in sessions/session-store.ts (ADR 062). +// One terminal session: its shell's PTY, its clients and event subscribers, the scrollback +// ring replayed to a joining client, and its output pacer. An object that owns the PTY +// lifecycle, moved out of the WebSocket handler with its order kept exactly (ADR 062, ADR 048); +// the registry of sessions is SessionStore in session-store.ts. import { homedir } from 'node:os'; -import type { WebSocket as WS } from 'ws'; +import type { Config } from '../../config'; import type { PtyProcess } from '../../pty'; -import { OutputPacer } from '../output-pacer'; +import { type FlowSocket, OutputPacer } from '../output-pacer'; +import { WS_CLOSE } from '../ws/close-codes'; +import { sessionBanner } from './session-banner'; -/** A running (or recently created) terminal session. */ -export interface Session { - /** Unique session identifier. */ - id: string; +/** Starts a session's shell: `spawnForSession` from src/pty in production, a fake in tests. */ +export type SpawnPty = ( + cols: number, + rows: number, + shell: string, + term: string, + colorTerm: string, + cwd: string, + env: Record<string, string>, +) => PtyProcess; + +/** What {@link Session.ensurePty} needs to start a shell, injected so tests need no real PTY. */ +export interface PtySpawnDeps { + /** Reads the config at spawn time, so an edited config file applies to the next shell (ADR 009). */ + loadConfig: () => Config; + /** Spawns the shell. */ + spawnPty: SpawnPty; + /** The package runner (`bunx` or `npx`) the banner tells the user to run. */ + runner: string; +} + +/** The registry a session reports its shell's exit to; SessionStore implements it. */ +export interface SessionExitHost { + /** Unregisters the session, first thing on exit. */ + removeExited(id: string): void; + /** Stops the server if no session is left; called once the clients' output is flushed. */ + notifyIfEmpty(): void; +} + +/** A running (or recently created) terminal session; routes and sockets share it through SessionStore. */ +export class Session { /** Unix timestamp (ms) when the session was created. */ - createdAt: number; - /** Working directory for the PTY shell. */ - baseDir: string; - /** The underlying PTY process, or `null` if no shell has been spawned yet. */ - pty: PtyProcess | null; - /** All currently connected WebSocket clients for this session. */ - clients: Set<WS>; - /** All currently connected event subscribers for this session. */ - subscribers: Set<WS>; - /** Accumulated PTY output retained for replay when a new client joins. */ - scrollback: string; + readonly createdAt = Date.now(); + /** The underlying PTY process, or `null` if no shell has been spawned yet (or it exited). */ + pty: PtyProcess | null = null; + /** All currently connected PTY clients. */ + readonly clients = new Set<FlowSocket>(); + /** All currently connected event subscribers. */ + readonly subscribers = new Set<FlowSocket>(); + /** The last `config.scrollback` characters of output, replayed when a client joins. */ + scrollback = ''; /** Paces PTY output to the clients by their acknowledgements (ADR 048). */ - output: OutputPacer; -} + readonly output = new OutputPacer(() => this.pty); -/** - * Builds a session that has no shell yet; `SessionStore.create` registers it. - * - * @param id - The session ID. - * @param baseDir - Working directory for the PTY shell (default: `homedir()`). - * @returns The new, unregistered {@link Session}. - */ -export function newSession(id: string, baseDir = homedir()): Session { - const session: Session = { - id, - createdAt: Date.now(), - baseDir, - pty: null, - clients: new Set(), - subscribers: new Set(), - scrollback: '', - output: new OutputPacer(() => session.pty), - }; - return session; + /** + * @param id - Unique session identifier; SessionStore.rename changes it. + * @param baseDir - Working directory for the shell (default: the user's home). + */ + constructor( + public id: string, + readonly baseDir: string = homedir(), + ) {} + + /** + * Adds a PTY client. `acks` is true for a client that connected with `?flow=1`; one + * without it predates ADR 048 (a tab loaded before an upgrade) and is sent output unpaced. + * Called before {@link ensurePty}, so the banner is paced like any other output. + */ + attach(ws: FlowSocket, acks: boolean): void { + this.clients.add(ws); + this.output.add(ws, acks); + } + + /** Removes a PTY client whose socket closed, dropping what was queued for it. */ + detach(ws: FlowSocket): void { + this.clients.delete(ws); + this.output.remove(ws); + } + + /** + * Greets a newly attached client. The first client spawns the shell and is sent the + * banner, which starts the scrollback, then the shell is sent a newline so it prints a + * prompt. A later client is sent the scrollback first and only then resizes the PTY. + * + * @param ws - The client just passed to {@link attach}. + * @param cols - The client's width, used for the spawn or the resize. + * @param rows - The client's height. + * @param deps - How to read the config and spawn the shell. + * @param host - Told when the shell exits. + */ + ensurePty( + ws: FlowSocket, + cols: number, + rows: number, + deps: PtySpawnDeps, + host: SessionExitHost, + ): void { + if (this.pty) { + if (this.scrollback) this.output.send(ws, this.scrollback); + this.pty.resize(cols, rows); + return; + } + const config = deps.loadConfig(); + const limit = config.scrollback; + const pty = deps.spawnPty( + cols, + rows, + config.shell, + config.term, + config.colorTerm, + this.baseDir, + config.env, + ); + this.pty = pty; + // The per-chunk hot path: append to the ring and hand the chunk to the pacer, nothing more. + pty.onData((data: string) => { + this.scrollback = (this.scrollback + data).slice(-limit); + this.output.broadcast(data); + }); + pty.onExit(() => this.handleExit(host)); + + const banner = sessionBanner(deps.runner); + this.output.send(ws, banner); + this.scrollback = banner; + pty.write('\n'); + } + + /** + * Kills the shell and closes every client and subscriber with `code`; the API's delete + * and the server's shutdown use it. + */ + close(code: number, reason: string): void { + this.pty?.kill(); + for (const client of this.clients) client.close(code, reason); + for (const sub of this.subscribers) sub.close(code, reason); + this.subscribers.clear(); + } + + /** Sends one published event line to every open subscriber (`POST /api/sessions/:id/events`). */ + broadcastEvent(line: string): void { + for (const sub of this.subscribers) { + if (sub.readyState === sub.OPEN) sub.send(line); + } + } + + /** The shell exited: unregister, close the subscribers, then flush and close the clients. */ + private handleExit(host: SessionExitHost): void { + host.removeExited(this.id); + for (const sub of this.subscribers) { + if (sub.readyState === sub.OPEN) sub.close(WS_CLOSE.SESSION_GONE, 'shell exited'); + } + this.subscribers.clear(); + this.pty = null; + // A client behind the output is sent what is queued for it before it is closed, + // so the last lines the shell wrote are not lost (ADR 048). + this.output.end(WS_CLOSE.SESSION_GONE, 'shell exited').then(() => { + host.notifyIfEmpty(); + }); + } } /** diff --git a/src/server/ws/client-message.ts b/src/server/ws/client-message.ts index ee6472f..8df4807 100644 --- a/src/server/ws/client-message.ts +++ b/src/server/ws/client-message.ts @@ -1,5 +1,5 @@ // What a PTY client sends the server: the size in its ws URL and the messages on the -// socket (ADR 048, ADR 062). Pure parsing only; websocket.ts applies the result. +// socket (ADR 048, ADR 062). Pure parsing only; pty-socket.ts applies the result. /** Default terminal size when the client sends none, or a non-number. */ const DEFAULT_COLS = 80; diff --git a/src/server/ws/events-socket.test.ts b/src/server/ws/events-socket.test.ts new file mode 100644 index 0000000..35f0865 --- /dev/null +++ b/src/server/ws/events-socket.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { FakePty } from '../../test-support/fake-pty'; +import { FakeSocket } from '../../test-support/fake-socket'; +import { SessionStore } from '../sessions/session-store'; +import { WS_CLOSE } from './close-codes'; +import { handleEventsSocket } from './events-socket'; + +describe('handleEventsSocket', () => { + test('an unknown session closes the subscriber with SESSION_GONE', () => { + const ws = new FakeSocket(); + handleEventsSocket(new SessionStore(), ws, 'nope'); + expect(ws.closed).toEqual({ code: WS_CLOSE.SESSION_GONE, reason: 'session deleted' }); + }); + + test('a session with no shell closes the subscriber with PTY_NOT_RUNNING', () => { + const store = new SessionStore(); + store.create('s'); + const ws = new FakeSocket(); + handleEventsSocket(store, ws, 's'); + expect(ws.closed).toEqual({ code: WS_CLOSE.PTY_NOT_RUNNING, reason: 'PTY not running' }); + expect(store.get('s')?.subscribers.size).toBe(0); + }); + + test('a running session subscribes the socket until it closes; errors are ignored', () => { + const store = new SessionStore(); + const session = store.create('s'); + session.pty = new FakePty(); + const ws = new FakeSocket(); + handleEventsSocket(store, ws, 's'); + expect(session.subscribers.has(ws)).toBe(true); + session.broadcastEvent('{"e":1}'); + expect(ws.sent).toEqual(['{"e":1}']); + ws.emit('error', new Error('reset')); + expect(session.subscribers.has(ws)).toBe(true); + ws.emit('close'); + expect(session.subscribers.size).toBe(0); + }); +}); diff --git a/src/server/ws/events-socket.ts b/src/server/ws/events-socket.ts new file mode 100644 index 0000000..7a997dc --- /dev/null +++ b/src/server/ws/events-socket.ts @@ -0,0 +1,36 @@ +// The handler for an event subscriber (`/ws/:id/events`): the socket `webtty` tools use to +// receive lines published to a session. A handler function over a narrow socket interface, +// so tests pass a fake (ADR 062). +import type { FlowSocket } from '../output-pacer'; +import type { SessionStore } from '../sessions/session-store'; +import { WS_CLOSE } from './close-codes'; + +/** The part of a WebSocket an event subscriber uses. */ +export interface EventsSocket extends FlowSocket { + on(event: 'close' | 'error', listener: () => void): unknown; +} + +/** + * Subscribes `ws` to the session `id`'s events. A session that does not exist closes it with + * SESSION_GONE, and one whose shell is not running with PTY_NOT_RUNNING. + * + * @param store - The server's sessions. + * @param ws - The subscriber's socket. + * @param id - The decoded session ID from the URL. + */ +export function handleEventsSocket(store: SessionStore, ws: EventsSocket, id: string): void { + const session = store.get(id); + if (!session) { + ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); + return; + } + if (session.pty === null) { + ws.close(WS_CLOSE.PTY_NOT_RUNNING, 'PTY not running'); + return; + } + session.subscribers.add(ws); + ws.on('close', () => { + session.subscribers.delete(ws); + }); + ws.on('error', () => {}); +} diff --git a/src/server/ws/pty-socket.test.ts b/src/server/ws/pty-socket.test.ts new file mode 100644 index 0000000..b9e9af1 --- /dev/null +++ b/src/server/ws/pty-socket.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_CONFIG } from '../../config'; +import { FakePty } from '../../test-support/fake-pty'; +import { FakeSocket } from '../../test-support/fake-socket'; +import type { PtySpawnDeps } from '../sessions/session'; +import { sessionBanner } from '../sessions/session-banner'; +import { SessionStore } from '../sessions/session-store'; +import { WS_CLOSE } from './close-codes'; +import { handlePtySocket } from './pty-socket'; + +/** A store with session `s`, spawn deps that hand out one FakePty, and a shared log. */ +function fixture() { + const log: string[] = []; + const pty = new FakePty(log); + const store = new SessionStore(); + const session = store.create('s', '/work'); + const deps: PtySpawnDeps = { + loadConfig: () => DEFAULT_CONFIG, + spawnPty: (cols, rows) => { + log.push(`spawn ${cols}x${rows}`); + return pty; + }, + runner: 'npx', + }; + return { log, pty, store, session, deps }; +} + +const query = (q: string) => new URLSearchParams(q); + +describe('handlePtySocket attach', () => { + test('an unknown session closes the socket with SESSION_GONE and registers nothing', () => { + const { store, deps } = fixture(); + const ws = new FakeSocket(); + handlePtySocket(store, ws, 'nope', query(''), deps); + expect(ws.closed).toEqual({ code: WS_CLOSE.SESSION_GONE, reason: 'session deleted' }); + expect(ws.listenerCount('message')).toBe(0); + expect(store.lastUsedId).toBeNull(); + }); + + test('the first client spawns at the query size: banner, then scrollback is the banner, then a newline', () => { + const { log, store, session, deps } = fixture(); + const ws = new FakeSocket(log); + handlePtySocket(store, ws, 's', query('cols=100&rows=30&flow=1'), deps); + expect(log).toEqual([ + 'spawn 100x30', + `ws.send ${JSON.stringify(sessionBanner('npx').slice(0, 20))}`, + 'pty.write "\\n"', + ]); + expect(session.clients.has(ws)).toBe(true); + expect(session.scrollback).toBe(sessionBanner('npx')); + expect(store.lastUsedId).toBe('s'); + }); + + test('a client without a size spawns the shell at 80x24, and an absurd size is clamped', () => { + const one = fixture(); + handlePtySocket(one.store, new FakeSocket(), 's', query(''), one.deps); + expect(one.log[0]).toBe('spawn 80x24'); + const two = fixture(); + handlePtySocket(two.store, new FakeSocket(), 's', query('cols=99999&rows=0'), two.deps); + expect(two.log[0]).toBe('spawn 1000x24'); + }); + + test('a reconnecting client gets the scrollback replay before the resize', () => { + const { log, pty, store, deps } = fixture(); + handlePtySocket(store, new FakeSocket(log, 'first'), 's', query(''), deps); + pty.emitData('$ ls\r\n'); + log.length = 0; + const second = new FakeSocket(log, 'second'); + handlePtySocket(store, second, 's', query('cols=90&rows=20'), deps); + expect(log).toEqual([ + `second.send ${JSON.stringify(sessionBanner('npx').slice(0, 20))}`, + 'pty.resize 90x20', + ]); + expect(second.sent.join('')).toBe(`${sessionBanner('npx')}$ ls\r\n`); + }); +}); + +describe('handlePtySocket messages', () => { + function attached(flow = '1') { + const f = fixture(); + const ws = new FakeSocket(f.log); + handlePtySocket(f.store, ws, 's', query(`flow=${flow}`), f.deps); + f.log.length = 0; + return { ...f, ws }; + } + + test('raw input is written to the PTY as is', () => { + const { log, ws } = attached(); + ws.receive('ls\r'); + ws.receive('{not json'); + expect(log).toEqual(['pty.write "ls\\r"', 'pty.write "{not json"']); + }); + + test('a resize message resizes the PTY, clamped, and is not written', () => { + const { log, ws } = attached(); + ws.receive('{"type":"resize","cols":120,"rows":9999}'); + expect(log).toEqual(['pty.resize 120x500']); + }); + + test('an ack message goes to the pacer and is not written', () => { + const { log, ws, pty, session } = attached(); + const big = 'x'.repeat(2 * 1024 * 1024); + pty.emitData(big); + pty.emitData('queued'); + expect(ws.sent.at(-1)).toBe(big); + ws.receive(`{"type":"ack","chars":${sessionBanner('npx').length + big.length}}`); + expect(ws.sent.at(-1)).toBe('queued'); + expect(log.filter((l) => l.startsWith('pty.write'))).toEqual([]); + expect(session.output.isPaused).toBe(false); + }); + + test('a resize the PTY refuses is written to it as raw input instead', () => { + const { log, ws, pty } = attached(); + pty.resize = () => { + throw new Error('exited'); + }; + ws.receive('{"type":"resize","cols":10,"rows":10}'); + expect(log).toEqual(['pty.write "{\\"type\\":\\"resize\\",\\"cols\\":10,\\"rows\\":10}"']); + }); + + test('messages after the shell exited are dropped', () => { + const { log, ws, session } = attached(); + session.pty = null; + ws.receive('ls\r'); + ws.receive('{"type":"resize","cols":10,"rows":10}'); + expect(log).toEqual([]); + }); + + test('close detaches the client from the session and the pacer; error is ignored', () => { + const { ws, pty, session } = attached('0'); + ws.emit('error', new Error('reset')); + ws.emit('close'); + expect(session.clients.size).toBe(0); + pty.emitData('later'); + expect(ws.sent.at(-1)).not.toBe('later'); + }); +}); diff --git a/src/server/ws/pty-socket.ts b/src/server/ws/pty-socket.ts new file mode 100644 index 0000000..5a4c75c --- /dev/null +++ b/src/server/ws/pty-socket.ts @@ -0,0 +1,69 @@ +// The handler for a terminal client (`/ws/:id/pty`): attaches the socket to its Session, +// which spawns the shell or replays the scrollback, then routes the client's messages to +// the pacer or the PTY. A handler function over a narrow socket interface, so tests pass a +// fake (ADR 062, ADR 048). +import type { FlowSocket } from '../output-pacer'; +import type { PtySpawnDeps } from '../sessions/session'; +import type { SessionStore } from '../sessions/session-store'; +import { parseClientMessage, sizeFromQuery } from './client-message'; +import { WS_CLOSE } from './close-codes'; + +/** The part of a WebSocket a terminal client uses. */ +export interface PtyClientSocket extends FlowSocket { + on(event: 'message', listener: (data: Buffer) => void): unknown; + on(event: 'close' | 'error', listener: () => void): unknown; +} + +/** + * Attaches `ws` to the session `id`, or closes it with SESSION_GONE if there is none. The + * order is fixed (ADR 062): the client joins the pacer, the session is marked last-used, and + * only then is the shell spawned (banner, then `\n`) or the scrollback replayed (then resize). + * + * @param store - The server's sessions; also told when the shell exits. + * @param ws - The client's socket. + * @param id - The decoded session ID from the URL. + * @param params - The URL's query: `cols`, `rows` and `flow=1`. + * @param deps - How to read the config and spawn the shell. + */ +export function handlePtySocket( + store: SessionStore, + ws: PtyClientSocket, + id: string, + params: URLSearchParams, + deps: PtySpawnDeps, +): void { + const { cols, rows } = sizeFromQuery(params); + const session = store.get(id); + if (!session) { + ws.close(WS_CLOSE.SESSION_GONE, 'session deleted'); + return; + } + session.attach(ws, params.get('flow') === '1'); + store.markUsed(id); + session.ensurePty(ws, cols, rows, deps, store); + + ws.on('message', (data: Buffer) => { + const message = data.toString('utf8'); + const msg = parseClientMessage(message); + try { + if (msg.type === 'ack') { + session.output.ack(ws, msg.chars); + return; + } + if (msg.type === 'resize') { + session.pty?.resize(msg.cols, msg.rows); + return; + } + } catch { + // A control message whose handling throws (node-pty refuses to resize a PTY that + // has exited) is written to the PTY as raw input, as it always has been. + } + session.pty?.write(message); + }); + + ws.on('close', () => { + session.detach(ws); + }); + + ws.on('error', () => {}); +} diff --git a/src/server/ws/ws-upgrade.test.ts b/src/server/ws/ws-upgrade.test.ts new file mode 100644 index 0000000..f7873aa --- /dev/null +++ b/src/server/ws/ws-upgrade.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { WebSocket, type WebSocketServer } from 'ws'; +import { DEFAULT_CONFIG, loadConfig } from '../../config'; +import { spawnForSession } from '../../pty'; +import { FakePty } from '../../test-support/fake-pty'; +import { sessionBanner } from '../sessions/session-banner'; +import { SessionStore } from '../sessions/session-store'; +import { WS_CLOSE } from './close-codes'; +import { createWebSocketServer, defaultPtySpawnDeps } from './ws-upgrade'; + +let server: http.Server | null = null; +let wss: WebSocketServer | null = null; + +afterEach(async () => { + for (const client of wss?.clients ?? []) client.terminate(); + wss?.close(); + server?.closeAllConnections(); + await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; + wss = null; +}); + +/** An in-process HTTP server on port 0 with the WebSocket server attached and a fake PTY. */ +async function start() { + const store = new SessionStore(); + const log: string[] = []; + const pty = new FakePty(log); + server = http.createServer(); + wss = createWebSocketServer(server, store, { + loadConfig: () => DEFAULT_CONFIG, + spawnPty: (cols, rows) => { + log.push(`spawn ${cols}x${rows}`); + return pty; + }, + runner: 'bunx', + }); + const s = server; + await new Promise<void>((resolve) => s.listen(0, '127.0.0.1', resolve)); + const { port } = s.address() as AddressInfo; + return { store, log, pty, base: `ws://127.0.0.1:${port}` }; +} + +/** Opens `url` and resolves with the first message, or how it closed or failed. */ +function firstEvent(url: string): Promise<string> { + return new Promise((resolve) => { + const ws = new WebSocket(url); + ws.on('message', (data) => { + resolve(`message ${data.toString()}`); + ws.close(); + }); + ws.on('close', (code) => resolve(`close ${code}`)); + ws.on('error', () => resolve('error')); + }); +} + +describe('createWebSocketServer', () => { + test('a PTY socket for a session spawns its shell and receives the banner', async () => { + const { store, log, base } = await start(); + store.create('a b'); + expect(await firstEvent(`${base}/ws/a%20b/pty?cols=90&rows=20`)).toBe( + `message ${sessionBanner('bunx')}`, + ); + expect(log.slice(0, 1)).toEqual(['spawn 90x20']); + expect(store.lastUsedId).toBe('a b'); + }); + + test('a PTY socket for an unknown session is closed with SESSION_GONE', async () => { + const { base } = await start(); + expect(await firstEvent(`${base}/ws/nope/pty`)).toBe(`close ${WS_CLOSE.SESSION_GONE}`); + }); + + test('an events socket is routed to the event handler', async () => { + const { store, base } = await start(); + store.create('s'); + expect(await firstEvent(`${base}/ws/s/events`)).toBe(`close ${WS_CLOSE.PTY_NOT_RUNNING}`); + }); + + test('an ID that is not valid percent-encoding is closed with BAD_REQUEST', async () => { + const { base } = await start(); + expect(await firstEvent(`${base}/ws/%E0%A4%A/pty`)).toBe(`close ${WS_CLOSE.BAD_REQUEST}`); + expect(await firstEvent(`${base}/ws/%E0%A4%A/events`)).toBe(`close ${WS_CLOSE.BAD_REQUEST}`); + }); + + test('an upgrade to any other path has its socket destroyed', async () => { + const { base } = await start(); + expect(await firstEvent(`${base}/ws/s/other`)).toBe('error'); + expect(await firstEvent(`${base}/elsewhere`)).toBe('error'); + }); + + test('a connection that bypassed the upgrade router with an unknown path is closed', async () => { + await start(); + const closed: unknown[] = []; + const ws = { close: (...args: unknown[]) => closed.push(args) }; + wss?.emit('connection', ws, { url: '/nope', headers: {} }); + expect(closed).toEqual([[]]); + }); +}); + +describe('defaultPtySpawnDeps', () => { + test('reads the config file, spawns through src/pty and names the package runner', () => { + const deps = defaultPtySpawnDeps(); + expect(deps.loadConfig).toBe(loadConfig); + expect(deps.spawnPty).toBe(spawnForSession); + expect(['bunx', 'npx']).toContain(deps.runner); + }); +}); diff --git a/src/server/ws/ws-upgrade.ts b/src/server/ws/ws-upgrade.ts new file mode 100644 index 0000000..8f93a09 --- /dev/null +++ b/src/server/ws/ws-upgrade.ts @@ -0,0 +1,76 @@ +// The WebSocket server: a `noServer` WebSocketServer behind the HTTP server's `upgrade` +// event, routing `/ws/:id/pty` to the terminal handler and `/ws/:id/events` to the event +// handler. Wiring only; the handlers and the Session do the work (ADR 062). +import type http from 'node:http'; +import type { WebSocket as WS } from 'ws'; +import { WebSocketServer } from 'ws'; +import { loadConfig } from '../../config'; +import { spawnForSession } from '../../pty'; +import { packageRunner } from '../../runtime'; +import type { PtySpawnDeps } from '../sessions/session'; +import type { SessionStore } from '../sessions/session-store'; +import { WS_CLOSE } from './close-codes'; +import { handleEventsSocket } from './events-socket'; +import { handlePtySocket } from './pty-socket'; + +const PTY_PATH = /^\/ws\/([^/]+)\/pty$/; +const EVENTS_PATH = /^\/ws\/([^/]+)\/events$/; + +/** The production spawn dependencies: the config file, the platform's PTY backend and runner. */ +export function defaultPtySpawnDeps(): PtySpawnDeps { + return { loadConfig, spawnPty: spawnForSession, runner: packageRunner() }; +} + +function requestUrl(req: http.IncomingMessage): URL { + return new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); +} + +/** + * Attaches a WebSocket server to `httpServer`. An upgrade to any other path has its socket + * destroyed; an ID that is not valid percent-encoding is closed with BAD_REQUEST. + * + * @param httpServer - The HTTP server to attach the WebSocket server to. + * @param store - The server's sessions; its `onEmpty` runs once the last shell has exited. + * @param deps - How sessions spawn their shell (tests pass a fake PTY). + * @returns The configured {@link WebSocketServer}. + */ +export function createWebSocketServer( + httpServer: http.Server, + store: SessionStore, + deps: PtySpawnDeps = defaultPtySpawnDeps(), +): WebSocketServer { + const wss = new WebSocketServer({ noServer: true }); + + httpServer.on('upgrade', (req, socket, head) => { + const { pathname } = requestUrl(req); + if (pathname.match(PTY_PATH) || pathname.match(EVENTS_PATH)) { + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); + } else { + socket.destroy(); + } + }); + + wss.on('connection', (ws: WS, req: http.IncomingMessage) => { + const url = requestUrl(req); + const ptyMatch = url.pathname.match(PTY_PATH); + const eventsMatch = url.pathname.match(EVENTS_PATH); + const match = ptyMatch ?? eventsMatch; + if (!match) { + ws.close(); + return; + } + + let id: string; + try { + id = decodeURIComponent(match[1]); + } catch { + ws.close(WS_CLOSE.BAD_REQUEST, 'Bad Request'); + return; + } + + if (eventsMatch) handleEventsSocket(store, ws, id); + else handlePtySocket(store, ws, id, url.searchParams, deps); + }); + + return wss; +} From cebd9d791759ac9b88248142278902d3df879134 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:34:34 -0400 Subject: [PATCH 175/295] docs(arch): record that a1e6c23 does not build and carries the websocket.ts removal The test commit a1e6c23 also deleted src/server/websocket.ts while index.ts and routes.ts still imported it; 1c67320 repoints them. History is not rewritten, so ADR 062 notes the commit to skip when bisecting. --- docs/adrs/062.server.composition.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adrs/062.server.composition.md b/docs/adrs/062.server.composition.md index a38aef3..bc513b6 100644 --- a/docs/adrs/062.server.composition.md +++ b/docs/adrs/062.server.composition.md @@ -52,3 +52,7 @@ - Routes, WebSocket channels and sessions run in-process on port 0 in tests, so they count in coverage and edge cases become deterministic; two or three subprocess tests remain as end-to-end checks. - A new route is a handler and a line in the table; a new config key is the schema, the default, its parse rule and (if the client needs it) the key list, all in `src/config/`. - Golden tests written before the move make any change in a response visible. + +## History note + +Commit `a1e6c23` ("test(server): a fake PTY and a fake socket in test-support/") also deletes `src/server/websocket.ts` by a staging mistake, while `index.ts` and `routes.ts` still import it, so that one commit does not build or pass tests: `git bisect skip` it. The removal belongs to the next commit, `1c67320` ("refactor(server): split websocket.ts into sessions/session.ts and ws/*"), which repoints those imports and is green. The history is not rewritten; later steps stage explicit paths and check `git show --stat` before committing. From 630d2b68c4e2b62ce287d512cfd9ab25c4ff4361 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:35:04 -0400 Subject: [PATCH 176/295] refactor(client): smooth scroll and wheel stream take their browser globals as deps SmoothScroll reads devicePixelRatio and performance.now through a deps argument defaulting to { window, performance }, and WheelStream asks matchMedia through one defaulting to globalThis, like the other views split in this step. Tests inject them instead of patching globals, and cover onOutput, ghostty-web's own viewport moves, resize and the no-renderer paths, bringing smooth-scroll.ts to 100% lines and functions. --- .../features/scroll/smooth-scroll.test.ts | 211 +++++++++++------- src/client/features/scroll/smooth-scroll.ts | 13 +- .../features/scroll/wheel-stream.test.ts | 42 ++-- src/client/features/scroll/wheel-stream.ts | 18 +- 4 files changed, 170 insertions(+), 114 deletions(-) diff --git a/src/client/features/scroll/smooth-scroll.test.ts b/src/client/features/scroll/smooth-scroll.test.ts index dbc272e..0d2c0db 100644 --- a/src/client/features/scroll/smooth-scroll.test.ts +++ b/src/client/features/scroll/smooth-scroll.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test'; +import { beforeEach, describe, expect, test } from 'bun:test'; import type { Terminal } from '../../vendor/ghostty-web'; import { SmoothScroll } from './smooth-scroll'; import { WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; @@ -6,10 +6,15 @@ import { WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; /** Scrolls made by `fake`, whose `frame` the render loop stand-in calls. */ const live: SmoothScroll[] = []; +/** The device pixel ratio, clock and reduced-motion setting the fakes are given. */ +const env = { dpr: 1, now: 0, reducedMotion: false }; + describe('SmoothScroll', () => { function fake(cellHeight: number) { const metrics = { height: cellHeight }; const offsets: number[] = []; + let scrolled = (_y: number) => {}; + let resized = () => {}; const term = { viewportY: 0, rows: 40, @@ -18,77 +23,134 @@ describe('SmoothScroll', () => { getMetrics: () => metrics, setScrollOffset: (offset: number) => offsets.push(offset), }, - onScroll: () => {}, - onResize: () => {}, + onScroll: (cb: (y: number) => void) => { + scrolled = cb; + }, + onResize: (cb: () => void) => { + resized = cb; + }, getScrollbackLength: () => 100, scrollToLine(line: number) { term.viewportY = line; }, }; - const scroll = new SmoothScroll(term as unknown as Terminal, new WheelStream()); + const stream = new WheelStream({ + matchMedia: (q) => ({ + matches: env.reducedMotion && q === '(prefers-reduced-motion: reduce)', + }), + }); + const scroll = new SmoothScroll(term as unknown as Terminal, stream, { + window: { + get devicePixelRatio() { + return env.dpr; + }, + }, + performance: { now: () => env.now }, + }); live.push(scroll); - return { term, metrics, offsets, scroll }; + return { + term, + metrics, + offsets, + scroll, + scrolled: (y: number) => scrolled(y), + resized: () => resized(), + }; } const wheel = (deltaY: number) => ({ deltaY, deltaMode: 0 }) as WheelEvent; + beforeEach(() => { + Object.assign(env, { dpr: 1, now: 0, reducedMotion: false }); + live.length = 0; + }); + test('update re-snaps the offset to a new device pixel ratio', () => { - const g = globalThis as { window?: unknown }; - const saved = g.window; - try { - g.window = { devicePixelRatio: 1 }; - const { term, offsets, scroll } = fake(13); - // 0.69 x 13 = 8.97 CSS px, snapped to 9 at 1x. - scroll.handleWheel(wheel(-13 * 5.69)); - expect(term.viewportY).toBe(5); - expect(offsets.at(-1)).toBe(9); - g.window = { devicePixelRatio: 2 }; - scroll.update(); - expect(offsets.at(-1)).toBe(9); - g.window = { devicePixelRatio: 1.5 }; - scroll.update(); - // 8.97 x 1.5 = 13.455 device px, snapped to 13. - expect(offsets.at(-1)).toBeCloseTo(13 / 1.5); - } finally { - g.window = saved; - } + const { term, offsets, scroll } = fake(13); + // 0.69 x 13 = 8.97 CSS px, snapped to 9 at 1x. + scroll.handleWheel(wheel(-13 * 5.69)); + expect(term.viewportY).toBe(5); + expect(offsets.at(-1)).toBe(9); + env.dpr = 2; + scroll.update(); + expect(offsets.at(-1)).toBe(9); + env.dpr = 1.5; + scroll.update(); + // 8.97 x 1.5 = 13.455 device px, snapped to 13. + expect(offsets.at(-1)).toBeCloseTo(13 / 1.5); + }); + + test('a zero device pixel ratio counts as 1x', () => { + const { offsets, scroll } = fake(13); + env.dpr = 0; + scroll.handleWheel(wheel(-13 * 5.69)); + expect(offsets.at(-1)).toBe(9); }); test('update re-splits the position for a new cell height', () => { - const g = globalThis as { window?: unknown }; - const saved = g.window; - try { - g.window = { devicePixelRatio: 1 }; - const { term, metrics, offsets, scroll } = fake(14); - // A fractional delta, so the trackpad path applies it at once: 2.518 lines. - scroll.handleWheel(wheel(-14 * 2.5 - 0.25)); - expect(offsets.at(-1)).toBe(7); - metrics.height = 20; - scroll.update(); - expect(term.viewportY).toBe(2); - expect(offsets.at(-1)).toBe(10); - } finally { - g.window = saved; - } + const { term, metrics, offsets, scroll } = fake(14); + // A fractional delta, so the trackpad path applies it at once: 2.518 lines. + scroll.handleWheel(wheel(-14 * 2.5 - 0.25)); + expect(offsets.at(-1)).toBe(7); + metrics.height = 20; + scroll.update(); + expect(term.viewportY).toBe(2); + expect(offsets.at(-1)).toBe(10); + }); + + test('new output at the bottom clears a sub-line offset', () => { + const { term, offsets, scroll } = fake(10); + // 0.55 lines: line 0 with a 5.5 px offset, rounded to 6. + scroll.handleWheel(wheel(-5.5)); + expect([term.viewportY, offsets.at(-1)]).toEqual([0, 6]); + scroll.onOutput(); + expect([term.viewportY, offsets.at(-1)]).toEqual([0, 0]); + // At rest on the bottom, output changes nothing. + scroll.onOutput(); + expect(offsets).toEqual([6, 0]); + }); + + test('new output above the bottom is left to ghostty-web', () => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel(wheel(-25.75)); + expect([term.viewportY, offsets.at(-1)]).toEqual([2, 6]); + scroll.onOutput(); + expect([term.viewportY, offsets.at(-1)]).toEqual([2, 6]); + }); + + test("follows ghostty-web's own moves of the viewport, and resizes", () => { + const { term, offsets, scroll, scrolled, resized } = fake(10); + scroll.handleWheel(wheel(-25.75)); + // A keyboard scroll to line 7. + term.viewportY = 7; + scrolled(7); + expect(offsets.at(-1)).toBe(0); + // Its own line is not a move to follow. + offsets.length = 0; + scrolled(7); + expect(offsets).toEqual([]); + term.viewportY = 3; + resized(); + expect([term.viewportY, offsets.at(-1)]).toEqual([3, 0]); + }); + + test('leaves the alternate screen, and a terminal without a renderer, to ghostty-web', () => { + const alt = fake(10); + alt.term.wasmTerm.isAlternateScreen = () => true; + expect(alt.scroll.handleWheel(wheel(-40))).toBe(false); + const bare = fake(10); + (bare.term as { renderer?: unknown }).renderer = undefined; + expect(bare.scroll.handleWheel(wheel(-40))).toBe(false); + bare.scroll.update(); + bare.scroll.frame(16); }); describe('mouse wheel glide', () => { function withFrames(run: (tick: (ms: number) => void) => void) { - const g = globalThis as { window?: unknown; performance?: unknown }; - const [savedWindow, savedPerformance] = [g.window, g.performance]; - let now = 0; - g.window = { devicePixelRatio: 1 }; - g.performance = { now: () => now }; - live.length = 0; - try { - // One frame of the render loop, `ms` after the last. - run((ms) => { - now += ms; - for (const scroll of live) scroll.frame(now); - }); - } finally { - g.window = savedWindow; - g.performance = savedPerformance; - } + // One frame of the render loop, `ms` after the last. + run((ms) => { + env.now += ms; + for (const scroll of live) scroll.frame(env.now); + }); } const notch = (deltaY: number) => ({ deltaX: 0, deltaY, deltaMode: 0 }) as WheelEvent; @@ -122,44 +184,25 @@ describe('SmoothScroll', () => { test('with reduced motion a notch scrolls at once', () => { withFrames(() => { - const g = globalThis as { matchMedia?: unknown }; - const saved = g.matchMedia; - g.matchMedia = (q: string) => ({ matches: q === '(prefers-reduced-motion: reduce)' }); - try { - const { term, scroll } = fake(10); - scroll.handleWheel(notch(-40)); - expect(term.viewportY).toBe(4); - } finally { - g.matchMedia = saved; - } + env.reducedMotion = true; + const { term, scroll } = fake(10); + scroll.handleWheel(notch(-40)); + expect(term.viewportY).toBe(4); }); }); test('a glide is not stopped by its own move onto the next line', () => { withFrames((tick) => { - const { term, offsets, scroll } = fake(10); + const { term, offsets, scroll, scrolled } = fake(10); // ghostty-web fires onScroll synchronously from scrollToLine. - let onScroll = (_y: number) => {}; - const t = term as unknown as { onScroll: unknown; scrollToLine: (l: number) => void }; - const real = t.scrollToLine; - t.scrollToLine = (l: number) => { - real(l); - onScroll(l); + const real = term.scrollToLine; + term.scrollToLine = (line: number) => { + real(line); + scrolled(line); }; - const s = new SmoothScroll( - { - ...term, - onScroll: (cb: (y: number) => void) => { - onScroll = cb; - }, - } as unknown as Terminal, - new WheelStream(), - ); - live.push(s); - void scroll; // Frames 0.5 ms apart are sure to land within 0.05 lines below a line, where the // offset rounds up to the next line. - s.handleWheel(notch(-40)); + scroll.handleWheel(notch(-40)); for (let i = 0; i < 300; i++) tick(0.5); expect([term.viewportY, offsets.at(-1)]).toEqual([4, 0]); }); diff --git a/src/client/features/scroll/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts index a48b7fb..fb9f982 100644 --- a/src/client/features/scroll/smooth-scroll.ts +++ b/src/client/features/scroll/smooth-scroll.ts @@ -23,6 +23,12 @@ import { Glide, type Terminal } from '../../vendor/ghostty-web'; import { splitScroll, wheelLines } from './scroll-split'; import type { WheelStream } from './wheel-stream'; +/** The browser objects the scroll reads: the device pixel ratio, at every split, and the clock. */ +export interface SmoothScrollDeps { + window: Pick<Window, 'devicePixelRatio'>; + performance: Pick<Performance, 'now'>; +} + /** Scrolls the terminal's scrollback by pixels: a fractional position that glides. */ export class SmoothScroll { /** The position in lines scrolled up from the bottom, fractional between rows. */ @@ -33,6 +39,7 @@ export class SmoothScroll { constructor( private readonly term: Terminal, private readonly stream: WheelStream, + private readonly deps: SmoothScrollDeps = { window, performance }, ) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard // scrolling and scrollbar drags. Follow it whenever it lands on a different line. @@ -50,7 +57,7 @@ export class SmoothScroll { const lines = wheelLines(e, metrics.height, this.term.rows); // A delta during a glide adds to where the glide is heading. const to = this.clamp(this.glide.target - lines); - this.glide.retarget(to, this.stream.forScrollback(e), performance.now()); + this.glide.retarget(to, this.stream.forScrollback(e), this.deps.performance.now()); if (!this.glide.active) this.apply(); return true; } @@ -63,7 +70,7 @@ export class SmoothScroll { if (!this.glide.active) return; // A spring has arrived once within half a device pixel. const height = this.term.renderer?.getMetrics()?.height ?? 15; - this.glide.step(now, 0.5 / (height * (window.devicePixelRatio || 1))); + this.glide.step(now, 0.5 / (height * (this.deps.window.devicePixelRatio || 1))); this.glide.value = this.clamp(this.glide.value); this.apply(); } @@ -97,7 +104,7 @@ export class SmoothScroll { const { line, offset } = splitScroll( this.glide.value, metrics.height, - window.devicePixelRatio || 1, + this.deps.window.devicePixelRatio || 1, ); // Changes ghostty-web's viewportY synchronously. Its render loop draws the rows that // came into view and shows the offset on the next frame. Rounding the offset can land diff --git a/src/client/features/scroll/wheel-stream.test.ts b/src/client/features/scroll/wheel-stream.test.ts index 1e24f59..8202ef4 100644 --- a/src/client/features/scroll/wheel-stream.test.ts +++ b/src/client/features/scroll/wheel-stream.test.ts @@ -87,35 +87,33 @@ describe('WheelStream at the start of a gesture', () => { }); describe('WheelStream with reduced motion', () => { - function reduced(run: () => void) { - const g = globalThis as { matchMedia?: unknown }; - const saved = g.matchMedia; - g.matchMedia = (q: string) => ({ matches: q === '(prefers-reduced-motion: reduce)' }); - try { - run(); - } finally { - g.matchMedia = saved; - } - } + const reduced = () => + new WheelStream({ + matchMedia: (q) => ({ matches: q === '(prefers-reduced-motion: reduce)' }), + }); const at = (deltaY: number, timeStamp: number, deltaMode = 0) => ({ deltaX: 0, deltaY, deltaMode, timeStamp }) as WheelEvent; test('everything moves the scrollback at once', () => { - reduced(() => { - const s = new WheelStream(); - expect(s.forScrollback(at(-3, 1000, 1)).duration).toBe(0); - s.forScrollback(at(-95.5, 2000)); - expect(s.forScrollback(at(-97.25, 2040)).duration).toBe(0); - }); + const s = reduced(); + expect(s.forScrollback(at(-3, 1000, 1)).duration).toBe(0); + s.forScrollback(at(-95.5, 2000)); + expect(s.forScrollback(at(-97.25, 2040)).duration).toBe(0); }); test("a wheel notch's ticks go at once, a sparse stream's stay paced", () => { - reduced(() => { - const s = new WheelStream(); - expect(s.forApp(at(-3, 1000, 1)).duration).toBe(0); - s.forApp(at(-95.5, 2000)); - expect(s.forApp(at(-97.25, 2040)).duration).toBeGreaterThan(0); - }); + const s = reduced(); + expect(s.forApp(at(-3, 1000, 1)).duration).toBe(0); + s.forApp(at(-95.5, 2000)); + expect(s.forApp(at(-97.25, 2040)).duration).toBeGreaterThan(0); + }); +}); + +describe('WheelStream without matchMedia', () => { + test('moves at full motion', () => { + const s = new WheelStream({}); + const notch = { deltaX: 0, deltaY: -3, deltaMode: 1, timeStamp: 1000 } as WheelEvent; + expect(s.forScrollback(notch).duration).toBe(WHEEL_GLIDE_MS); }); }); diff --git a/src/client/features/scroll/wheel-stream.ts b/src/client/features/scroll/wheel-stream.ts index fe8ff84..2c66dc1 100644 --- a/src/client/features/scroll/wheel-stream.ts +++ b/src/client/features/scroll/wheel-stream.ts @@ -47,9 +47,10 @@ export const FAST_STREAM_MS = 60; /** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ export const STREAM_OMEGA = 28; -/** The OS asks for less motion. */ -function reducedMotion(): boolean { - return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; +/** The browser object the stream asks whether the OS wants less motion. */ +export interface WheelStreamDeps { + /** `window.matchMedia`; without one, motion is full. */ + matchMedia?(query: string): { matches: boolean }; } /** A classified event: its pace, and whether it is a wheel notch. */ @@ -77,10 +78,12 @@ export class WheelStream { private springing = false; private last: { event: WheelEvent; classified: Classified } | null = null; + constructor(private readonly deps: WheelStreamDeps = globalThis) {} + /** How `e` moves the scrollback. With reduced motion, everything moves at once. */ forScrollback(e: WheelEvent): Pace { const { pace } = this.classify(e); - return reducedMotion() ? AT_ONCE : pace; + return this.reducedMotion() ? AT_ONCE : pace; } /** @@ -90,7 +93,12 @@ export class WheelStream { */ forApp(e: WheelEvent): Pace { const { pace, wheel } = this.classify(e); - return wheel && reducedMotion() ? AT_ONCE : pace; + return wheel && this.reducedMotion() ? AT_ONCE : pace; + } + + /** The OS asks for less motion. */ + private reducedMotion(): boolean { + return this.deps.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; } private classify(e: WheelEvent): Classified { From 5cb16597a0c6399181ca82775caf2cb1c5764ed7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:37:50 -0400 Subject: [PATCH 177/295] refactor(client): split kitty.ts into features/kitty/protocol/ The pure half of the kitty graphics support gets one module per concern: apc-splitter (ApcSplitter, MAX_APC_BYTES), apc-command (KittyKeys, KittyCommand, parseKittyApc, num), chunk-assembler (ChunkAssembler, Transmission, MAX_TRANSMISSION_BYTES), kitty-reply, png-size, cell-geometry (deviceCellSize, axisScale, cellSpan) and control-scanner (clearsPlacements, ControlScanner). Function bodies are unchanged; each file gets a header and each export a JSDoc line. graphics.ts imports from the new modules. kitty.test.ts is split per module with every assertion kept (95), plus cases for num() and for a payload that is not base64, so each protocol module is at 100% lines. The ratchet entry for kitty.ts is removed, and the kitty-graphics SPEC and the stale kitty.ts references in comments point at the new paths. --- docs/specs/architecture.md | 2 +- docs/specs/kitty-graphics.md | 14 +- scripts/architecture/ratchet-allowlist.ts | 1 - src/client/features/kitty/graphics.test.ts | 2 +- src/client/features/kitty/graphics.ts | 20 +- src/client/features/kitty/kitty.test.ts | 489 ------------------ src/client/features/kitty/kitty.ts | 384 -------------- .../kitty/protocol/apc-command.test.ts | 38 ++ .../features/kitty/protocol/apc-command.ts | 31 ++ .../kitty/protocol/apc-splitter.test.ts | 127 +++++ .../features/kitty/protocol/apc-splitter.ts | 132 +++++ .../kitty/protocol/cell-geometry.test.ts | 82 +++ .../features/kitty/protocol/cell-geometry.ts | 64 +++ .../kitty/protocol/chunk-assembler.test.ts | 104 ++++ .../kitty/protocol/chunk-assembler.ts | 82 +++ .../kitty/protocol/control-scanner.test.ts | 52 ++ .../kitty/protocol/control-scanner.ts | 39 ++ .../kitty/protocol/kitty-reply.test.ts | 29 ++ .../features/kitty/protocol/kitty-reply.ts | 26 + .../features/kitty/protocol/png-size.test.ts | 76 +++ .../features/kitty/protocol/png-size.ts | 40 ++ src/client/vendor/ghostty-web/terminal.ts | 4 +- 22 files changed, 944 insertions(+), 894 deletions(-) delete mode 100644 src/client/features/kitty/kitty.test.ts delete mode 100644 src/client/features/kitty/kitty.ts create mode 100644 src/client/features/kitty/protocol/apc-command.test.ts create mode 100644 src/client/features/kitty/protocol/apc-command.ts create mode 100644 src/client/features/kitty/protocol/apc-splitter.test.ts create mode 100644 src/client/features/kitty/protocol/apc-splitter.ts create mode 100644 src/client/features/kitty/protocol/cell-geometry.test.ts create mode 100644 src/client/features/kitty/protocol/cell-geometry.ts create mode 100644 src/client/features/kitty/protocol/chunk-assembler.test.ts create mode 100644 src/client/features/kitty/protocol/chunk-assembler.ts create mode 100644 src/client/features/kitty/protocol/control-scanner.test.ts create mode 100644 src/client/features/kitty/protocol/control-scanner.ts create mode 100644 src/client/features/kitty/protocol/kitty-reply.test.ts create mode 100644 src/client/features/kitty/protocol/kitty-reply.ts create mode 100644 src/client/features/kitty/protocol/png-size.test.ts create mode 100644 src/client/features/kitty/protocol/png-size.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 30ea77d..853c808 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -319,7 +319,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a | Architecture SPEC and conventions | Layers, folders, module kinds, naming, size limits, comments | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | -| Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | +| Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` (protocol done) | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | | Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | | Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ⬜ | diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index d93d32d..5fb187f 100644 --- a/docs/specs/kitty-graphics.md +++ b/docs/specs/kitty-graphics.md @@ -24,7 +24,7 @@ the rejected alternatives. ``` PTY ──► server ──► WebSocket ──► ws.onmessage │ - KittyGraphics.feed(data) (features/kitty/kitty.ts) + KittyGraphics.feed(data) (features/kitty/graphics.ts) │ │ text segments APC commands │ │ @@ -77,7 +77,7 @@ it (`OPT_SIZE`, [ADR 042](../adrs/042.client.official-vt-engine.md)). Until [ADR 047](../adrs/047.client.official-engine-only.md) the client answered `14/16/18 t` itself on the Coder engine. -Pixel sizes are device pixels, matching what kitty and Ghostty report. The cell size is `round(cssCell × devicePixelRatio)` (`deviceCellPixels`, the same as `kitty.ts`'s `deviceCellSize` that frames are scaled by) and the text-area size is `cols × cell`, so the numbers a program derives always agree with each other, including at fractional ratios. +Pixel sizes are device pixels, matching what kitty and Ghostty report. The cell size is `round(cssCell × devicePixelRatio)` (`deviceCellPixels`, the same as `deviceCellSize` in `features/kitty/protocol/cell-geometry.ts` that frames are scaled by) and the text-area size is `cols × cell`, so the numbers a program derives always agree with each other, including at fractional ratios. ## Limits @@ -126,8 +126,14 @@ reuses the same image id. ``` src/client/ features/kitty/ - kitty.ts ← pure: APC splitter, key=value parser, kitty reply builder, clear scanner (unit tested) - kitty.test.ts + protocol/ ← pure, one concern per file, each with its x.test.ts (100% lines): + apc-splitter.ts APC sequences out of the PTY stream, with the size limit + apc-command.ts key=value parser (parseKittyApc) and num() + chunk-assembler.ts m=1 chunk reassembly, with the transmission cap + kitty-reply.ts the reply builder, honouring q=1 / q=2 + png-size.ts PNG IHDR width and height, from bytes or base64 + cell-geometry.ts device cell size, per-axis scale, cells an image covers + control-scanner.ts sequences that clear placements, across chunk boundaries graphics.ts ← browser: decode, placement table, overlay canvas, scroll tracking graphics.test.ts ← state handling against a stubbed DOM and image decoder index.ts ← wires ws.onmessage through KittyGraphics diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 56a100d..145e6af 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -21,7 +21,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, 'src/client/features/kitty/graphics.ts': { lines: 662, step: KITTY_SPLIT }, - 'src/client/features/kitty/kitty.ts': { lines: 384, step: KITTY_SPLIT }, // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. 'src/client/index.ts': { lines: 568, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, diff --git a/src/client/features/kitty/graphics.test.ts b/src/client/features/kitty/graphics.test.ts index 131f07c..d5a12a0 100644 --- a/src/client/features/kitty/graphics.test.ts +++ b/src/client/features/kitty/graphics.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { deflateSync } from 'node:zlib'; import { KittyGraphics, type Limits } from './graphics'; -import { deviceCellSize } from './kitty'; +import { deviceCellSize } from './protocol/cell-geometry'; // KittyGraphics needs a DOM, a canvas and image decoding. These tests stub just // enough of them to drive its state handling: what is placed, what is answered, diff --git a/src/client/features/kitty/graphics.ts b/src/client/features/kitty/graphics.ts index 3a0241c..5130f71 100644 --- a/src/client/features/kitty/graphics.ts +++ b/src/client/features/kitty/graphics.ts @@ -1,25 +1,21 @@ import type { Terminal } from '../../vendor/ghostty-web'; +import { type KittyKeys, num, parseKittyApc } from './protocol/apc-command'; +import { ApcSplitter } from './protocol/apc-splitter'; +import { axisScale, cellSpan } from './protocol/cell-geometry'; import { - ApcSplitter, - axisScale, ChunkAssembler, - ControlScanner, - cellSpan, - type KittyKeys, - kittyReply, MAX_TRANSMISSION_BYTES, - num, - parseKittyApc, - pngSize, - pngSizeFromBytes, type Transmission, -} from './kitty'; +} from './protocol/chunk-assembler'; +import { ControlScanner } from './protocol/control-scanner'; +import { kittyReply } from './protocol/kitty-reply'; +import { pngSize, pngSizeFromBytes } from './protocol/png-size'; // Sizes Ghostty reports to programs (CSI 14t/16t) are device pixels, as in kitty: // programs such as terminal-browser divide them by the display's // scale factor to get CSS pixels, so reporting CSS pixels makes their content // render at the wrong size. On a 2x display this means 4x the pixels per -// full-window frame. Cell sizes are whole device pixels (see kitty.ts), so the +// full-window frame. Cell sizes are whole device pixels (see protocol/cell-geometry.ts), so the // CSS-to-device scale is per axis. See ADR 032. interface Scale { x: number; diff --git a/src/client/features/kitty/kitty.test.ts b/src/client/features/kitty/kitty.test.ts deleted file mode 100644 index b03c83f..0000000 --- a/src/client/features/kitty/kitty.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - ApcSplitter, - axisScale, - ChunkAssembler, - ControlScanner, - cellSpan, - clearsPlacements, - deviceCellSize, - kittyReply, - parseKittyApc, - pngSize, - pngSizeFromBytes, -} from './kitty'; - -const ESC = '\x1b'; -const apc = (body: string): string => `${ESC}_${body}${ESC}\\`; - -describe('ApcSplitter', () => { - test('plain text passes through untouched', () => { - expect(new ApcSplitter().feed('hello \x1b[31mred\x1b[0m')).toEqual([ - { type: 'text', text: 'hello \x1b[31mred\x1b[0m' }, - ]); - }); - - test('splits text, APC, text in order', () => { - const out = new ApcSplitter().feed(`before${apc('Ga=q;AAAA')}after`); - expect(out).toEqual([ - { type: 'text', text: 'before' }, - { type: 'apc', body: 'Ga=q;AAAA' }, - { type: 'text', text: 'after' }, - ]); - }); - - test('multiple APCs in one chunk', () => { - const out = new ApcSplitter().feed(`${apc('Ga=d')}x${apc('Ga=d,d=A')}`); - expect(out.map((s) => s.type)).toEqual(['apc', 'text', 'apc']); - }); - - test('APC split across chunks at every byte boundary', () => { - const full = `pre${apc('Gi=1,a=T;QUJD')}post`; - for (let cut = 1; cut < full.length; cut++) { - const s = new ApcSplitter(); - const segs = [...s.feed(full.slice(0, cut)), ...s.feed(full.slice(cut))]; - const text = segs - .filter((x) => x.type === 'text') - .map((x) => (x as { text: string }).text) - .join(''); - const apcs = segs.filter((x) => x.type === 'apc'); - expect(text).toBe('prepost'); - expect(apcs).toEqual([{ type: 'apc', body: 'Gi=1,a=T;QUJD' }]); - } - }); - - test('a trailing lone ESC is held until the next chunk decides what it is', () => { - const s = new ApcSplitter(); - expect(s.feed('abc\x1b')).toEqual([{ type: 'text', text: 'abc' }]); - expect(s.feed('[31m')).toEqual([{ type: 'text', text: '\x1b[31m' }]); - }); - - test('ESC not followed by _ is returned to the text stream', () => { - expect(new ApcSplitter().feed('a\x1b[Hb\x1bcc')).toEqual([ - { type: 'text', text: 'a\x1b[Hb\x1bcc' }, - ]); - }); - - test('malformed APC (ESC not followed by backslash) is dropped and the ESC restarts a sequence', () => { - const out = new ApcSplitter().feed('\x1b_Gjunk\x1b[31mred'); - expect(out).toEqual([{ type: 'text', text: '\x1b[31mred' }]); - }); - - test('non-graphics APC is still swallowed', () => { - const out = new ApcSplitter().feed(`a${apc('Xsomething')}b`); - expect(out[1]).toEqual({ type: 'apc', body: 'Xsomething' }); - }); - - test('splitter is reusable after an APC completes', () => { - const s = new ApcSplitter(); - s.feed(apc('Ga=d')); - expect(s.feed('plain')).toEqual([{ type: 'text', text: 'plain' }]); - }); -}); - -describe('parseKittyApc', () => { - test('parses keys and payload', () => { - expect(parseKittyApc('Ga=T,f=32,s=2,v=1,i=7;AAAA')).toEqual({ - keys: { a: 'T', f: '32', s: '2', v: '1', i: '7' }, - payload: 'AAAA', - }); - }); - - test('no payload', () => { - expect(parseKittyApc('Ga=d,d=A')).toEqual({ keys: { a: 'd', d: 'A' }, payload: '' }); - }); - - test('payload may itself contain no semicolons beyond the first', () => { - expect(parseKittyApc('Gi=1;a;b')?.payload).toBe('a;b'); - }); - - test('non-graphics APC returns null', () => { - expect(parseKittyApc('Xhello')).toBeNull(); - }); -}); - -describe('ChunkAssembler', () => { - test('single chunk returns immediately', () => { - const a = new ChunkAssembler(); - expect(a.push({ keys: { a: 'T' }, payload: 'AAAA' })).toEqual({ - keys: { a: 'T' }, - data: 'AAAA', - }); - }); - - test('m=1 chunks are joined; first chunk keys win', () => { - const a = new ChunkAssembler(); - expect(a.push({ keys: { a: 'T', i: '3', m: '1' }, payload: 'AAAA' })).toBeNull(); - expect(a.push({ keys: { m: '1' }, payload: 'BBBB' })).toBeNull(); - expect(a.push({ keys: { m: '0' }, payload: 'CCCC' })).toEqual({ - keys: { a: 'T', i: '3', m: '1' }, - data: 'AAAABBBBCCCC', - }); - }); - - test('assembler resets after a transmission completes', () => { - const a = new ChunkAssembler(); - a.push({ keys: { m: '1' }, payload: 'A' }); - a.push({ keys: {}, payload: 'B' }); - expect(a.push({ keys: { i: '9' }, payload: 'Z' })).toEqual({ keys: { i: '9' }, data: 'Z' }); - }); -}); - -describe('kittyReply', () => { - test('OK reply carries the id', () => { - expect(kittyReply({ i: '31' }, 31)).toBe('\x1b_Gi=31;OK\x1b\\'); - }); - - test('includes placement id and image number when present', () => { - expect(kittyReply({ i: '5', p: '2' }, 5)).toBe('\x1b_Gi=5,p=2;OK\x1b\\'); - expect(kittyReply({ I: '9' }, 1000009)).toBe('\x1b_Gi=1000009,I=9;OK\x1b\\'); - }); - - test('no id → no reply', () => { - expect(kittyReply({ a: 'T' }, 0)).toBeNull(); - }); - - test('q=1 suppresses OK but not errors', () => { - expect(kittyReply({ i: '1', q: '1' }, 1)).toBeNull(); - expect(kittyReply({ i: '1', q: '1' }, 1, { code: 'EINVAL', message: 'x' })).toBe( - '\x1b_Gi=1;EINVAL:x\x1b\\', - ); - }); - - test('q=2 suppresses everything', () => { - expect(kittyReply({ i: '1', q: '2' }, 1)).toBeNull(); - expect(kittyReply({ i: '1', q: '2' }, 1, { code: 'EINVAL', message: 'x' })).toBeNull(); - }); -}); - -describe('pngSize', () => { - test('reads width and height from the IHDR', () => { - // 8-byte signature, 4-byte length, "IHDR", width=300, height=200 - const bytes = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, - 0x01, 0x2c, 0, 0, 0, 0xc8, - ]); - expect(pngSize(btoa(String.fromCharCode(...bytes)))).toEqual({ width: 300, height: 200 }); - }); - - test('not a PNG → null', () => { - expect(pngSize(btoa('x'.repeat(24)))).toBeNull(); - expect(pngSize('AAAA')).toBeNull(); - }); -}); - -describe('placement clearing', () => { - test.each(['\x1b[2J', '\x1b[3J', '\x1b[?1049h', '\x1b[?1049l', '\x1b[?47h', '\x1bc'])( - '%j clears', - (seq) => { - expect(clearsPlacements(`text${seq}more`)).toBe(true); - }, - ); - - test.each(['\x1b[J', '\x1b[1J', '\x1b[?25h', '\x1b[2K', 'plain'])('%j does not', (seq) => { - expect(clearsPlacements(seq)).toBe(false); - }); -}); - -describe('cellSpan', () => { - const img = { width: 100, height: 50 }; - - test('natural size rounds up to whole cells', () => { - expect(cellSpan({}, img, 8, 16)).toEqual({ cols: 13, rows: 4 }); - }); - - test('c and r win', () => { - expect(cellSpan({ c: '10', r: '5' }, img, 8, 16)).toEqual({ cols: 10, rows: 5 }); - }); - - test('c alone keeps the aspect ratio', () => { - // 20 cols * 8px = 160px wide → scale 1.6 → 80px tall → 5 rows of 16px - expect(cellSpan({ c: '20' }, img, 8, 16)).toEqual({ cols: 20, rows: 5 }); - }); - - test('r alone keeps the aspect ratio', () => { - // 4 rows * 16px = 64px tall → scale 1.28 → 128px wide → 16 cols of 8px - expect(cellSpan({ r: '4' }, img, 8, 16)).toEqual({ cols: 16, rows: 4 }); - }); - - test('the span follows the region passed in, not the whole image', () => { - // A 16x32 source rectangle at 2x is 8x16 CSS px: one cell, not the two the - // device-pixel numbers would suggest. - expect(cellSpan({ w: '16', h: '32' }, { width: 16 / 2, height: 32 / 2 }, 8, 16)).toEqual({ - cols: 1, - rows: 1, - }); - }); -}); - -describe('device pixel scale', () => { - test('cell size is a whole number of device pixels', () => { - expect(deviceCellSize(9.6, 2)).toBe(19); - expect(deviceCellSize(9.6, 1.25)).toBe(12); - expect(deviceCellSize(9.6, 1.5)).toBe(14); - expect(deviceCellSize(0.2, 1)).toBe(1); - }); - - test.each([1, 1.25, 1.5, 1.75, 2, 3])( - 'a frame of cols cells fills cols CSS cells exactly at dpr %p', - (dpr) => { - const cols = 200; - const cssCell = 9.6; - const frameWidth = cols * deviceCellSize(cssCell, dpr); - expect(frameWidth / axisScale(cssCell, dpr)).toBeCloseTo(cols * cssCell, 6); - }, - ); - - test('raw devicePixelRatio would drift off the grid at fractional scales', () => { - const cols = 200; - const cssCell = 9.6; - const drawn = (cols * deviceCellSize(cssCell, 1.75)) / 1.75; - expect(Math.abs(drawn - cols * cssCell)).toBeGreaterThan(5); - }); - - test('scale follows the snapped cell, close to devicePixelRatio', () => { - expect(axisScale(9.6, 2)).toBeCloseTo(19 / 9.6, 10); - expect(axisScale(0, 2)).toBe(2); - }); -}); - -describe('ChunkAssembler size cap', () => { - test('chunks that add up past the cap are dropped and reported once, at the end', () => { - const a = new ChunkAssembler(10); - expect(a.push({ keys: { i: '4', m: '1' }, payload: 'AAAAAA' })).toBeNull(); - // 6 + 6 > 10: dropped here, the rest of the chunks are swallowed - expect(a.push({ keys: { m: '1' }, payload: 'BBBBBB' })).toBeNull(); - expect(a.push({ keys: { m: '1' }, payload: 'C' })).toBeNull(); - expect(a.push({ keys: { m: '0' }, payload: 'D' })).toEqual({ - keys: { i: '4', m: '1' }, - data: '', - tooBig: true, - }); - }); - - test('the cap applies when the overflow is on the final chunk', () => { - const a = new ChunkAssembler(10); - a.push({ keys: { i: '4', m: '1' }, payload: 'AAAAAA' }); - expect(a.push({ keys: { m: '0' }, payload: 'BBBBBB' })).toMatchObject({ tooBig: true }); - }); - - test('a single oversized chunk is reported straight away', () => { - const a = new ChunkAssembler(4); - expect(a.push({ keys: { i: '2' }, payload: 'AAAAAAAA' })).toMatchObject({ - keys: { i: '2' }, - tooBig: true, - }); - }); - - test('a first chunk that is too big swallows the chunks that follow it', () => { - const a = new ChunkAssembler(4); - expect(a.push({ keys: { i: '2', m: '1' }, payload: 'AAAAAAAA' })).toBeNull(); - expect(a.push({ keys: { m: '1' }, payload: 'B' })).toBeNull(); - expect(a.push({ keys: { m: '0' }, payload: 'C' })).toMatchObject({ tooBig: true }); - }); - - test('normal transmissions still work after an oversized one', () => { - const a = new ChunkAssembler(4); - a.push({ keys: { m: '0' }, payload: 'AAAAAAAA' }); - expect(a.push({ keys: { i: '9' }, payload: 'Z' })).toEqual({ keys: { i: '9' }, data: 'Z' }); - }); -}); - -describe('ControlScanner', () => { - test('a clear split across chunks is seen once it completes', () => { - const s = new ControlScanner(); - expect(s.scan('text\x1b[')).toBe(false); - expect(s.scan('2Jmore')).toBe(true); - }); - - test('split at every position', () => { - const full = 'a\x1b[?1049hb\x1b[16tc'; - for (let cut = 1; cut < full.length; cut++) { - const s = new ControlScanner(); - const a = s.scan(full.slice(0, cut)); - const b = s.scan(full.slice(cut)); - expect(a || b).toBe(true); - } - }); - - test('a lone trailing ESC is carried, so ESC c split in two is a reset', () => { - const s = new ControlScanner(); - expect(s.scan('x\x1b')).toBe(false); - expect(s.scan('cy')).toBe(true); - }); - - test('a completed sequence is not counted again with the next chunk', () => { - const s = new ControlScanner(); - expect(s.scan('\x1b[2J')).toBe(true); - expect(s.scan('plain')).toBe(false); - }); - - test('an unrelated unfinished sequence is dropped once it turns into something else', () => { - const s = new ControlScanner(); - s.scan('\x1b[3'); - expect(s.scan('1m text')).toBe(false); - expect(s.scan('plain')).toBe(false); - }); -}); - -describe('pngSizeFromBytes', () => { - const header = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 0x01, - 0x2c, 0, 0, 0, 0xc8, - ]); - - test('reads the IHDR of a decoded file', () => { - expect(pngSizeFromBytes(header)).toEqual({ width: 300, height: 200 }); - }); - - test('rejects short or non-PNG data', () => { - expect(pngSizeFromBytes(header.subarray(0, 10))).toBeNull(); - expect(pngSizeFromBytes(new Uint8Array(30))).toBeNull(); - }); -}); - -describe('cellSpan at fractional display scales', () => { - // A program sizes an image as whole cells of the size it was told; converting - // that back to CSS pixels must give the same number of cells, not one more - // because of floating-point error. - test.each([1, 1.25, 1.5, 1.75, 2, 3])( - 'a whole number of cells stays that many at dpr %p', - (dpr) => { - for (const css of [7.8, 8.4, 9.6, 10.2]) { - for (const cells of [1, 2, 3, 7, 80, 213]) { - const devCell = deviceCellSize(css, dpr); - const region = { width: (cells * devCell) / axisScale(css, dpr), height: 1 }; - expect(cellSpan({}, region, css, 1).cols).toBe(cells); - } - } - }, - ); -}); - -describe('ApcSplitter size limit', () => { - const big = (n: number) => 'A'.repeat(n); - - test('an oversized sequence is reported with its start, so it can still be answered', () => { - const out = new ApcSplitter(20).feed(`before${apc(`Ga=t,i=1;${big(50)}`)}after`); - expect(out).toHaveLength(3); - expect(out[0]).toEqual({ type: 'text', text: 'before' }); - expect(out[1]).toMatchObject({ type: 'apc', overflow: true }); - expect((out[1] as { body: string }).body.startsWith('Ga=t,i=1;')).toBe(true); - expect(out[2]).toEqual({ type: 'text', text: 'after' }); - }); - - test('none of the payload reaches the text', () => { - const out = new ApcSplitter(20).feed(apc(`Ga=t,i=1;${big(500)}`)); - expect(out.filter((s) => s.type === 'text')).toEqual([]); - }); - - test('only the start is kept, however large the sequence', () => { - const out = new ApcSplitter(20).feed(apc(`Ga=t,i=1;${big(20000)}`)); - expect((out[0] as { body: string }).body.length).toBe(4096); - }); - - test('a sequence that is too big is still recognised across chunks', () => { - const s = new ApcSplitter(20); - expect(s.feed(`${ESC}_Ga=t,i=1;${big(30)}`)).toEqual([]); - expect(s.feed(big(30))).toEqual([]); - const out = s.feed(`${big(5)}${ESC}\\ok`); - expect(out[0]).toMatchObject({ type: 'apc', overflow: true }); - expect(out[1]).toEqual({ type: 'text', text: 'ok' }); - }); - - test('a sequence at the limit is delivered whole', () => { - const body = `Ga=t;${big(15)}`; - expect(new ApcSplitter(body.length).feed(apc(body))).toEqual([{ type: 'apc', body }]); - }); - - test.each([ - ['CAN', '\x18'], - ['SUB', '\x1a'], - ])('%s cancels a sequence that was never terminated, so text resumes', (_name, stop) => { - const s = new ApcSplitter(20); - expect(s.feed(`${ESC}_Ga=t;${big(100)}`)).toEqual([]); - expect(s.feed(`still payload${stop}text again`)).toEqual([ - { type: 'text', text: 'text again' }, - ]); - }); - - test('CAN cancels an ordinary sequence too, delivering nothing', () => { - const s = new ApcSplitter(); - expect(s.feed(`${ESC}_Ga=q;AA\x18after`)).toEqual([{ type: 'text', text: 'after' }]); - }); - - test('CAN in plain text is just text', () => { - expect(new ApcSplitter().feed('a\x18b')).toEqual([{ type: 'text', text: 'a\x18b' }]); - }); -}); - -describe('ChunkAssembler.reject', () => { - test('a rejected single command is reported as too big, with its keys', () => { - expect(new ChunkAssembler().reject({ keys: { i: '3' }, payload: '' })).toEqual({ - keys: { i: '3' }, - data: '', - tooBig: true, - }); - }); - - test('a rejected first chunk swallows the chunks after it and reports once at the end', () => { - const a = new ChunkAssembler(); - expect(a.reject({ keys: { i: '3', m: '1' }, payload: '' })).toBeNull(); - expect(a.push({ keys: { m: '1' }, payload: 'AA' })).toBeNull(); - expect(a.push({ keys: { m: '0' }, payload: 'AA' })).toEqual({ - keys: { i: '3', m: '1' }, - data: '', - tooBig: true, - }); - }); - - test('a rejected later chunk drops what was pending and answers for the first chunk', () => { - const a = new ChunkAssembler(); - a.push({ keys: { i: '3', m: '1' }, payload: 'AAAA' }); - expect(a.reject({ keys: { m: '0' }, payload: '' })).toEqual({ - keys: { i: '3', m: '1' }, - data: '', - tooBig: true, - }); - // and the next transmission starts clean - expect(a.push({ keys: { i: '4' }, payload: 'Z' })).toEqual({ keys: { i: '4' }, data: 'Z' }); - }); -}); - -describe('pngSizeFromBytes validation', () => { - const valid = () => - new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, - 0x01, 0x2c, 0, 0, 0, 0xc8, - ]); - - test('a valid header is read', () => { - expect(pngSizeFromBytes(valid())).toEqual({ width: 300, height: 200 }); - }); - - test.each([ - ['first byte of the signature', (b: Uint8Array) => (b[0] = 0x00)], - ['line-ending bytes of the signature', (b: Uint8Array) => (b[5] = 0x00)], - ['IHDR length', (b: Uint8Array) => (b[11] = 12)], - ['IHDR type', (b: Uint8Array) => (b[15] = 0x58)], - ['zero width', (b: Uint8Array) => b.fill(0, 16, 20)], - ['zero height', (b: Uint8Array) => b.fill(0, 20, 24)], - ['width over 2^31 - 1', (b: Uint8Array) => b.fill(0xff, 16, 20)], - ])('a wrong %s is refused', (_name, corrupt) => { - const b = valid(); - corrupt(b); - expect(pngSizeFromBytes(b)).toBeNull(); - }); - - test('bytes that only spell PNG at offsets 1 to 3 are not a PNG', () => { - const b = new Uint8Array(24); - b.set([0x00, 0x50, 0x4e, 0x47], 0); - expect(pngSizeFromBytes(b)).toBeNull(); - }); - - test('the base64 form is validated the same way', () => { - const b = valid(); - b[0] = 0x00; - expect(pngSize(btoa(String.fromCharCode(...b)))).toBeNull(); - }); -}); diff --git a/src/client/features/kitty/kitty.ts b/src/client/features/kitty/kitty.ts deleted file mode 100644 index 8a7716d..0000000 --- a/src/client/features/kitty/kitty.ts +++ /dev/null @@ -1,384 +0,0 @@ -// Pure (DOM-free) half of the kitty graphics protocol support: splitting APC -// sequences out of the PTY stream, parsing them, and building replies. The -// browser half (decode, placements, overlay canvas) is in graphics.ts. -// See ADR 032. - -const ESC = '\x1b'; - -// A single APC is one chunk of at most 4096 base64 bytes per the spec, but -// nothing stops a client sending one huge sequence. Bound what we buffer. -export const MAX_APC_BYTES = 64 * 1024 * 1024; - -// How much of the start of an APC is kept when it is too big to keep whole: -// enough for the control keys, so the command can still be answered. -const HEAD_BYTES = 4096; - -// What ends an APC: ST (`ESC \`), or CAN / SUB, which cancel a control string. -const CAN = '\x18'; -const SUB = '\x1a'; -const APC_STOP = new RegExp(`[${ESC}${CAN}${SUB}]`, 'g'); - -export type Segment = - | { type: 'text'; text: string } - // `overflow`: the sequence was over the size limit, so `body` is only its - // start (the control keys), enough to answer with an error. - | { type: 'apc'; body: string; overflow?: boolean }; - -/** - * Splits a PTY stream into plain text and APC sequences (`ESC _ ... ESC \`). - * - * State survives across `feed()` calls: a WebSocket frame can end anywhere, - * including between the `ESC` and `_` that open an APC, or between the `ESC` - * and `\` that close it. A lone trailing `ESC` is therefore held back until - * the next chunk says what it starts. - * - * A sequence over `maxBytes` is not buffered whole: its start is kept and the - * rest is swallowed up to its terminator, so none of the payload reaches the - * terminal as text. Like any control string it ends at ST, or at CAN / SUB, - * which is the way out of one that is never terminated. - */ -export class ApcSplitter { - private state: 'text' | 'esc' | 'apc' | 'apc-esc' = 'text'; - private parts: string[] = []; - private head = ''; - private size = 0; - private overflow = false; - - constructor(private maxBytes = MAX_APC_BYTES) {} - - feed(data: string): Segment[] { - const out: Segment[] = []; - let text = ''; - let i = 0; - while (i < data.length) { - if (this.state === 'text') { - const esc = data.indexOf(ESC, i); - if (esc < 0) { - text += data.slice(i); - break; - } - text += data.slice(i, esc); - i = esc + 1; - this.state = 'esc'; - } else if (this.state === 'esc') { - if (data[i] === '_') { - if (text) out.push({ type: 'text', text }); - text = ''; - this.begin(); - i++; - } else { - // Not an APC: give the held ESC back and reprocess this char. - text += ESC; - this.state = 'text'; - } - } else if (this.state === 'apc') { - APC_STOP.lastIndex = i; - const stop = APC_STOP.exec(data); - const end = stop ? stop.index : data.length; - this.append(data.slice(i, end)); - i = end; - if (stop) { - i++; - if (stop[0] === ESC) this.state = 'apc-esc'; - else this.reset(); // CAN / SUB: cancelled, nothing to deliver - } - } else if (data[i] === '\\') { - // apc-esc: ESC \ closes the sequence. - out.push( - this.overflow - ? { type: 'apc', body: this.head, overflow: true } - : { type: 'apc', body: this.parts.join('') }, - ); - this.reset(); - i++; - } else { - // ESC not followed by `\` inside an APC: malformed. Drop the APC and - // treat the ESC as the start of a fresh sequence. - this.reset(); - this.state = 'esc'; - } - } - if (text) out.push({ type: 'text', text }); - return out; - } - - private begin(): void { - this.reset(); - this.state = 'apc'; - } - - private append(s: string): void { - if (!s) return; - if (this.head.length < HEAD_BYTES) this.head += s.slice(0, HEAD_BYTES - this.head.length); - if (this.overflow) return; - this.size += s.length; - if (this.size > this.maxBytes) { - this.overflow = true; - this.parts = []; - return; - } - this.parts.push(s); - } - - private reset(): void { - this.state = 'text'; - this.parts = []; - this.head = ''; - this.size = 0; - this.overflow = false; - } -} - -export type KittyKeys = Record<string, string>; - -export interface KittyCommand { - keys: KittyKeys; - payload: string; -} - -/** Parses an APC body (`G<k=v,k=v>;<payload>`). Returns null if it is not graphics. */ -export function parseKittyApc(body: string): KittyCommand | null { - if (body[0] !== 'G') return null; - const semi = body.indexOf(';'); - const control = semi < 0 ? body.slice(1) : body.slice(1, semi); - const payload = semi < 0 ? '' : body.slice(semi + 1); - const keys: KittyKeys = {}; - for (const pair of control.split(',')) { - const eq = pair.indexOf('='); - if (eq > 0) keys[pair.slice(0, eq)] = pair.slice(eq + 1); - } - return { keys, payload }; -} - -export interface Transmission { - keys: KittyKeys; - data: string; - /** The chunks added up to more than the size cap and were dropped; `data` is empty. */ - tooBig?: boolean; -} - -/** - * Cap on one chunked transmission's total encoded size. `MAX_APC_BYTES` bounds a - * single sequence, but a client can send any number of `m=1` chunks that are - * each valid, and every payload is held until the final one arrives. - */ -export const MAX_TRANSMISSION_BYTES = 64 * 1024 * 1024; - -/** - * Reassembles chunked transmissions. The first chunk carries every key; the - * following ones carry only `m` (and optionally `q`). `m=1` means more - * follows, `m=0` or absent ends it. - * - * A transmission that grows past `maxBytes` is dropped and the rest of its - * chunks are swallowed, so they are not mistaken for new transmissions. The - * caller gets a `tooBig` result once the final chunk arrives, to reply to. - */ -export class ChunkAssembler { - private pending: { keys: KittyKeys; parts: string[]; size: number } | null = null; - private discarding: KittyKeys | null = null; - - constructor(private maxBytes = MAX_TRANSMISSION_BYTES) {} - - /** - * A chunk that could not be delivered whole (the splitter's size limit). Its - * control keys are all that is known; treat it as the end of an oversized - * transmission, or as one more chunk of one if it says more follows. - */ - reject(cmd: KittyCommand): Transmission | null { - const keys = this.pending?.keys ?? this.discarding ?? cmd.keys; - this.pending = null; - if (cmd.keys.m === '1') { - this.discarding = keys; - return null; - } - this.discarding = null; - return { keys, data: '', tooBig: true }; - } - - push(cmd: KittyCommand): Transmission | null { - const more = cmd.keys.m === '1'; - if (this.discarding) { - if (more) return null; - const keys = this.discarding; - this.discarding = null; - return { keys, data: '', tooBig: true }; - } - const size = (this.pending?.size ?? 0) + cmd.payload.length; - if (size > this.maxBytes) { - const keys = this.pending?.keys ?? cmd.keys; - this.pending = null; - if (more) { - this.discarding = keys; - return null; - } - return { keys, data: '', tooBig: true }; - } - if (!this.pending) { - if (!more) return { keys: cmd.keys, data: cmd.payload }; - this.pending = { keys: cmd.keys, parts: [cmd.payload], size }; - return null; - } - this.pending.parts.push(cmd.payload); - this.pending.size = size; - if (more) return null; - const done = { keys: this.pending.keys, data: this.pending.parts.join('') }; - this.pending = null; - return done; - } -} - -/** Numeric key value, or `fallback` if absent / not a number. */ -export function num(keys: KittyKeys, key: string, fallback = 0): number { - const v = Number(keys[key]); - return keys[key] !== undefined && Number.isFinite(v) ? v : fallback; -} - -/** - * Builds the reply for a command, or null when none must be sent: a command - * without an image id/number gets no reply, `q=1` suppresses OK replies and - * `q=2` suppresses everything. - */ -export function kittyReply( - keys: KittyKeys, - imageId: number, - error?: { code: string; message: string }, -): string | null { - if (keys.i === undefined && keys.I === undefined) return null; - const q = num(keys, 'q'); - if (q === 2 || (q === 1 && !error)) return null; - const ids = [`i=${imageId}`]; - if (keys.I !== undefined) ids.push(`I=${keys.I}`); - if (keys.p !== undefined) ids.push(`p=${keys.p}`); - const status = error ? `${error.code}:${error.message}` : 'OK'; - return `${ESC}_G${ids.join(',')};${status}${ESC}\\`; -} - -const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; - -/** - * Width and height from a PNG's IHDR, given the first 24 bytes of the file or - * more. The header is checked in full (signature, that the first chunk is a - * 13-byte IHDR, and that the dimensions are valid) because the result is used - * to move the cursor before the decoder has looked at the data. - */ -export function pngSizeFromBytes(bytes: Uint8Array): { width: number; height: number } | null { - if (bytes.length < 24) return null; - for (let i = 0; i < PNG_SIGNATURE.length; i++) if (bytes[i] !== PNG_SIGNATURE[i]) return null; - const u32 = (o: number): number => - ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]) >>> 0; - if (u32(8) !== 13) return null; - if (u32(12) !== 0x49484452) return null; // "IHDR" - const width = u32(16); - const height = u32(20); - // PNG dimensions are 1 to 2^31 - 1 - if (width === 0 || height === 0 || width > 0x7fffffff || height > 0x7fffffff) return null; - return { width, height }; -} - -/** - * Width and height from a PNG's IHDR, read from the first 24 bytes of the - * base64 payload. Lets cursor movement be computed before the (async) decode. - * Only valid when the payload is not zlib-compressed. - */ -export function pngSize(base64: string): { width: number; height: number } | null { - if (base64.length < 32) return null; - let bin: string; - try { - bin = atob(base64.slice(0, 32)); - } catch { - return null; - } - return pngSizeFromBytes(Uint8Array.from(bin, (c) => c.charCodeAt(0))); -} - -// Sequences after which any placed image no longer corresponds to what is on -// screen: erase display (2 = screen, 3 = screen + scrollback), alternate -// screen switches, and full reset. The emulator cannot tell us about overwrites, -// so these are the only points where placements are dropped implicitly. -const CLEARS_PLACEMENTS = new RegExp(`${ESC}\\[[23]J|${ESC}\\[\\?(?:1049|1047|47)[hl]|${ESC}c`); - -export function clearsPlacements(text: string): boolean { - return CLEARS_PLACEMENTS.test(text); -} - -/** - * A cell's size in whole device pixels. Ghostty reports it to programs (CSI 16t, - * from terminal.ts `deviceCellPixels`, which computes the same) and they size - * frames as cols x this, so it has to be an integer even though the CSS cell size - * (and devicePixelRatio, e.g. 1.25 or 1.5 on Windows) is not. - */ -export function deviceCellSize(cssCell: number, dpr: number): number { - return Math.max(1, Math.round(cssCell * dpr)); -} - -/** - * Device pixels per CSS pixel along one axis, derived from the whole-pixel cell - * size rather than from devicePixelRatio directly. A frame of `cols` cells is - * `cols * deviceCellSize` pixels wide; dividing by this scale draws it exactly - * `cols * cssCell` CSS pixels wide, however the rounding fell. Dividing by the - * raw devicePixelRatio would drift by up to half a pixel per column, which - * leaves the image off the terminal grid at fractional scales. - */ -export function axisScale(cssCell: number, dpr: number): number { - return cssCell > 0 ? deviceCellSize(cssCell, dpr) / cssCell : dpr; -} - -// A sequence cut off at the end of a chunk: ESC, or ESC [ followed by parameter -// bytes but no final byte yet. -const INCOMPLETE = new RegExp(`${ESC}(?:\\[[0-9;?]{0,16})?$`); - -/** - * Finds clear sequences in text that arrives in arbitrary pieces. PTY output and - * WebSocket frames can end anywhere, so `ESC [` in one piece and `2 J` in the next - * are one sequence; the terminal reassembles them in its own parser, and this - * keeps the unfinished tail between calls so the client sees them too. - */ -export class ControlScanner { - private carry = ''; - - /** Whether a sequence that invalidates placed images was completed. */ - scan(text: string): boolean { - const s = this.carry + text; - const tail = INCOMPLETE.exec(s); - // Anything before the tail is complete, so it is scanned now; the tail - // itself cannot match yet and is scanned again once it is finished. - this.carry = tail ? tail[0] : ''; - return clearsPlacements(s); - } -} - -export interface CellSpan { - cols: number; - rows: number; -} - -/** - * How many cells an image covers. `c` / `r` win when given; when only one is - * given the other follows from the aspect ratio; with neither, the image is - * shown at natural size. `region` is the size in CSS pixels of the part of the - * image being shown (the whole image, or the `w` x `h` source rectangle already - * converted from device pixels by the caller). - */ -export function cellSpan( - keys: KittyKeys, - region: { width: number; height: number }, - cellWidth: number, - cellHeight: number, -): CellSpan { - const c = num(keys, 'c'); - const r = num(keys, 'r'); - const sw = region.width; - const sh = region.height; - if (c && r) return { cols: c, rows: r }; - if (c) return { cols: c, rows: cellsFor((c * cellWidth * sh) / sw, cellHeight) }; - if (r) return { cols: cellsFor((r * cellHeight * sw) / sh, cellWidth), rows: r }; - return { cols: cellsFor(sw, cellWidth), rows: cellsFor(sh, cellHeight) }; -} - -// Whole cells needed for `px`. A size that is exactly N cells, converted from -// device to CSS pixels, can come out as N + a rounding error, which a plain -// ceil turns into N + 1: the tolerance is far below any real fraction of a -// cell. -function cellsFor(px: number, cell: number): number { - return Math.max(1, Math.ceil(px / cell - 1e-6)); -} diff --git a/src/client/features/kitty/protocol/apc-command.test.ts b/src/client/features/kitty/protocol/apc-command.test.ts new file mode 100644 index 0000000..079769c --- /dev/null +++ b/src/client/features/kitty/protocol/apc-command.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { num, parseKittyApc } from './apc-command'; + +describe('parseKittyApc', () => { + test('parses keys and payload', () => { + expect(parseKittyApc('Ga=T,f=32,s=2,v=1,i=7;AAAA')).toEqual({ + keys: { a: 'T', f: '32', s: '2', v: '1', i: '7' }, + payload: 'AAAA', + }); + }); + + test('no payload', () => { + expect(parseKittyApc('Ga=d,d=A')).toEqual({ keys: { a: 'd', d: 'A' }, payload: '' }); + }); + + test('payload may itself contain no semicolons beyond the first', () => { + expect(parseKittyApc('Gi=1;a;b')?.payload).toBe('a;b'); + }); + + test('non-graphics APC returns null', () => { + expect(parseKittyApc('Xhello')).toBeNull(); + }); +}); + +describe('num', () => { + test.each([ + ['a number', { s: '42' }, 42], + ['absent', {}, 7], + ['not a number', { s: 'x' }, 7], + ['not finite', { s: 'Infinity' }, 7], + ])('%s', (_name, keys, expected) => { + expect(num(keys as Record<string, string>, 's', 7)).toBe(expected); + }); + + test('the fallback defaults to 0', () => { + expect(num({}, 's')).toBe(0); + }); +}); diff --git a/src/client/features/kitty/protocol/apc-command.ts b/src/client/features/kitty/protocol/apc-command.ts new file mode 100644 index 0000000..44002ca --- /dev/null +++ b/src/client/features/kitty/protocol/apc-command.ts @@ -0,0 +1,31 @@ +// A kitty graphics command parsed from an APC body (util, pure, ADR 032): its control +// keys and payload, and how a numeric key is read. + +/** A command's control keys (`a=T`, `i=7`, ...) by name, values still strings. */ +export type KittyKeys = Record<string, string>; + +/** One parsed graphics command: what `parseKittyApc` returns and the assembler takes. */ +export interface KittyCommand { + keys: KittyKeys; + payload: string; +} + +/** Parses an APC body (`G<k=v,k=v>;<payload>`). Returns null if it is not graphics. */ +export function parseKittyApc(body: string): KittyCommand | null { + if (body[0] !== 'G') return null; + const semi = body.indexOf(';'); + const control = semi < 0 ? body.slice(1) : body.slice(1, semi); + const payload = semi < 0 ? '' : body.slice(semi + 1); + const keys: KittyKeys = {}; + for (const pair of control.split(',')) { + const eq = pair.indexOf('='); + if (eq > 0) keys[pair.slice(0, eq)] = pair.slice(eq + 1); + } + return { keys, payload }; +} + +/** Numeric key value, or `fallback` if absent / not a number. */ +export function num(keys: KittyKeys, key: string, fallback = 0): number { + const v = Number(keys[key]); + return keys[key] !== undefined && Number.isFinite(v) ? v : fallback; +} diff --git a/src/client/features/kitty/protocol/apc-splitter.test.ts b/src/client/features/kitty/protocol/apc-splitter.test.ts new file mode 100644 index 0000000..384085d --- /dev/null +++ b/src/client/features/kitty/protocol/apc-splitter.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import { ApcSplitter } from './apc-splitter'; + +const ESC = '\x1b'; +const apc = (body: string): string => `${ESC}_${body}${ESC}\\`; + +describe('ApcSplitter', () => { + test('plain text passes through untouched', () => { + expect(new ApcSplitter().feed('hello \x1b[31mred\x1b[0m')).toEqual([ + { type: 'text', text: 'hello \x1b[31mred\x1b[0m' }, + ]); + }); + + test('splits text, APC, text in order', () => { + const out = new ApcSplitter().feed(`before${apc('Ga=q;AAAA')}after`); + expect(out).toEqual([ + { type: 'text', text: 'before' }, + { type: 'apc', body: 'Ga=q;AAAA' }, + { type: 'text', text: 'after' }, + ]); + }); + + test('multiple APCs in one chunk', () => { + const out = new ApcSplitter().feed(`${apc('Ga=d')}x${apc('Ga=d,d=A')}`); + expect(out.map((s) => s.type)).toEqual(['apc', 'text', 'apc']); + }); + + test('APC split across chunks at every byte boundary', () => { + const full = `pre${apc('Gi=1,a=T;QUJD')}post`; + for (let cut = 1; cut < full.length; cut++) { + const s = new ApcSplitter(); + const segs = [...s.feed(full.slice(0, cut)), ...s.feed(full.slice(cut))]; + const text = segs + .filter((x) => x.type === 'text') + .map((x) => (x as { text: string }).text) + .join(''); + const apcs = segs.filter((x) => x.type === 'apc'); + expect(text).toBe('prepost'); + expect(apcs).toEqual([{ type: 'apc', body: 'Gi=1,a=T;QUJD' }]); + } + }); + + test('a trailing lone ESC is held until the next chunk decides what it is', () => { + const s = new ApcSplitter(); + expect(s.feed('abc\x1b')).toEqual([{ type: 'text', text: 'abc' }]); + expect(s.feed('[31m')).toEqual([{ type: 'text', text: '\x1b[31m' }]); + }); + + test('ESC not followed by _ is returned to the text stream', () => { + expect(new ApcSplitter().feed('a\x1b[Hb\x1bcc')).toEqual([ + { type: 'text', text: 'a\x1b[Hb\x1bcc' }, + ]); + }); + + test('malformed APC (ESC not followed by backslash) is dropped and the ESC restarts a sequence', () => { + const out = new ApcSplitter().feed('\x1b_Gjunk\x1b[31mred'); + expect(out).toEqual([{ type: 'text', text: '\x1b[31mred' }]); + }); + + test('non-graphics APC is still swallowed', () => { + const out = new ApcSplitter().feed(`a${apc('Xsomething')}b`); + expect(out[1]).toEqual({ type: 'apc', body: 'Xsomething' }); + }); + + test('splitter is reusable after an APC completes', () => { + const s = new ApcSplitter(); + s.feed(apc('Ga=d')); + expect(s.feed('plain')).toEqual([{ type: 'text', text: 'plain' }]); + }); +}); + +describe('ApcSplitter size limit', () => { + const big = (n: number) => 'A'.repeat(n); + + test('an oversized sequence is reported with its start, so it can still be answered', () => { + const out = new ApcSplitter(20).feed(`before${apc(`Ga=t,i=1;${big(50)}`)}after`); + expect(out).toHaveLength(3); + expect(out[0]).toEqual({ type: 'text', text: 'before' }); + expect(out[1]).toMatchObject({ type: 'apc', overflow: true }); + expect((out[1] as { body: string }).body.startsWith('Ga=t,i=1;')).toBe(true); + expect(out[2]).toEqual({ type: 'text', text: 'after' }); + }); + + test('none of the payload reaches the text', () => { + const out = new ApcSplitter(20).feed(apc(`Ga=t,i=1;${big(500)}`)); + expect(out.filter((s) => s.type === 'text')).toEqual([]); + }); + + test('only the start is kept, however large the sequence', () => { + const out = new ApcSplitter(20).feed(apc(`Ga=t,i=1;${big(20000)}`)); + expect((out[0] as { body: string }).body.length).toBe(4096); + }); + + test('a sequence that is too big is still recognised across chunks', () => { + const s = new ApcSplitter(20); + expect(s.feed(`${ESC}_Ga=t,i=1;${big(30)}`)).toEqual([]); + expect(s.feed(big(30))).toEqual([]); + const out = s.feed(`${big(5)}${ESC}\\ok`); + expect(out[0]).toMatchObject({ type: 'apc', overflow: true }); + expect(out[1]).toEqual({ type: 'text', text: 'ok' }); + }); + + test('a sequence at the limit is delivered whole', () => { + const body = `Ga=t;${big(15)}`; + expect(new ApcSplitter(body.length).feed(apc(body))).toEqual([{ type: 'apc', body }]); + }); + + test.each([ + ['CAN', '\x18'], + ['SUB', '\x1a'], + ])('%s cancels a sequence that was never terminated, so text resumes', (_name, stop) => { + const s = new ApcSplitter(20); + expect(s.feed(`${ESC}_Ga=t;${big(100)}`)).toEqual([]); + expect(s.feed(`still payload${stop}text again`)).toEqual([ + { type: 'text', text: 'text again' }, + ]); + }); + + test('CAN cancels an ordinary sequence too, delivering nothing', () => { + const s = new ApcSplitter(); + expect(s.feed(`${ESC}_Ga=q;AA\x18after`)).toEqual([{ type: 'text', text: 'after' }]); + }); + + test('CAN in plain text is just text', () => { + expect(new ApcSplitter().feed('a\x18b')).toEqual([{ type: 'text', text: 'a\x18b' }]); + }); +}); diff --git a/src/client/features/kitty/protocol/apc-splitter.ts b/src/client/features/kitty/protocol/apc-splitter.ts new file mode 100644 index 0000000..a26aca5 --- /dev/null +++ b/src/client/features/kitty/protocol/apc-splitter.ts @@ -0,0 +1,132 @@ +// Splits APC sequences out of the PTY stream (object, pure, ADR 032): plain text goes on +// to the terminal, each `ESC _ ... ESC \` comes back whole, or as its start when it is +// over the size limit. + +const ESC = '\x1b'; + +/** + * A single APC is one chunk of at most 4096 base64 bytes per the spec, but + * nothing stops a client sending one huge sequence. Bound what we buffer. + */ +export const MAX_APC_BYTES = 64 * 1024 * 1024; + +// How much of the start of an APC is kept when it is too big to keep whole: +// enough for the control keys, so the command can still be answered. +const HEAD_BYTES = 4096; + +// What ends an APC: ST (`ESC \`), or CAN / SUB, which cancel a control string. +const CAN = '\x18'; +const SUB = '\x1a'; +const APC_STOP = new RegExp(`[${ESC}${CAN}${SUB}]`, 'g'); + +/** A piece of the stream: text for the terminal, or one APC sequence's body. */ +export type Segment = + | { type: 'text'; text: string } + // `overflow`: the sequence was over the size limit, so `body` is only its + // start (the control keys), enough to answer with an error. + | { type: 'apc'; body: string; overflow?: boolean }; + +/** + * Splits a PTY stream into plain text and APC sequences (`ESC _ ... ESC \`). + * + * State survives across `feed()` calls: a WebSocket frame can end anywhere, + * including between the `ESC` and `_` that open an APC, or between the `ESC` + * and `\` that close it. A lone trailing `ESC` is therefore held back until + * the next chunk says what it starts. + * + * A sequence over `maxBytes` is not buffered whole: its start is kept and the + * rest is swallowed up to its terminator, so none of the payload reaches the + * terminal as text. Like any control string it ends at ST, or at CAN / SUB, + * which is the way out of one that is never terminated. + */ +export class ApcSplitter { + private state: 'text' | 'esc' | 'apc' | 'apc-esc' = 'text'; + private parts: string[] = []; + private head = ''; + private size = 0; + private overflow = false; + + constructor(private maxBytes = MAX_APC_BYTES) {} + + feed(data: string): Segment[] { + const out: Segment[] = []; + let text = ''; + let i = 0; + while (i < data.length) { + if (this.state === 'text') { + const esc = data.indexOf(ESC, i); + if (esc < 0) { + text += data.slice(i); + break; + } + text += data.slice(i, esc); + i = esc + 1; + this.state = 'esc'; + } else if (this.state === 'esc') { + if (data[i] === '_') { + if (text) out.push({ type: 'text', text }); + text = ''; + this.begin(); + i++; + } else { + // Not an APC: give the held ESC back and reprocess this char. + text += ESC; + this.state = 'text'; + } + } else if (this.state === 'apc') { + APC_STOP.lastIndex = i; + const stop = APC_STOP.exec(data); + const end = stop ? stop.index : data.length; + this.append(data.slice(i, end)); + i = end; + if (stop) { + i++; + if (stop[0] === ESC) this.state = 'apc-esc'; + else this.reset(); // CAN / SUB: cancelled, nothing to deliver + } + } else if (data[i] === '\\') { + // apc-esc: ESC \ closes the sequence. + out.push( + this.overflow + ? { type: 'apc', body: this.head, overflow: true } + : { type: 'apc', body: this.parts.join('') }, + ); + this.reset(); + i++; + } else { + // ESC not followed by `\` inside an APC: malformed. Drop the APC and + // treat the ESC as the start of a fresh sequence. + this.reset(); + this.state = 'esc'; + } + } + if (text) out.push({ type: 'text', text }); + return out; + } + + private begin(): void { + this.reset(); + this.state = 'apc'; + } + + private append(s: string): void { + if (!s) return; + if (this.head.length < HEAD_BYTES) this.head += s.slice(0, HEAD_BYTES - this.head.length); + if (this.overflow) return; + this.size += s.length; + if (this.size > this.maxBytes) { + this.overflow = true; + this.parts = []; + return; + } + this.parts.push(s); + } + + private reset(): void { + this.state = 'text'; + this.parts = []; + this.head = ''; + this.size = 0; + this.overflow = false; + } +} diff --git a/src/client/features/kitty/protocol/cell-geometry.test.ts b/src/client/features/kitty/protocol/cell-geometry.test.ts new file mode 100644 index 0000000..a02b248 --- /dev/null +++ b/src/client/features/kitty/protocol/cell-geometry.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { axisScale, cellSpan, deviceCellSize } from './cell-geometry'; + +describe('cellSpan', () => { + const img = { width: 100, height: 50 }; + + test('natural size rounds up to whole cells', () => { + expect(cellSpan({}, img, 8, 16)).toEqual({ cols: 13, rows: 4 }); + }); + + test('c and r win', () => { + expect(cellSpan({ c: '10', r: '5' }, img, 8, 16)).toEqual({ cols: 10, rows: 5 }); + }); + + test('c alone keeps the aspect ratio', () => { + // 20 cols * 8px = 160px wide → scale 1.6 → 80px tall → 5 rows of 16px + expect(cellSpan({ c: '20' }, img, 8, 16)).toEqual({ cols: 20, rows: 5 }); + }); + + test('r alone keeps the aspect ratio', () => { + // 4 rows * 16px = 64px tall → scale 1.28 → 128px wide → 16 cols of 8px + expect(cellSpan({ r: '4' }, img, 8, 16)).toEqual({ cols: 16, rows: 4 }); + }); + + test('the span follows the region passed in, not the whole image', () => { + // A 16x32 source rectangle at 2x is 8x16 CSS px: one cell, not the two the + // device-pixel numbers would suggest. + expect(cellSpan({ w: '16', h: '32' }, { width: 16 / 2, height: 32 / 2 }, 8, 16)).toEqual({ + cols: 1, + rows: 1, + }); + }); +}); + +describe('device pixel scale', () => { + test('cell size is a whole number of device pixels', () => { + expect(deviceCellSize(9.6, 2)).toBe(19); + expect(deviceCellSize(9.6, 1.25)).toBe(12); + expect(deviceCellSize(9.6, 1.5)).toBe(14); + expect(deviceCellSize(0.2, 1)).toBe(1); + }); + + test.each([1, 1.25, 1.5, 1.75, 2, 3])( + 'a frame of cols cells fills cols CSS cells exactly at dpr %p', + (dpr) => { + const cols = 200; + const cssCell = 9.6; + const frameWidth = cols * deviceCellSize(cssCell, dpr); + expect(frameWidth / axisScale(cssCell, dpr)).toBeCloseTo(cols * cssCell, 6); + }, + ); + + test('raw devicePixelRatio would drift off the grid at fractional scales', () => { + const cols = 200; + const cssCell = 9.6; + const drawn = (cols * deviceCellSize(cssCell, 1.75)) / 1.75; + expect(Math.abs(drawn - cols * cssCell)).toBeGreaterThan(5); + }); + + test('scale follows the snapped cell, close to devicePixelRatio', () => { + expect(axisScale(9.6, 2)).toBeCloseTo(19 / 9.6, 10); + expect(axisScale(0, 2)).toBe(2); + }); +}); + +describe('cellSpan at fractional display scales', () => { + // A program sizes an image as whole cells of the size it was told; converting + // that back to CSS pixels must give the same number of cells, not one more + // because of floating-point error. + test.each([1, 1.25, 1.5, 1.75, 2, 3])( + 'a whole number of cells stays that many at dpr %p', + (dpr) => { + for (const css of [7.8, 8.4, 9.6, 10.2]) { + for (const cells of [1, 2, 3, 7, 80, 213]) { + const devCell = deviceCellSize(css, dpr); + const region = { width: (cells * devCell) / axisScale(css, dpr), height: 1 }; + expect(cellSpan({}, region, css, 1).cols).toBe(cells); + } + } + }, + ); +}); diff --git a/src/client/features/kitty/protocol/cell-geometry.ts b/src/client/features/kitty/protocol/cell-geometry.ts new file mode 100644 index 0000000..ee495ae --- /dev/null +++ b/src/client/features/kitty/protocol/cell-geometry.ts @@ -0,0 +1,64 @@ +// Cell and device-pixel geometry of placed images (util, pure, ADR 032): the whole-pixel +// cell size Ghostty reports, the per-axis CSS-to-device scale, and how many cells an +// image covers. + +import { type KittyKeys, num } from './apc-command'; + +/** + * A cell's size in whole device pixels. Ghostty reports it to programs (CSI 16t, + * from terminal.ts `deviceCellPixels`, which computes the same) and they size + * frames as cols x this, so it has to be an integer even though the CSS cell size + * (and devicePixelRatio, e.g. 1.25 or 1.5 on Windows) is not. + */ +export function deviceCellSize(cssCell: number, dpr: number): number { + return Math.max(1, Math.round(cssCell * dpr)); +} + +/** + * Device pixels per CSS pixel along one axis, derived from the whole-pixel cell + * size rather than from devicePixelRatio directly. A frame of `cols` cells is + * `cols * deviceCellSize` pixels wide; dividing by this scale draws it exactly + * `cols * cssCell` CSS pixels wide, however the rounding fell. Dividing by the + * raw devicePixelRatio would drift by up to half a pixel per column, which + * leaves the image off the terminal grid at fractional scales. + */ +export function axisScale(cssCell: number, dpr: number): number { + return cssCell > 0 ? deviceCellSize(cssCell, dpr) / cssCell : dpr; +} + +/** The cells an image covers, as `cellSpan` computes them. */ +export interface CellSpan { + cols: number; + rows: number; +} + +/** + * How many cells an image covers. `c` / `r` win when given; when only one is + * given the other follows from the aspect ratio; with neither, the image is + * shown at natural size. `region` is the size in CSS pixels of the part of the + * image being shown (the whole image, or the `w` x `h` source rectangle already + * converted from device pixels by the caller). + */ +export function cellSpan( + keys: KittyKeys, + region: { width: number; height: number }, + cellWidth: number, + cellHeight: number, +): CellSpan { + const c = num(keys, 'c'); + const r = num(keys, 'r'); + const sw = region.width; + const sh = region.height; + if (c && r) return { cols: c, rows: r }; + if (c) return { cols: c, rows: cellsFor((c * cellWidth * sh) / sw, cellHeight) }; + if (r) return { cols: cellsFor((r * cellHeight * sw) / sh, cellWidth), rows: r }; + return { cols: cellsFor(sw, cellWidth), rows: cellsFor(sh, cellHeight) }; +} + +// Whole cells needed for `px`. A size that is exactly N cells, converted from +// device to CSS pixels, can come out as N + a rounding error, which a plain +// ceil turns into N + 1: the tolerance is far below any real fraction of a +// cell. +function cellsFor(px: number, cell: number): number { + return Math.max(1, Math.ceil(px / cell - 1e-6)); +} diff --git a/src/client/features/kitty/protocol/chunk-assembler.test.ts b/src/client/features/kitty/protocol/chunk-assembler.test.ts new file mode 100644 index 0000000..199aa56 --- /dev/null +++ b/src/client/features/kitty/protocol/chunk-assembler.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import { ChunkAssembler } from './chunk-assembler'; + +describe('ChunkAssembler', () => { + test('single chunk returns immediately', () => { + const a = new ChunkAssembler(); + expect(a.push({ keys: { a: 'T' }, payload: 'AAAA' })).toEqual({ + keys: { a: 'T' }, + data: 'AAAA', + }); + }); + + test('m=1 chunks are joined; first chunk keys win', () => { + const a = new ChunkAssembler(); + expect(a.push({ keys: { a: 'T', i: '3', m: '1' }, payload: 'AAAA' })).toBeNull(); + expect(a.push({ keys: { m: '1' }, payload: 'BBBB' })).toBeNull(); + expect(a.push({ keys: { m: '0' }, payload: 'CCCC' })).toEqual({ + keys: { a: 'T', i: '3', m: '1' }, + data: 'AAAABBBBCCCC', + }); + }); + + test('assembler resets after a transmission completes', () => { + const a = new ChunkAssembler(); + a.push({ keys: { m: '1' }, payload: 'A' }); + a.push({ keys: {}, payload: 'B' }); + expect(a.push({ keys: { i: '9' }, payload: 'Z' })).toEqual({ keys: { i: '9' }, data: 'Z' }); + }); +}); + +describe('ChunkAssembler size cap', () => { + test('chunks that add up past the cap are dropped and reported once, at the end', () => { + const a = new ChunkAssembler(10); + expect(a.push({ keys: { i: '4', m: '1' }, payload: 'AAAAAA' })).toBeNull(); + // 6 + 6 > 10: dropped here, the rest of the chunks are swallowed + expect(a.push({ keys: { m: '1' }, payload: 'BBBBBB' })).toBeNull(); + expect(a.push({ keys: { m: '1' }, payload: 'C' })).toBeNull(); + expect(a.push({ keys: { m: '0' }, payload: 'D' })).toEqual({ + keys: { i: '4', m: '1' }, + data: '', + tooBig: true, + }); + }); + + test('the cap applies when the overflow is on the final chunk', () => { + const a = new ChunkAssembler(10); + a.push({ keys: { i: '4', m: '1' }, payload: 'AAAAAA' }); + expect(a.push({ keys: { m: '0' }, payload: 'BBBBBB' })).toMatchObject({ tooBig: true }); + }); + + test('a single oversized chunk is reported straight away', () => { + const a = new ChunkAssembler(4); + expect(a.push({ keys: { i: '2' }, payload: 'AAAAAAAA' })).toMatchObject({ + keys: { i: '2' }, + tooBig: true, + }); + }); + + test('a first chunk that is too big swallows the chunks that follow it', () => { + const a = new ChunkAssembler(4); + expect(a.push({ keys: { i: '2', m: '1' }, payload: 'AAAAAAAA' })).toBeNull(); + expect(a.push({ keys: { m: '1' }, payload: 'B' })).toBeNull(); + expect(a.push({ keys: { m: '0' }, payload: 'C' })).toMatchObject({ tooBig: true }); + }); + + test('normal transmissions still work after an oversized one', () => { + const a = new ChunkAssembler(4); + a.push({ keys: { m: '0' }, payload: 'AAAAAAAA' }); + expect(a.push({ keys: { i: '9' }, payload: 'Z' })).toEqual({ keys: { i: '9' }, data: 'Z' }); + }); +}); + +describe('ChunkAssembler.reject', () => { + test('a rejected single command is reported as too big, with its keys', () => { + expect(new ChunkAssembler().reject({ keys: { i: '3' }, payload: '' })).toEqual({ + keys: { i: '3' }, + data: '', + tooBig: true, + }); + }); + + test('a rejected first chunk swallows the chunks after it and reports once at the end', () => { + const a = new ChunkAssembler(); + expect(a.reject({ keys: { i: '3', m: '1' }, payload: '' })).toBeNull(); + expect(a.push({ keys: { m: '1' }, payload: 'AA' })).toBeNull(); + expect(a.push({ keys: { m: '0' }, payload: 'AA' })).toEqual({ + keys: { i: '3', m: '1' }, + data: '', + tooBig: true, + }); + }); + + test('a rejected later chunk drops what was pending and answers for the first chunk', () => { + const a = new ChunkAssembler(); + a.push({ keys: { i: '3', m: '1' }, payload: 'AAAA' }); + expect(a.reject({ keys: { m: '0' }, payload: '' })).toEqual({ + keys: { i: '3', m: '1' }, + data: '', + tooBig: true, + }); + // and the next transmission starts clean + expect(a.push({ keys: { i: '4' }, payload: 'Z' })).toEqual({ keys: { i: '4' }, data: 'Z' }); + }); +}); diff --git a/src/client/features/kitty/protocol/chunk-assembler.ts b/src/client/features/kitty/protocol/chunk-assembler.ts new file mode 100644 index 0000000..9fbb1b5 --- /dev/null +++ b/src/client/features/kitty/protocol/chunk-assembler.ts @@ -0,0 +1,82 @@ +// Reassembles chunked kitty transmissions (object, pure, ADR 032): `m=1` chunks are held +// until the final one, and a transmission over the size cap is reported once, at its end. + +import type { KittyCommand, KittyKeys } from './apc-command'; + +/** A whole transmission: the first chunk's keys and every chunk's payload joined. */ +export interface Transmission { + keys: KittyKeys; + data: string; + /** The chunks added up to more than the size cap and were dropped; `data` is empty. */ + tooBig?: boolean; +} + +/** + * Cap on one chunked transmission's total encoded size. `MAX_APC_BYTES` bounds a + * single sequence, but a client can send any number of `m=1` chunks that are + * each valid, and every payload is held until the final one arrives. + */ +export const MAX_TRANSMISSION_BYTES = 64 * 1024 * 1024; + +/** + * Reassembles chunked transmissions. The first chunk carries every key; the + * following ones carry only `m` (and optionally `q`). `m=1` means more + * follows, `m=0` or absent ends it. + * + * A transmission that grows past `maxBytes` is dropped and the rest of its + * chunks are swallowed, so they are not mistaken for new transmissions. The + * caller gets a `tooBig` result once the final chunk arrives, to reply to. + */ +export class ChunkAssembler { + private pending: { keys: KittyKeys; parts: string[]; size: number } | null = null; + private discarding: KittyKeys | null = null; + + constructor(private maxBytes = MAX_TRANSMISSION_BYTES) {} + + /** + * A chunk that could not be delivered whole (the splitter's size limit). Its + * control keys are all that is known; treat it as the end of an oversized + * transmission, or as one more chunk of one if it says more follows. + */ + reject(cmd: KittyCommand): Transmission | null { + const keys = this.pending?.keys ?? this.discarding ?? cmd.keys; + this.pending = null; + if (cmd.keys.m === '1') { + this.discarding = keys; + return null; + } + this.discarding = null; + return { keys, data: '', tooBig: true }; + } + + push(cmd: KittyCommand): Transmission | null { + const more = cmd.keys.m === '1'; + if (this.discarding) { + if (more) return null; + const keys = this.discarding; + this.discarding = null; + return { keys, data: '', tooBig: true }; + } + const size = (this.pending?.size ?? 0) + cmd.payload.length; + if (size > this.maxBytes) { + const keys = this.pending?.keys ?? cmd.keys; + this.pending = null; + if (more) { + this.discarding = keys; + return null; + } + return { keys, data: '', tooBig: true }; + } + if (!this.pending) { + if (!more) return { keys: cmd.keys, data: cmd.payload }; + this.pending = { keys: cmd.keys, parts: [cmd.payload], size }; + return null; + } + this.pending.parts.push(cmd.payload); + this.pending.size = size; + if (more) return null; + const done = { keys: this.pending.keys, data: this.pending.parts.join('') }; + this.pending = null; + return done; + } +} diff --git a/src/client/features/kitty/protocol/control-scanner.test.ts b/src/client/features/kitty/protocol/control-scanner.test.ts new file mode 100644 index 0000000..63779fd --- /dev/null +++ b/src/client/features/kitty/protocol/control-scanner.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { ControlScanner, clearsPlacements } from './control-scanner'; + +describe('placement clearing', () => { + test.each(['\x1b[2J', '\x1b[3J', '\x1b[?1049h', '\x1b[?1049l', '\x1b[?47h', '\x1bc'])( + '%j clears', + (seq) => { + expect(clearsPlacements(`text${seq}more`)).toBe(true); + }, + ); + + test.each(['\x1b[J', '\x1b[1J', '\x1b[?25h', '\x1b[2K', 'plain'])('%j does not', (seq) => { + expect(clearsPlacements(seq)).toBe(false); + }); +}); + +describe('ControlScanner', () => { + test('a clear split across chunks is seen once it completes', () => { + const s = new ControlScanner(); + expect(s.scan('text\x1b[')).toBe(false); + expect(s.scan('2Jmore')).toBe(true); + }); + + test('split at every position', () => { + const full = 'a\x1b[?1049hb\x1b[16tc'; + for (let cut = 1; cut < full.length; cut++) { + const s = new ControlScanner(); + const a = s.scan(full.slice(0, cut)); + const b = s.scan(full.slice(cut)); + expect(a || b).toBe(true); + } + }); + + test('a lone trailing ESC is carried, so ESC c split in two is a reset', () => { + const s = new ControlScanner(); + expect(s.scan('x\x1b')).toBe(false); + expect(s.scan('cy')).toBe(true); + }); + + test('a completed sequence is not counted again with the next chunk', () => { + const s = new ControlScanner(); + expect(s.scan('\x1b[2J')).toBe(true); + expect(s.scan('plain')).toBe(false); + }); + + test('an unrelated unfinished sequence is dropped once it turns into something else', () => { + const s = new ControlScanner(); + s.scan('\x1b[3'); + expect(s.scan('1m text')).toBe(false); + expect(s.scan('plain')).toBe(false); + }); +}); diff --git a/src/client/features/kitty/protocol/control-scanner.ts b/src/client/features/kitty/protocol/control-scanner.ts new file mode 100644 index 0000000..e2dfbb9 --- /dev/null +++ b/src/client/features/kitty/protocol/control-scanner.ts @@ -0,0 +1,39 @@ +// Spots the sequences that invalidate placed images (util + object, pure, ADR 032), in +// one string or across arbitrary chunk boundaries. + +const ESC = '\x1b'; + +// Sequences after which any placed image no longer corresponds to what is on +// screen: erase display (2 = screen, 3 = screen + scrollback), alternate +// screen switches, and full reset. The emulator cannot tell us about overwrites, +// so these are the only points where placements are dropped implicitly. +const CLEARS_PLACEMENTS = new RegExp(`${ESC}\\[[23]J|${ESC}\\[\\?(?:1049|1047|47)[hl]|${ESC}c`); + +/** Whether `text` holds a sequence after which placed images no longer match the screen. */ +export function clearsPlacements(text: string): boolean { + return CLEARS_PLACEMENTS.test(text); +} + +// A sequence cut off at the end of a chunk: ESC, or ESC [ followed by parameter +// bytes but no final byte yet. +const INCOMPLETE = new RegExp(`${ESC}(?:\\[[0-9;?]{0,16})?$`); + +/** + * Finds clear sequences in text that arrives in arbitrary pieces. PTY output and + * WebSocket frames can end anywhere, so `ESC [` in one piece and `2 J` in the next + * are one sequence; the terminal reassembles them in its own parser, and this + * keeps the unfinished tail between calls so the client sees them too. + */ +export class ControlScanner { + private carry = ''; + + /** Whether a sequence that invalidates placed images was completed. */ + scan(text: string): boolean { + const s = this.carry + text; + const tail = INCOMPLETE.exec(s); + // Anything before the tail is complete, so it is scanned now; the tail + // itself cannot match yet and is scanned again once it is finished. + this.carry = tail ? tail[0] : ''; + return clearsPlacements(s); + } +} diff --git a/src/client/features/kitty/protocol/kitty-reply.test.ts b/src/client/features/kitty/protocol/kitty-reply.test.ts new file mode 100644 index 0000000..1231893 --- /dev/null +++ b/src/client/features/kitty/protocol/kitty-reply.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { kittyReply } from './kitty-reply'; + +describe('kittyReply', () => { + test('OK reply carries the id', () => { + expect(kittyReply({ i: '31' }, 31)).toBe('\x1b_Gi=31;OK\x1b\\'); + }); + + test('includes placement id and image number when present', () => { + expect(kittyReply({ i: '5', p: '2' }, 5)).toBe('\x1b_Gi=5,p=2;OK\x1b\\'); + expect(kittyReply({ I: '9' }, 1000009)).toBe('\x1b_Gi=1000009,I=9;OK\x1b\\'); + }); + + test('no id → no reply', () => { + expect(kittyReply({ a: 'T' }, 0)).toBeNull(); + }); + + test('q=1 suppresses OK but not errors', () => { + expect(kittyReply({ i: '1', q: '1' }, 1)).toBeNull(); + expect(kittyReply({ i: '1', q: '1' }, 1, { code: 'EINVAL', message: 'x' })).toBe( + '\x1b_Gi=1;EINVAL:x\x1b\\', + ); + }); + + test('q=2 suppresses everything', () => { + expect(kittyReply({ i: '1', q: '2' }, 1)).toBeNull(); + expect(kittyReply({ i: '1', q: '2' }, 1, { code: 'EINVAL', message: 'x' })).toBeNull(); + }); +}); diff --git a/src/client/features/kitty/protocol/kitty-reply.ts b/src/client/features/kitty/protocol/kitty-reply.ts new file mode 100644 index 0000000..480daeb --- /dev/null +++ b/src/client/features/kitty/protocol/kitty-reply.ts @@ -0,0 +1,26 @@ +// Builds the reply a kitty graphics command gets (util, pure, ADR 032), honouring the +// `q` quiet levels. + +import { type KittyKeys, num } from './apc-command'; + +const ESC = '\x1b'; + +/** + * Builds the reply for a command, or null when none must be sent: a command + * without an image id/number gets no reply, `q=1` suppresses OK replies and + * `q=2` suppresses everything. + */ +export function kittyReply( + keys: KittyKeys, + imageId: number, + error?: { code: string; message: string }, +): string | null { + if (keys.i === undefined && keys.I === undefined) return null; + const q = num(keys, 'q'); + if (q === 2 || (q === 1 && !error)) return null; + const ids = [`i=${imageId}`]; + if (keys.I !== undefined) ids.push(`I=${keys.I}`); + if (keys.p !== undefined) ids.push(`p=${keys.p}`); + const status = error ? `${error.code}:${error.message}` : 'OK'; + return `${ESC}_G${ids.join(',')};${status}${ESC}\\`; +} diff --git a/src/client/features/kitty/protocol/png-size.test.ts b/src/client/features/kitty/protocol/png-size.test.ts new file mode 100644 index 0000000..20f4ab9 --- /dev/null +++ b/src/client/features/kitty/protocol/png-size.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test'; +import { pngSize, pngSizeFromBytes } from './png-size'; + +describe('pngSize', () => { + test('reads width and height from the IHDR', () => { + // 8-byte signature, 4-byte length, "IHDR", width=300, height=200 + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, + 0x01, 0x2c, 0, 0, 0, 0xc8, + ]); + expect(pngSize(btoa(String.fromCharCode(...bytes)))).toEqual({ width: 300, height: 200 }); + }); + + test('not a PNG → null', () => { + expect(pngSize(btoa('x'.repeat(24)))).toBeNull(); + expect(pngSize('AAAA')).toBeNull(); + }); + + test('a payload that is not base64 → null', () => { + expect(pngSize('!'.repeat(32))).toBeNull(); + }); +}); + +describe('pngSizeFromBytes', () => { + const header = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 0x01, + 0x2c, 0, 0, 0, 0xc8, + ]); + + test('reads the IHDR of a decoded file', () => { + expect(pngSizeFromBytes(header)).toEqual({ width: 300, height: 200 }); + }); + + test('rejects short or non-PNG data', () => { + expect(pngSizeFromBytes(header.subarray(0, 10))).toBeNull(); + expect(pngSizeFromBytes(new Uint8Array(30))).toBeNull(); + }); +}); + +describe('pngSizeFromBytes validation', () => { + const valid = () => + new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, + 0x01, 0x2c, 0, 0, 0, 0xc8, + ]); + + test('a valid header is read', () => { + expect(pngSizeFromBytes(valid())).toEqual({ width: 300, height: 200 }); + }); + + test.each([ + ['first byte of the signature', (b: Uint8Array) => (b[0] = 0x00)], + ['line-ending bytes of the signature', (b: Uint8Array) => (b[5] = 0x00)], + ['IHDR length', (b: Uint8Array) => (b[11] = 12)], + ['IHDR type', (b: Uint8Array) => (b[15] = 0x58)], + ['zero width', (b: Uint8Array) => b.fill(0, 16, 20)], + ['zero height', (b: Uint8Array) => b.fill(0, 20, 24)], + ['width over 2^31 - 1', (b: Uint8Array) => b.fill(0xff, 16, 20)], + ])('a wrong %s is refused', (_name, corrupt) => { + const b = valid(); + corrupt(b); + expect(pngSizeFromBytes(b)).toBeNull(); + }); + + test('bytes that only spell PNG at offsets 1 to 3 are not a PNG', () => { + const b = new Uint8Array(24); + b.set([0x00, 0x50, 0x4e, 0x47], 0); + expect(pngSizeFromBytes(b)).toBeNull(); + }); + + test('the base64 form is validated the same way', () => { + const b = valid(); + b[0] = 0x00; + expect(pngSize(btoa(String.fromCharCode(...b)))).toBeNull(); + }); +}); diff --git a/src/client/features/kitty/protocol/png-size.ts b/src/client/features/kitty/protocol/png-size.ts new file mode 100644 index 0000000..e2aa1ee --- /dev/null +++ b/src/client/features/kitty/protocol/png-size.ts @@ -0,0 +1,40 @@ +// Reads a PNG's width and height from its IHDR (util, pure, ADR 032), from raw bytes or +// from the start of a base64 payload, so the cursor can move before the async decode. + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * Width and height from a PNG's IHDR, given the first 24 bytes of the file or + * more. The header is checked in full (signature, that the first chunk is a + * 13-byte IHDR, and that the dimensions are valid) because the result is used + * to move the cursor before the decoder has looked at the data. + */ +export function pngSizeFromBytes(bytes: Uint8Array): { width: number; height: number } | null { + if (bytes.length < 24) return null; + for (let i = 0; i < PNG_SIGNATURE.length; i++) if (bytes[i] !== PNG_SIGNATURE[i]) return null; + const u32 = (o: number): number => + ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]) >>> 0; + if (u32(8) !== 13) return null; + if (u32(12) !== 0x49484452) return null; // "IHDR" + const width = u32(16); + const height = u32(20); + // PNG dimensions are 1 to 2^31 - 1 + if (width === 0 || height === 0 || width > 0x7fffffff || height > 0x7fffffff) return null; + return { width, height }; +} + +/** + * Width and height from a PNG's IHDR, read from the first 24 bytes of the + * base64 payload. Lets cursor movement be computed before the (async) decode. + * Only valid when the payload is not zlib-compressed. + */ +export function pngSize(base64: string): { width: number; height: number } | null { + if (base64.length < 32) return null; + let bin: string; + try { + bin = atob(base64.slice(0, 32)); + } catch { + return null; + } + return pngSizeFromBytes(Uint8Array.from(bin, (c) => c.charCodeAt(0))); +} diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 785bd8f..fa1db63 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -141,8 +141,8 @@ export function mouseReportText(bytes: Uint8Array): string { /** * A cell's size in whole device pixels, for Ghostty's size reports - * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as kitty.ts's `deviceCellSize` - * sizes image frames: the CSS cell times `devicePixelRatio`, rounded, at least 1. + * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as the kitty feature's + * `deviceCellSize` (features/kitty/protocol/cell-geometry.ts) sizes image frames: the CSS cell times `devicePixelRatio`, rounded, at least 1. * (webtty, ADR 042) */ export function deviceCellPixels(cssCell: number, dpr: number): number { From 9da5359dfd0a1c3882fdb4f899bc59d4b5bfef86 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:40:39 -0400 Subject: [PATCH 178/295] test(server): golden tests for every route before moving routes Pin the status, headers (Date aside) and body of every HTTP route as literals, so the move into the ordered route table (ADR 062) shows any change in a reply. The handler runs in-process on a free loopback port with a temporary HOME, dist and wasm, wrapped as src/server/index.ts wraps it, with a fresh SessionStore per test and fake PTYs and sockets. Covered: OPTIONS; stop and status; /api/config (keys, order, defaults, per-request read, no server keys); sessions CRUD with 400/404/409, the 64 KB drop and X-Sessions-Remaining; GET / and /index.html redirects; /s/:id; /p/:pid; /s/:id/publish (NDJSON, CRLF, partial last line, the 64 KB line drop, lines broadcast before the 204 that follows the end); /s/:id/execute (stdout, stderr, stdin, cwd and config env, ENOENT, spawn throw); /dist/* with traversal sent raw; the wasm; unknown paths and unsupported methods as 404 with CORS; readJson. Split by route group into routes-golden, routes-pages-golden and routes-streams-golden over src/test-support/route-harness.ts; the checker's kebab-case rule refuses a "routes.golden.test.ts" stem. --- src/server/routes-golden.test.ts | 274 +++++++++++++++++++++++ src/server/routes-pages-golden.test.ts | 137 ++++++++++++ src/server/routes-streams-golden.test.ts | 166 ++++++++++++++ src/test-support/route-harness.ts | 203 +++++++++++++++++ 4 files changed, 780 insertions(+) create mode 100644 src/server/routes-golden.test.ts create mode 100644 src/server/routes-pages-golden.test.ts create mode 100644 src/server/routes-streams-golden.test.ts create mode 100644 src/test-support/route-harness.ts diff --git a/src/server/routes-golden.test.ts b/src/server/routes-golden.test.ts new file mode 100644 index 0000000..2f716f6 --- /dev/null +++ b/src/server/routes-golden.test.ts @@ -0,0 +1,274 @@ +// Golden tests for the API routes: server stop and status, /api/config and the sessions +// CRUD, each reply's status, headers and body pinned as literals before the routes move into +// the ordered route table (ADR 062, ADR 064). Pages and assets are in routes-pages-golden.test.ts, +// the NDJSON routes in routes-streams-golden.test.ts. +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import http from 'node:http'; +import { Socket } from 'node:net'; +import path from 'node:path'; +import { DEFAULT_THEME } from '../config'; +import { FakeSocket } from '../test-support/fake-socket'; +import { + BAD_REQUEST, + json, + NOT_FOUND, + plain, + type Reply, + RouteHarness, + sessionBody, +} from '../test-support/route-harness'; +import { getVersion } from '../version'; +import { readJson } from './routes'; +import type { Session } from './sessions/session'; + +const h = new RouteHarness(); +const { call, post, running, idle } = h; + +beforeAll(() => h.start()); +afterAll(() => h.stop()); +beforeEach(() => h.reset()); + +describe('routes golden — server', () => { + test('OPTIONS on any path is 204 with CORS and no body', async () => { + for (const url of ['/', '/api/sessions', '/nowhere']) { + expect(await call('OPTIONS', url)).toEqual({ status: 204, headers: plain(0), body: '' }); + } + }); + + test('POST /api/server/stop answers "stopping" and then calls onStop once', async () => { + expect(await call('POST', '/api/server/stop')).toEqual({ + status: 200, + headers: { ...plain(8), 'content-type': 'text/plain' }, + body: 'stopping', + }); + expect(h.stops).toBe(1); + }); + + test('GET /api/server/status reports the package version', async () => { + const body = JSON.stringify({ version: getVersion() }); + expect(await call('GET', '/api/server/status')).toEqual({ + status: 200, + headers: json(body), + body, + }); + }); + + test('stop and status answer only their own method', async () => { + expect(await call('GET', '/api/server/stop')).toEqual(NOT_FOUND); + expect(await call('POST', '/api/server/status')).toEqual(NOT_FOUND); + expect(h.stops).toBe(0); + }); +}); + +describe('routes golden — /api/config', () => { + test('sends the client keys in order with the defaults, legacy scrollback included', async () => { + const body = JSON.stringify({ + cols: 80, + rows: 24, + fontSize: 13, + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace, 'STIX Two Text'", + cursorStyle: 'bar', + cursorStyleBlink: true, + scrollback: 256 * 1024, + scrollbackLines: 10000, + theme: DEFAULT_THEME, + copyOnSelect: true, + rightClickBehavior: 'contextMenu', + clipboardWrite: true, + bellTitle: true, + desktopNotifications: true, + mouseScrollSpeed: 1, + padding: 0, + keyboardBindings: [], + }); + expect(await call('GET', '/api/config')).toEqual({ status: 200, headers: json(body), body }); + }); + + test('reads the config file per request and never sends server-side keys', async () => { + h.writeConfig({ fontSize: 20, shell: '/bin/zsh', host: '0.0.0.0', env: { A: '1' } }); + const reply = await call('GET', '/api/config'); + const config = JSON.parse(reply.body) as Record<string, unknown>; + expect(config.fontSize).toBe(20); + expect(Object.keys(config)).not.toContain('shell'); + expect(Object.keys(config)).not.toContain('host'); + expect(Object.keys(config)).not.toContain('env'); + expect(Object.keys(config)).not.toContain('port'); + }); +}); + +describe('routes golden — /api/sessions', () => { + test('GET lists the sessions in creation order', async () => { + expect(await call('GET', '/api/sessions')).toEqual({ + status: 200, + headers: json('[]'), + body: '[]', + }); + const a = idle('a'); + const b = running('b', 77); + const body = `[${sessionBody(a)},${sessionBody(b)}]`; + expect(await call('GET', '/api/sessions')).toEqual({ status: 200, headers: json(body), body }); + }); + + test('POST creates a session under the given id and baseDir', async () => { + const reply = await post('/api/sessions', { id: 'work', baseDir: h.tmpHome }); + const created = h.store.get('work') as Session; + const body = sessionBody(created); + expect(reply).toEqual({ status: 201, headers: json(body), body }); + expect(created.baseDir).toBe(h.tmpHome); + }); + + test('POST without a body generates an id and uses the home directory', async () => { + const reply = await call('POST', '/api/sessions'); + expect(reply.status).toBe(201); + const { id } = JSON.parse(reply.body) as { id: string }; + expect(h.store.has(id)).toBe(true); + expect(reply.body).toBe(sessionBody(h.store.get(id) as Session)); + }); + + test('POST rejects bad bodies with the old status and text', async () => { + fs.writeFileSync(path.join(h.tmpHome, 'a-file'), ''); + const missing = path.join(h.tmpHome, 'missing'); + const file = path.join(h.tmpHome, 'a-file'); + const cases: [unknown, number, string][] = [ + ['{nope', 400, 'invalid JSON'], + [{ id: 'bad id!' }, 400, JSON.stringify({ error: 'invalid id: bad id!' })], + [{ id: 'x', baseDir: 'rel/dir' }, 400, '{"error":"baseDir must be an absolute path"}'], + [ + { id: 'x', baseDir: missing }, + 400, + JSON.stringify({ error: `baseDir does not exist: ${missing}` }), + ], + [ + { id: 'x', baseDir: file }, + 400, + JSON.stringify({ error: `baseDir is not a directory: ${file}` }), + ], + ]; + for (const [body, status, text] of cases) { + const headers = text.startsWith('{') ? json(text) : plain(text.length); + expect(await post('/api/sessions', body)).toEqual({ status, headers, body: text }); + } + expect(h.store.size).toBe(0); + }); + + test('POST answers 409 for an id that exists', async () => { + idle('dup'); + const body = JSON.stringify({ error: 'session already exists: dup' }); + expect(await post('/api/sessions', { id: 'dup' })).toEqual({ + status: 409, + headers: json(body), + body, + }); + }); + + test('POST over 64 KB drops the connection and creates nothing (the 413 never reaches the wire)', async () => { + await expect(post('/api/sessions', 'x'.repeat(70 * 1024))).rejects.toThrow(); + expect(h.store.size).toBe(0); + }); + + test('PUT and DELETE on the collection fall through to 404', async () => { + expect(await call('PUT', '/api/sessions')).toEqual(NOT_FOUND); + expect(await call('DELETE', '/api/sessions')).toEqual(NOT_FOUND); + }); +}); + +describe('routes golden — /api/sessions/:id', () => { + test('GET returns the session, decoding the id, or 404', async () => { + const s = running('a.b', 99); + const body = sessionBody(s); + expect(await call('GET', '/api/sessions/a.b')).toEqual({ + status: 200, + headers: json(body), + body, + }); + expect(await call('GET', '/api/sessions/a%2Eb')).toEqual({ + status: 200, + headers: json(body), + body, + }); + expect(await call('GET', '/api/sessions/nope')).toEqual(NOT_FOUND); + }); + + test('an undecodable id is 400 Bad Request for every method', async () => { + for (const method of ['GET', 'PATCH', 'DELETE', 'PUT']) { + expect(await call(method, '/api/sessions/%E0%A4%A')).toEqual(BAD_REQUEST); + } + }); + + test('PATCH renames the session and answers with its new JSON', async () => { + const s = idle('old'); + h.store.markUsed('old'); + const reply = await call('PATCH', '/api/sessions/old', { body: '{"id":"new"}' }); + const body = sessionBody(s); + expect(reply).toEqual({ status: 200, headers: json(body), body }); + expect(s.id).toBe('new'); + expect(h.store.has('old')).toBe(false); + expect(h.store.lastUsedId).toBe('new'); + }); + + test('PATCH errors: invalid JSON, 404, missing id, invalid id, 409', async () => { + idle('a'); + idle('b'); + const invalidId = JSON.stringify({ error: 'invalid id: no way' }); + const conflict = JSON.stringify({ error: 'session already exists: b' }); + const cases: [string, string, Reply][] = [ + ['a', '{bad', { status: 400, headers: plain(12), body: 'invalid JSON' }], + ['zzz', '{"id":"c"}', NOT_FOUND], + ['a', '{}', { status: 400, headers: plain(10), body: 'missing id' }], + ['a', '{"id":"no way"}', { status: 400, headers: json(invalidId), body: invalidId }], + ['a', '{"id":"b"}', { status: 409, headers: json(conflict), body: conflict }], + ]; + for (const [id, body, expected] of cases) { + expect(await call('PATCH', `/api/sessions/${id}`, { body })).toEqual(expected); + } + expect(h.store.list().map((s) => s.id)).toEqual(['a', 'b']); + }); + + test('DELETE closes the session with 4001, answers 204 and X-Sessions-Remaining', async () => { + const log: string[] = []; + const s = running('gone', 4242, log); + const sub = new FakeSocket(); + s.subscribers.add(sub); + idle('kept'); + expect(await call('DELETE', '/api/sessions/gone')).toEqual({ + status: 204, + headers: { ...plain(0), 'x-sessions-remaining': '1' }, + body: '', + }); + expect(sub.closed).toEqual({ code: 4001, reason: 'session deleted' }); + expect(log).toEqual(['pty.kill']); + expect(h.store.has('gone')).toBe(false); + expect(await call('DELETE', '/api/sessions/gone')).toEqual(NOT_FOUND); + }); + + test('POST and PUT on a session and deeper paths are 404', async () => { + idle('a'); + expect(await call('POST', '/api/sessions/a')).toEqual(NOT_FOUND); + expect(await call('PUT', '/api/sessions/a')).toEqual(NOT_FOUND); + expect(await call('GET', '/api/sessions/a/b')).toEqual(NOT_FOUND); + }); +}); + +describe('readJson', () => { + /** A request whose body is `chunks`, with no socket behind it. */ + function request(...chunks: string[]): http.IncomingMessage { + const req = new http.IncomingMessage(new Socket()); + for (const c of chunks) req.push(c); + req.push(null); + return req; + } + + test('parses the body, and an empty body is {}', async () => { + expect(await readJson(request('{"a":', '1}'))).toEqual({ a: 1 }); + expect(await readJson(request())).toEqual({}); + }); + + test('rejects invalid JSON, and a body over 64 KB with status 413', async () => { + await expect(readJson(request('{nope'))).rejects.toThrow('invalid JSON'); + await expect(readJson(request('x'.repeat(64 * 1024 + 1)))).rejects.toMatchObject({ + message: 'payload too large', + status: 413, + }); + }); +}); diff --git a/src/server/routes-pages-golden.test.ts b/src/server/routes-pages-golden.test.ts new file mode 100644 index 0000000..ad89ad8 --- /dev/null +++ b/src/server/routes-pages-golden.test.ts @@ -0,0 +1,137 @@ +// Golden tests for the page and asset routes: the `GET /` redirect, `/s/:id`, `/p/:pid`, +// `/dist/*`, the wasm and the 404 fall-through, each pinned as status, headers and body before +// the routes move into the ordered route table (ADR 062, ADR 064). +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { + CLIENT_HTML, + CORS, + NOT_FOUND, + plain, + RouteHarness, + WASM_BYTES, +} from '../test-support/route-harness'; + +const h = new RouteHarness(); +const { call, running, idle, rawGet } = h; + +beforeAll(() => h.start()); +afterAll(() => h.stop()); +beforeEach(() => h.reset()); + +describe('routes golden — pages', () => { + const redirect = (location: string) => ({ + status: 302, + headers: { ...CORS, 'content-length': '0', location }, + body: '', + }); + + test('GET / and /index.html create "main" when nothing was used and redirect to it', async () => { + expect(await call('GET', '/')).toEqual(redirect('/s/main')); + expect(h.store.list().map((s) => s.id)).toEqual(['main']); + expect(await call('GET', '/index.html')).toEqual(redirect('/s/main')); + expect(h.store.size).toBe(1); + }); + + test('GET / redirects to the last-used session while it exists', async () => { + idle('work'); + h.store.markUsed('work'); + expect(await call('GET', '/')).toEqual(redirect('/s/work')); + expect(h.store.has('main')).toBe(false); + h.store.removeExited('work'); + expect(await call('GET', '/')).toEqual(redirect('/s/main')); + }); + + test('POST / is 404', async () => { + expect(await call('POST', '/')).toEqual(NOT_FOUND); + expect(h.store.size).toBe(0); + }); + + test('GET /s/:id serves client.html for a known valid id, else 404', async () => { + idle('a b'); + idle('work'); + expect(await call('GET', '/s/work')).toEqual({ + status: 200, + headers: { ...plain(CLIENT_HTML.length), 'content-type': 'text/html' }, + body: CLIENT_HTML, + }); + expect(await call('GET', '/s/nope')).toEqual(NOT_FOUND); + expect(await call('GET', '/s/%E0%A4%A')).toEqual(NOT_FOUND); + // Registered but not a valid id: the page refuses it. + expect(await call('GET', '/s/a%20b')).toEqual(NOT_FOUND); + expect(await call('POST', '/s/work')).toEqual(NOT_FOUND); + }); + + test('GET /p/:pid redirects to the session whose shell has that pid', async () => { + idle('idle'); + running('busy', 31337); + expect(await call('GET', '/p/31337')).toEqual(redirect('/s/busy')); + for (const url of ['/p/1', '/p/0', '/p/abc', '/p/-1', '/p/']) { + expect(await call('GET', url)).toEqual(NOT_FOUND); + } + expect(await call('POST', '/p/31337')).toEqual(NOT_FOUND); + }); +}); + +describe('routes golden — static assets', () => { + test('/dist/* serves files from the client dist with their MIME type', async () => { + expect(await call('GET', '/dist/client-browser.js')).toEqual({ + status: 200, + headers: { ...plain(15), 'content-type': 'application/javascript' }, + body: 'console.log(1);', + }); + expect(await call('GET', '/dist/client.css')).toEqual({ + status: 200, + headers: { ...plain(6), 'content-type': 'text/css' }, + body: 'body{}', + }); + // Any method: the /dist/ prefix is matched before the method is looked at. + expect((await call('POST', '/dist/client.css')).status).toBe(200); + expect(await call('GET', '/dist/missing.js')).toEqual(NOT_FOUND); + }); + + test('/dist/ refuses the directory itself and paths that resolve outside it', async () => { + for (const url of ['/dist/', '/dist//etc/passwd', `/dist/${h.tmpHome}/secret.txt`]) { + expect(await call('GET', url)).toEqual(NOT_FOUND); + } + }); + + test('/dist traversal sent raw (not normalised by fetch) stays inside the dist', async () => { + for (const raw of ['/dist/../secret.txt', '/dist/..%2Fsecret.txt', '/dist/%2e%2e/secret.txt']) { + const reply = await rawGet(raw); + expect(reply.status).toBe(404); + expect(reply.body).toBe('Not Found'); + expect(reply.headers['access-control-allow-origin']).toBe('*'); + } + }); + + test('GET /ghostty-vt-small.wasm serves the wasm byte for byte; neighbours are 404', async () => { + expect(await call('GET', '/ghostty-vt-small.wasm')).toEqual({ + status: 200, + headers: { ...plain(WASM_BYTES.length), 'content-type': 'application/wasm' }, + body: WASM_BYTES.toString(), + }); + for (const url of [ + '/ghostty-vt.wasm', + '/ghostty-vt-small.wasm.json', + '/dist/ghostty-vt-small.wasm', + ]) { + expect(await call('GET', url)).toEqual(NOT_FOUND); + } + expect((await call('POST', '/ghostty-vt-small.wasm')).status).toBe(200); + }); + + test('unknown paths and unsupported methods are 404 with CORS', async () => { + for (const [method, url] of [ + ['GET', '/unknown'], + ['GET', '/api'], + ['GET', '/api/unknown'], + ['DELETE', '/'], + ['PATCH', '/api/config'], + ['PUT', '/s/x/publish'], + ['GET', '/s/x/execute'], + ['GET', '/client.html'], + ]) { + expect(await call(method, url)).toEqual(NOT_FOUND); + } + }); +}); diff --git a/src/server/routes-streams-golden.test.ts b/src/server/routes-streams-golden.test.ts new file mode 100644 index 0000000..4bfa0e7 --- /dev/null +++ b/src/server/routes-streams-golden.test.ts @@ -0,0 +1,166 @@ +// Golden tests for the streaming routes: `POST /s/:id/publish` (NDJSON in, broadcast to the +// event subscribers) and `POST /s/:id/execute` (a child process streamed out as NDJSON), pinned +// before the routes move into the ordered route table (ADR 062, ADR 064). +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import { FakeSocket } from '../test-support/fake-socket'; +import { + BAD_REQUEST, + NDJSON, + NOT_FOUND, + plain, + RouteHarness, + until, +} from '../test-support/route-harness'; + +const h = new RouteHarness(); +const { call, post, running, idle, postStream } = h; + +beforeAll(() => h.start()); +afterAll(() => h.stop()); +beforeEach(() => h.reset()); + +describe('routes golden — POST /s/:id/publish', () => { + test('refuses bad ids, wrong content types, unknown sessions and stopped shells', async () => { + idle('idle'); + running('live'); + expect(await post('/s/%E0%A4%A/publish', '{}')).toEqual(BAD_REQUEST); + expect(await post('/s/live/publish', '{}', 'text/plain')).toEqual(BAD_REQUEST); + expect(await call('POST', '/s/live/publish', { body: '{}' })).toEqual(BAD_REQUEST); + expect(await post('/s/nope/publish', '{}')).toEqual(NOT_FOUND); + expect(await post('/s/idle/publish', '{}')).toEqual({ + status: 409, + headers: plain(15), + body: 'PTY not running', + }); + }); + + test('broadcasts each valid NDJSON line, CRLF trimmed, a partial last line included', async () => { + const s = running('live'); + const sub = new FakeSocket(); + s.subscribers.add(sub); + const body = '{"a":1}\r\n\n not json\n{"b":2}\n\r\n{"c":3}\r'; + expect(await post('/s/live/publish', body, 'application/json; charset=utf-8')).toEqual({ + status: 204, + headers: plain(0), + body: '', + }); + expect(sub.sent).toEqual(['{"a":1}', '{"b":2}', '{"c":3}']); + }); + + test('an invalid partial last line is skipped and a line over 64 KB is dropped', async () => { + const s = running('live'); + const sub = new FakeSocket(); + s.subscribers.add(sub); + expect((await post('/s/live/publish', '{"a":1}\n{"b":')).status).toBe(204); + expect((await post('/s/live/publish', `{"big":"${'x'.repeat(70 * 1024)}"}`)).status).toBe(204); + expect(sub.sent).toEqual(['{"a":1}']); + }); + + test('lines are broadcast as they arrive and 204 is sent only after the body ends', async () => { + const s = running('live'); + const sub = new FakeSocket(); + s.subscribers.add(sub); + const { controller: push, response } = postStream('/s/live/publish'); + let answered = false; + const pending = response.then((res) => { + answered = true; + return res; + }); + push.enqueue(new TextEncoder().encode('{"first":1}\n{"sec')); + await until(() => sub.sent.length === 1); + expect(answered).toBe(false); + push.enqueue(new TextEncoder().encode('ond":2}')); + push.close(); + expect((await pending).status).toBe(204); + expect(sub.sent).toEqual(['{"first":1}', '{"second":2}']); + }); +}); + +describe('routes golden — POST /s/:id/execute', () => { + const run = (body: unknown) => post('/s/live/execute', body); + const script = (code: string, stdin?: string) => ({ + cmd: process.execPath, + args: ['-e', code], + ...(stdin === undefined ? {} : { stdin }), + }); + + test('refuses bad ids, wrong content types, unknown sessions, stopped shells and bad bodies', async () => { + idle('idle'); + running('live'); + const invalidBody = { status: 400, headers: plain(12), body: 'invalid body' }; + expect(await post('/s/%E0%A4%A/execute', '{}')).toEqual(BAD_REQUEST); + expect(await post('/s/live/execute', '{}', 'text/plain')).toEqual(BAD_REQUEST); + expect(await post('/s/nope/execute', '{}')).toEqual(NOT_FOUND); + expect(await post('/s/idle/execute', '{}')).toEqual({ + status: 409, + headers: plain(15), + body: 'PTY not running', + }); + expect(await run('{bad')).toEqual({ status: 400, headers: plain(12), body: 'invalid JSON' }); + for (const body of [ + {}, + { cmd: '' }, + { cmd: 1, args: [] }, + { cmd: 'ls' }, + { cmd: 'ls', args: [1] }, + ]) { + expect(await run(body)).toEqual(invalidBody); + } + }); + + test('a body over 64 KB drops the connection', async () => { + running('live'); + await expect(run('x'.repeat(70 * 1024))).rejects.toThrow(); + }); + + test('streams stdout as NDJSON then the exit code, feeding stdin', async () => { + running('live'); + const code = 'process.stdout.write(require("fs").readFileSync(0,"utf8").toUpperCase())'; + expect(await run(script(code, 'hi'))).toEqual({ + status: 200, + headers: NDJSON, + body: '{"stream":"stdout","data":"HI"}\n{"exit":0}\n', + }); + }); + + test('streams stderr and a non-zero exit code', async () => { + running('live'); + expect(await run(script('process.stderr.write("oops");process.exit(3)'))).toEqual({ + status: 200, + headers: NDJSON, + body: '{"stream":"stderr","data":"oops"}\n{"exit":3}\n', + }); + }); + + test('runs in the session baseDir with the config env and without the server PORT', async () => { + h.writeConfig({ env: { GOLDEN: 'yes' } }); + running('live'); + const code = 'process.stdout.write([process.cwd(),process.env.GOLDEN].join("|"))'; + const reply = await run(script(code)); + const cwd = fs.realpathSync(h.tmpHome); + expect(reply.body).toBe( + `${JSON.stringify({ stream: 'stdout', data: `${cwd}|yes` })}\n{"exit":0}\n`, + ); + }); + + test('a missing command streams its error and exit 1 with the errno code', async () => { + running('live'); + const reply = await run({ cmd: 'webtty-golden-no-such-command', args: [] }); + expect(reply.status).toBe(200); + expect(reply.headers).toEqual(NDJSON); + const [first, second, ...rest] = reply.body.split('\n'); + const { error } = JSON.parse(second) as { error: string }; + expect(JSON.parse(first)).toEqual({ stream: 'stderr', data: `${error}\n` }); + expect(JSON.parse(second)).toEqual({ exit: 1, error, code: 'ENOENT' }); + expect(rest).toEqual(['']); + }); + + test('a spawn that throws is 500 "spawn error: …"', async () => { + running('live'); + const reply = await run({ cmd: 'a\0b', args: [] }); + expect(reply.status).toBe(500); + expect(reply.headers).toEqual(plain(reply.body.length)); + expect(reply.body.startsWith('spawn error: TypeError')).toBe(true); + }); +}); diff --git a/src/test-support/route-harness.ts b/src/test-support/route-harness.ts new file mode 100644 index 0000000..892c430 --- /dev/null +++ b/src/test-support/route-harness.ts @@ -0,0 +1,203 @@ +// An in-process HTTP server for the route golden tests: the request handler on a free loopback +// port with a temporary HOME and dist, a fresh SessionStore per test, and helpers that read a +// reply as status, headers and body. A test-support object, never collected as a test (ADR 062, ADR 064). +import fs from 'node:fs'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; +import { handleRequest } from '../server/routes'; +import type { Session } from '../server/sessions/session'; +import { SessionStore } from '../server/sessions/session-store'; +import { FakePty } from './fake-pty'; +import { cleanupTmpHome, getFreePort, makeTmpHome } from './server-harness'; + +/** A reply reduced to what the golden tests pin. */ +export interface Reply { + status: number; + headers: Record<string, string>; + body: string; +} + +/** The CORS headers every response carries, 404s and errors included. */ +export const CORS = { + 'access-control-allow-headers': 'Content-Type', + 'access-control-allow-methods': 'GET, POST, PATCH, DELETE, OPTIONS', + 'access-control-allow-origin': '*', +}; + +/** Headers of a `res.end(text)` reply with no Content-Type; Bun's node:http sends Content-Length: 0 on a 204 too. */ +export const plain = (length: number) => ({ ...CORS, 'content-length': String(length) }); + +/** Headers of a JSON reply whose body is `body`. */ +export const json = (body: string) => ({ + ...plain(Buffer.byteLength(body)), + 'content-type': 'application/json', +}); + +/** Headers of the streamed NDJSON reply from `/s/:id/execute`. */ +export const NDJSON = { + ...CORS, + 'cache-control': 'no-cache', + 'content-type': 'application/x-ndjson', + 'transfer-encoding': 'chunked', +}; + +/** The reply every unmatched route ends in. */ +export const NOT_FOUND: Reply = { status: 404, headers: plain(9), body: 'Not Found' }; + +/** The reply to an undecodable ID or a wrong content type. */ +export const BAD_REQUEST: Reply = { status: 400, headers: plain(11), body: 'Bad Request' }; + +/** The page `/s/:id` serves from the temporary dist. */ +export const CLIENT_HTML = '<!doctype html><p>client</p>'; + +/** The bytes of the stand-in wasm. */ +export const WASM_BYTES = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]); + +/** Keeps the server and its temporary HOME; call start() in beforeAll, reset() in beforeEach, stop() in afterAll. */ +export class RouteHarness { + tmpHome = ''; + wasmPath = ''; + /** The sessions the handler sees; replaced by reset() so tests start empty. */ + store = new SessionStore(); + /** How many times the handler called onStop. */ + stops = 0; + private server: http.Server | null = null; + private baseUrl = ''; + private origHome: string | undefined; + + /** Makes the temporary HOME, dist and wasm, and listens on a free port, wrapped as src/server/index.ts wraps the handler. */ + async start(): Promise<void> { + this.origHome = process.env.HOME; + this.tmpHome = makeTmpHome('routes-golden'); + process.env.HOME = this.tmpHome; + const distDir = path.join(this.tmpHome, 'dist'); + fs.mkdirSync(distDir); + fs.writeFileSync(path.join(distDir, 'client.html'), CLIENT_HTML); + fs.writeFileSync(path.join(distDir, 'client-browser.js'), 'console.log(1);'); + fs.writeFileSync(path.join(distDir, 'client.css'), 'body{}'); + this.wasmPath = path.join(this.tmpHome, 'ghostty-vt-small.wasm'); + fs.writeFileSync(this.wasmPath, WASM_BYTES); + fs.writeFileSync(path.join(this.tmpHome, 'secret.txt'), 'secret'); + + const server = http.createServer((req, res) => { + const onStop = () => this.stops++; + handleRequest(req, res, this.store, this.wasmPath, distDir, onStop).catch(() => { + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); + }); + const port = await getFreePort(); + await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve)); + this.baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + this.server = server; + } + + /** Gives the next test an empty store, no stop calls and no config file. */ + reset(): void { + this.store = new SessionStore(); + this.stops = 0; + fs.rmSync(path.join(this.tmpHome, '.config'), { recursive: true, force: true }); + } + + /** Closes the server, restores HOME and removes the temporary directory. */ + async stop(): Promise<void> { + const server = this.server; + if (server) await new Promise<void>((resolve) => server.close(() => resolve())); + this.server = null; + if (this.origHome === undefined) delete process.env.HOME; + else process.env.HOME = this.origHome; + cleanupTmpHome(this.tmpHome); + } + + /** Writes `config` as the config file in the temporary HOME; the handler reads it per request. */ + writeConfig(config: object): void { + const dir = path.join(this.tmpHome, '.config', 'webtty'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config)); + } + + /** Sends a request without following redirects; the reply's headers leave out Date. */ + call = async (method: string, url: string, init: RequestInit = {}): Promise<Reply> => { + const res = await fetch(`${this.baseUrl}${url}`, { method, redirect: 'manual', ...init }); + const headers: Record<string, string> = {}; + for (const [k, v] of res.headers) { + if (k !== 'date' && k !== 'connection' && k !== 'keep-alive') headers[k] = v; + } + return { status: res.status, headers, body: await res.text() }; + }; + + /** POSTs `body` (an object is JSON-encoded, a string is sent as-is) with `contentType`. */ + post = (url: string, body: unknown, contentType = 'application/json'): Promise<Reply> => + this.call('POST', url, { + headers: { 'content-type': contentType }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + + /** POSTs a streamed body; the test pushes chunks into the returned controller and closes it. */ + postStream = (url: string) => { + let controller!: ReadableStreamDefaultController<Uint8Array>; + const body = new ReadableStream<Uint8Array>({ + start(c) { + controller = c; + }, + }); + const init = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + duplex: 'half', + }; + const response = fetch(`${this.baseUrl}${url}`, init as RequestInit); + return { controller, response }; + }; + + /** GETs `rawPath` with node:http, so a path like `/dist/../x` reaches the server unnormalised. */ + rawGet = (rawPath: string): Promise<Reply> => + new Promise((resolve, reject) => { + const req = http.request(`${this.baseUrl}/`, { path: rawPath }, (res) => { + let body = ''; + res.on('data', (c: Buffer) => { + body += c.toString(); + }); + res.on('end', () => { + const headers = res.headers as Record<string, string>; + resolve({ status: res.statusCode ?? 0, headers, body }); + }); + }); + req.on('error', reject); + req.end(); + }); + + /** Registers a session whose PTY is a fake, as if a client had spawned its shell. */ + running = (id: string, pid = 4242, log: string[] = []): Session => { + const session = this.store.create(id, this.tmpHome); + session.pty = new FakePty(log, pid); + return session; + }; + + /** Registers a session with no shell yet. */ + idle = (id: string): Session => this.store.create(id, this.tmpHome); +} + +/** The JSON body sessionToJson gives a session with no client, written out field by field. */ +export function sessionBody(s: Session): string { + return JSON.stringify({ + id: s.id, + createdAt: s.createdAt, + baseDir: s.baseDir, + connected: false, + pid: s.pty?.pid ?? null, + }); +} + +/** Polls `cond` every 5 ms for up to 2 s. */ +export async function until(cond: () => boolean): Promise<void> { + const deadline = Date.now() + 2000; + while (!cond()) { + if (Date.now() > deadline) throw new Error('condition not met in time'); + await Bun.sleep(5); + } +} From f4549ac2625c1925a148207b95d1f71a755b4017 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:42:50 -0400 Subject: [PATCH 179/295] refactor(client): move graphics.ts to features/kitty/graphics/kitty-graphics.ts A pure git mv ahead of the split: KittyGraphics and its tests move into the graphics/ folder under the facade's name. Only import paths change, and the ratchet entry follows the file. --- scripts/architecture/ratchet-allowlist.ts | 2 +- .../kitty-graphics.test.ts} | 4 ++-- .../{graphics.ts => graphics/kitty-graphics.ts} | 16 ++++++++-------- src/client/index.ts | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) rename src/client/features/kitty/{graphics.test.ts => graphics/kitty-graphics.test.ts} (99%) rename src/client/features/kitty/{graphics.ts => graphics/kitty-graphics.ts} (98%) diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 145e6af..b0e85a0 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -20,7 +20,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'scripts/update-ghostty-vt.ts': { lines: 360, step: 'scripts/ghostty-vt/ split (ADR 059)' }, 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/features/kitty/graphics.ts': { lines: 662, step: KITTY_SPLIT }, + 'src/client/features/kitty/graphics/kitty-graphics.ts': { lines: 662, step: KITTY_SPLIT }, // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. 'src/client/index.ts': { lines: 568, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, diff --git a/src/client/features/kitty/graphics.test.ts b/src/client/features/kitty/graphics/kitty-graphics.test.ts similarity index 99% rename from src/client/features/kitty/graphics.test.ts rename to src/client/features/kitty/graphics/kitty-graphics.test.ts index d5a12a0..6eed4bd 100644 --- a/src/client/features/kitty/graphics.test.ts +++ b/src/client/features/kitty/graphics/kitty-graphics.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { deflateSync } from 'node:zlib'; -import { KittyGraphics, type Limits } from './graphics'; -import { deviceCellSize } from './protocol/cell-geometry'; +import { deviceCellSize } from '../protocol/cell-geometry'; +import { KittyGraphics, type Limits } from './kitty-graphics'; // KittyGraphics needs a DOM, a canvas and image decoding. These tests stub just // enough of them to drive its state handling: what is placed, what is answered, diff --git a/src/client/features/kitty/graphics.ts b/src/client/features/kitty/graphics/kitty-graphics.ts similarity index 98% rename from src/client/features/kitty/graphics.ts rename to src/client/features/kitty/graphics/kitty-graphics.ts index 5130f71..9bb0457 100644 --- a/src/client/features/kitty/graphics.ts +++ b/src/client/features/kitty/graphics/kitty-graphics.ts @@ -1,15 +1,15 @@ -import type { Terminal } from '../../vendor/ghostty-web'; -import { type KittyKeys, num, parseKittyApc } from './protocol/apc-command'; -import { ApcSplitter } from './protocol/apc-splitter'; -import { axisScale, cellSpan } from './protocol/cell-geometry'; +import type { Terminal } from '../../../vendor/ghostty-web'; +import { type KittyKeys, num, parseKittyApc } from '../protocol/apc-command'; +import { ApcSplitter } from '../protocol/apc-splitter'; +import { axisScale, cellSpan } from '../protocol/cell-geometry'; import { ChunkAssembler, MAX_TRANSMISSION_BYTES, type Transmission, -} from './protocol/chunk-assembler'; -import { ControlScanner } from './protocol/control-scanner'; -import { kittyReply } from './protocol/kitty-reply'; -import { pngSize, pngSizeFromBytes } from './protocol/png-size'; +} from '../protocol/chunk-assembler'; +import { ControlScanner } from '../protocol/control-scanner'; +import { kittyReply } from '../protocol/kitty-reply'; +import { pngSize, pngSizeFromBytes } from '../protocol/png-size'; // Sizes Ghostty reports to programs (CSI 14t/16t) are device pixels, as in kitty: // programs such as terminal-browser divide them by the display's diff --git a/src/client/index.ts b/src/client/index.ts index 331aa9e..e1bc756 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,7 +5,7 @@ import { ClipboardWriter } from './features/clipboard/clipboard-writer'; import { Composer } from './features/compose/composer'; import { FindBar } from './features/find/find-bar'; import { isMacPlatform, shortcutFor } from './features/keyboard/shortcut-map'; -import { KittyGraphics } from './features/kitty/graphics'; +import { KittyGraphics } from './features/kitty/graphics/kitty-graphics'; import { activates, browserNotifications } from './features/notifications/browser-notifications'; import { DesktopNotifier } from './features/notifications/desktop-notifier'; import { nonTextPaste } from './features/paste/non-text-paste'; From d0df8c6b26d7a1aabe338c928e44402acfa6723e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:45:36 -0400 Subject: [PATCH 180/295] refactor(server): HTTP helpers and a shared NDJSON line splitter http/respond.ts holds the CORS headers and the JSON, text, 404, 400 and redirect writers; http/request-body.ts holds readJson (64 KB), the single 413/400 mapping (bodyErrorReply, readJsonOrReply), the JSON content-type check and decodeId. src/ndjson.ts adds LineSplitter, used by /publish (64 KB partial-line reset, CR stripped) and by the CLI's run (CR kept, as before). routes.ts re-exports readJson so the golden tests pass unchanged. Bodies, headers and status codes are byte-identical (ADR 062, ADR 063). --- src/cli/commands.ts | 10 +- src/ndjson.test.ts | 38 ++++ src/ndjson.ts | 52 +++++ src/server/http/request-body.test.ts | 103 ++++++++++ src/server/http/request-body.ts | 74 ++++++++ src/server/http/respond.test.ts | 66 +++++++ src/server/http/respond.ts | 52 +++++ src/server/routes.ts | 264 ++++++++------------------ src/test-support/response-recorder.ts | 24 +++ 9 files changed, 493 insertions(+), 190 deletions(-) create mode 100644 src/ndjson.test.ts create mode 100644 src/ndjson.ts create mode 100644 src/server/http/request-body.test.ts create mode 100644 src/server/http/request-body.ts create mode 100644 src/server/http/respond.test.ts create mode 100644 src/server/http/respond.ts create mode 100644 src/test-support/response-recorder.ts diff --git a/src/cli/commands.ts b/src/cli/commands.ts index d2c7adf..adb53dc 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -2,6 +2,7 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { configDir, loadConfig } from '../config'; +import { LineSplitter } from '../ndjson'; import { getBaseUrl, getPort, isServerRunning, openBrowser, startServer, stopServer } from './http'; /** @@ -294,16 +295,13 @@ export async function cmdRun(id: string, cmd?: string, args: string[] = []): Pro const reader = res.body.getReader(); const decoder = new TextDecoder(); - let buf = ''; + // The server never sends CR; keeping it leaves the lines exactly as they were before the splitter. + const lines = new LineSplitter({ stripCarriageReturn: false }); while (true) { const { done, value } = await reader.read(); if (done) break; - buf += decoder.decode(value, { stream: true }); - const lines = buf.split('\n'); - buf = lines.pop() ?? ''; - for (const line of lines) { - if (!line) continue; + for (const line of lines.push(decoder.decode(value, { stream: true }))) { const msg = JSON.parse(line) as Record<string, unknown>; if (msg.stream === 'stdout') process.stdout.write(String(msg.data)); else if (msg.stream === 'stderr') process.stderr.write(String(msg.data)); diff --git a/src/ndjson.test.ts b/src/ndjson.test.ts new file mode 100644 index 0000000..0db1274 --- /dev/null +++ b/src/ndjson.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { LineSplitter } from './ndjson'; + +describe('LineSplitter', () => { + test.each([ + ['one line per chunk', ['a\n', 'b\n'], [['a'], ['b']]], + ['several lines in one chunk', ['a\nb\nc\n'], [['a', 'b', 'c']]], + ['a line split across chunks', ['{"a"', ':1}\n'], [[], ['{"a":1}']]], + ['CRLF endings', ['a\r\nb\r\n'], [['a', 'b']]], + ['empty and bare-CR lines skipped', ['\n\r\na\n\n'], [['a']]], + ])('%s', (_name, chunks, expected) => { + const s = new LineSplitter(); + expect(chunks.map((c) => s.push(c))).toEqual(expected); + expect(s.flush()).toBeNull(); + }); + + test('flush returns the partial last line once, CR stripped', () => { + const s = new LineSplitter(); + expect(s.push('a\nb\r')).toEqual(['a']); + expect(s.flush()).toBe('b'); + expect(s.flush()).toBeNull(); + }); + + test('keeps the CR when asked to', () => { + const s = new LineSplitter({ stripCarriageReturn: false }); + expect(s.push('a\r\n\r\n')).toEqual(['a\r', '\r']); + s.push('b\r'); + expect(s.flush()).toBe('b\r'); + }); + + test('drops a partial line longer than maxLength, then carries on', () => { + const s = new LineSplitter({ maxLength: 4 }); + expect(s.push('ok\nabcde')).toEqual(['ok']); + expect(s.push('f\nnext\n')).toEqual(['f', 'next']); + s.push('abcd'); + expect(s.flush()).toBe('abcd'); + }); +}); diff --git a/src/ndjson.ts b/src/ndjson.ts new file mode 100644 index 0000000..8452604 --- /dev/null +++ b/src/ndjson.ts @@ -0,0 +1,52 @@ +// Newline-delimited JSON framing shared by the server's /publish route and the CLI's `run` +// (ADR 062, ADR 063). An object: a LineSplitter turns arbitrary stream chunks into whole lines. + +/** How a {@link LineSplitter} treats line endings and an unterminated line. */ +export interface LineSplitterOptions { + /** + * Drop the pending partial line once it grows past this many characters, so a peer that + * never sends a newline cannot grow the buffer without bound. No limit when omitted. + */ + maxLength?: number; + /** Strip one trailing `\r` from each line (CRLF input). Defaults to true. */ + stripCarriageReturn?: boolean; +} + +/** + * Splits a chunked text stream into lines on `\n`, holding back the partial last line until + * the next chunk or {@link flush}. Empty lines are skipped. One splitter per stream. + */ +export class LineSplitter { + private pending = ''; + private readonly maxLength: number; + private readonly stripCarriageReturn: boolean; + + constructor(options: LineSplitterOptions = {}) { + this.maxLength = options.maxLength ?? Number.POSITIVE_INFINITY; + this.stripCarriageReturn = options.stripCarriageReturn ?? true; + } + + /** Appends `chunk` and returns the non-empty lines it completed, in order. */ + push(chunk: string): string[] { + const parts = (this.pending + chunk).split('\n'); + this.pending = parts.pop() ?? ''; + if (this.pending.length > this.maxLength) this.pending = ''; + const lines: string[] = []; + for (const part of parts) { + const line = this.clean(part); + if (line) lines.push(line); + } + return lines; + } + + /** Returns the unterminated last line at end of stream (or null if none), and clears it. */ + flush(): string | null { + const line = this.clean(this.pending); + this.pending = ''; + return line || null; + } + + private clean(line: string): string { + return this.stripCarriageReturn ? line.replace(/\r$/, '') : line; + } +} diff --git a/src/server/http/request-body.test.ts b/src/server/http/request-body.test.ts new file mode 100644 index 0000000..144b1a3 --- /dev/null +++ b/src/server/http/request-body.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test'; +import http from 'node:http'; +import { Socket } from 'node:net'; +import { recordResponse } from '../../test-support/response-recorder'; +import { + bodyErrorReply, + decodeId, + hasJsonContentType, + MAX_BODY, + readJson, + readJsonOrReply, +} from './request-body'; + +/** A request whose body is `chunks`, with no socket behind it. */ +function request(chunks: string[], headers: http.IncomingHttpHeaders = {}): http.IncomingMessage { + const req = new http.IncomingMessage(new Socket()); + req.headers = headers; + for (const c of chunks) req.push(c); + req.push(null); + return req; +} + +describe('readJson', () => { + test('parses a chunked body, and an empty body is {}', async () => { + expect(await readJson(request(['{"a":', '1}']))).toEqual({ a: 1 }); + expect(await readJson(request([]))).toEqual({}); + }); + + test('a body of exactly 64 KB is read, one byte more is 413', async () => { + const atLimit = `"${'x'.repeat(MAX_BODY - 2)}"`; + expect(await readJson(request([atLimit]))).toBe('x'.repeat(MAX_BODY - 2)); + await expect(readJson(request([`${atLimit} `]))).rejects.toMatchObject({ status: 413 }); + }); + + test('invalid JSON rejects without a status', async () => { + const err = await readJson(request(['{nope'])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as { status?: number }).status).toBeUndefined(); + }); +}); + +describe('bodyErrorReply', () => { + test.each([ + [ + 'too large', + Object.assign(new Error('payload too large'), { status: 413 }), + 413, + 'Payload Too Large', + ], + ['invalid JSON', new Error('invalid JSON'), 400, 'invalid JSON'], + ['a socket error', new Error('ECONNRESET'), 400, 'invalid JSON'], + ])('%s', (_name, err, status, body) => { + expect(bodyErrorReply(err)).toEqual({ status, body } as ReturnType<typeof bodyErrorReply>); + }); +}); + +describe('readJsonOrReply', () => { + test('returns the value and writes nothing', async () => { + const { res, calls } = recordResponse(); + expect(await readJsonOrReply(request(['[1]']), res)).toEqual({ value: [1] }); + expect(calls).toEqual([]); + }); + + test('an oversized body is answered 413 and returns null', async () => { + const { res, calls } = recordResponse(); + expect(await readJsonOrReply(request(['x'.repeat(MAX_BODY + 1)]), res)).toBeNull(); + expect(calls).toEqual([ + ['writeHead', 413], + ['end', 'Payload Too Large'], + ]); + }); + + test('an invalid body is answered 400 and returns null', async () => { + const { res, calls } = recordResponse(); + expect(await readJsonOrReply(request(['{']), res)).toBeNull(); + expect(calls).toEqual([ + ['writeHead', 400], + ['end', 'invalid JSON'], + ]); + }); +}); + +describe('hasJsonContentType', () => { + test.each([ + [undefined, false], + ['text/plain', false], + ['application/json', true], + ['application/json; charset=utf-8', true], + ])('%p → %p', (type, expected) => { + const headers = type === undefined ? {} : { 'content-type': type }; + expect(hasJsonContentType(request([], headers))).toBe(expected); + }); +}); + +describe('decodeId', () => { + test.each([ + ['main', 'main'], + ['a%20b', 'a b'], + ['%E0%A4%A', null], + ])('%p → %p', (raw, expected) => { + expect(decodeId(raw)).toBe(expected); + }); +}); diff --git a/src/server/http/request-body.ts b/src/server/http/request-body.ts new file mode 100644 index 0000000..28ea15e --- /dev/null +++ b/src/server/http/request-body.ts @@ -0,0 +1,74 @@ +// Request-side helpers for the HTTP routes: the 64 KB JSON body reader with its single +// 413/400 error mapping, the JSON content-type check and URL ID decoding (ADR 062). Utilities. +import type http from 'node:http'; +import { sendText } from './respond'; + +/** The largest request body the server reads, JSON or an NDJSON line (64 KB). */ +export const MAX_BODY = 64 * 1024; + +/** + * Reads and JSON-parses the request body (max 64 KB). + * + * @param req - The incoming HTTP request. + * @returns A promise resolving to the parsed JSON value, or an empty object if the body is empty. + * @throws {Error} with `status: 413` if the body exceeds 64 KB (the request is destroyed). + * @throws {Error} if the body contains invalid JSON. + */ +export function readJson(req: http.IncomingMessage): Promise<unknown> { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > MAX_BODY) { + req.destroy(); + reject(Object.assign(new Error('payload too large'), { status: 413 })); + } + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch { + reject(new Error('invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +/** The status and body a {@link readJson} failure is answered with: 413 when too large, else 400. */ +export function bodyErrorReply(err: unknown): { status: 413 | 400; body: string } { + return (err as { status?: number }).status === 413 + ? { status: 413, body: 'Payload Too Large' } + : { status: 400, body: 'invalid JSON' }; +} + +/** + * Reads the JSON body of `req` for a route. On failure it has already answered `res` + * (413 or 400, see {@link bodyErrorReply}) and returns null; otherwise `{ value }`. + */ +export async function readJsonOrReply( + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise<{ value: unknown } | null> { + try { + return { value: await readJson(req) }; + } catch (err) { + const reply = bodyErrorReply(err); + sendText(res, reply.status, reply.body); + return null; + } +} + +/** True when the request says its body is JSON, as /publish and /execute require. */ +export function hasJsonContentType(req: http.IncomingMessage): boolean { + return (req.headers['content-type'] ?? '').startsWith('application/json'); +} + +/** Decodes a percent-encoded ID from a URL path segment, or null if it is malformed. */ +export function decodeId(raw: string): string | null { + try { + return decodeURIComponent(raw); + } catch { + return null; + } +} diff --git a/src/server/http/respond.test.ts b/src/server/http/respond.test.ts new file mode 100644 index 0000000..6f3db0e --- /dev/null +++ b/src/server/http/respond.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test'; +import type http from 'node:http'; +import { recordResponse } from '../../test-support/response-recorder'; +import { + CORS_HEADERS, + redirect, + sendBadRequest, + sendJson, + sendNotFound, + sendText, + setCors, +} from './respond'; + +describe('respond', () => { + test('setCors sets the three CORS headers', () => { + const { res, calls } = recordResponse(); + setCors(res); + expect(calls).toEqual(Object.entries(CORS_HEADERS).map(([k, v]) => ['setHeader', k, v])); + }); + + test.each<[string, (res: http.ServerResponse) => void, unknown[][]]>([ + [ + 'sendJson', + (r) => sendJson(r, 201, { a: 1 }), + [ + ['writeHead', 201, { 'Content-Type': 'application/json' }], + ['end', '{"a":1}'], + ], + ], + [ + 'sendText with a body', + (r) => sendText(r, 409, 'x'), + [ + ['writeHead', 409], + ['end', 'x'], + ], + ], + ['sendText with no body', (r) => sendText(r, 204), [['writeHead', 204], ['end']]], + [ + 'sendText with headers', + (r) => sendText(r, 204, undefined, { 'X-A': '1' }), + [['writeHead', 204, { 'X-A': '1' }], ['end']], + ], + [ + 'sendNotFound', + sendNotFound, + [ + ['writeHead', 404], + ['end', 'Not Found'], + ], + ], + [ + 'sendBadRequest', + sendBadRequest, + [ + ['writeHead', 400], + ['end', 'Bad Request'], + ], + ], + ['redirect', (r) => redirect(r, '/s/a'), [['writeHead', 302, { Location: '/s/a' }], ['end']]], + ])('%s', (_name, send, expected) => { + const { res, calls } = recordResponse(); + send(res); + expect(calls).toEqual(expected); + }); +}); diff --git a/src/server/http/respond.ts b/src/server/http/respond.ts new file mode 100644 index 0000000..0101f57 --- /dev/null +++ b/src/server/http/respond.ts @@ -0,0 +1,52 @@ +// Response writers for the HTTP routes: CORS, JSON, plain text and redirects (ADR 062). +// Utilities; each writes the status line and headers exactly as the old inline code did. +import type http from 'node:http'; + +/** The CORS headers set on every response, errors and 404s included. */ +export const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +/** Sets {@link CORS_HEADERS} on `res`, before any route writes its head. */ +export function setCors(res: http.ServerResponse): void { + for (const [k, v] of Object.entries(CORS_HEADERS)) res.setHeader(k, v); +} + +/** Ends `res` with `status` and `value` as a JSON body. */ +export function sendJson(res: http.ServerResponse, status: number, value: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(value)); +} + +/** + * Ends `res` with `status`, optional extra headers and an optional body. With no body it + * calls `res.end()` with no argument, as the routes always have, so no header changes. + */ +export function sendText( + res: http.ServerResponse, + status: number, + body?: string, + headers?: http.OutgoingHttpHeaders, +): void { + if (headers) res.writeHead(status, headers); + else res.writeHead(status); + if (body === undefined) res.end(); + else res.end(body); +} + +/** The plain `404 Not Found` every unmatched or missing resource ends in. */ +export function sendNotFound(res: http.ServerResponse): void { + sendText(res, 404, 'Not Found'); +} + +/** The plain `400 Bad Request` for an undecodable ID or a wrong content type. */ +export function sendBadRequest(res: http.ServerResponse): void { + sendText(res, 400, 'Bad Request'); +} + +/** A `302` to `location` with an empty body. */ +export function redirect(res: http.ServerResponse, location: string): void { + sendText(res, 302, undefined, { Location: location }); +} diff --git a/src/server/routes.ts b/src/server/routes.ts index 681dfc0..0598f9f 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,64 +1,30 @@ +// The webtty HTTP request handler: the REST API, the page routes and static assets, as one +// ordered if-chain (ADR 062). Response and body helpers live in http/; routes/ splits this next. import { spawn } from 'node:child_process'; import fs from 'node:fs'; import type http from 'node:http'; import { homedir } from 'node:os'; import path from 'node:path'; import { loadConfig, toClientConfig } from '../config'; +import { LineSplitter } from '../ndjson'; import { getVersion } from '../version'; +import { decodeId, hasJsonContentType, MAX_BODY, readJsonOrReply } from './http/request-body'; +import { + redirect, + sendBadRequest, + sendJson, + sendNotFound, + sendText, + setCors, +} from './http/respond'; import { GHOSTTY_WASM, serveFile } from './http/static-files'; import { sessionToJson } from './sessions/session'; import { generateId, isValidId } from './sessions/session-id'; import type { SessionStore } from './sessions/session-store'; import { WS_CLOSE } from './ws/close-codes'; -const MAX_BODY = 64 * 1024; - -const CORS_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', -}; - -function setCors(res: http.ServerResponse): void { - for (const [k, v] of Object.entries(CORS_HEADERS)) res.setHeader(k, v); -} - -function decodeId(raw: string): string | null { - try { - return decodeURIComponent(raw); - } catch { - return null; - } -} - -/** - * Reads and JSON-parses the request body (max 64 KB). - * - * @param req - The incoming HTTP request. - * @returns A promise resolving to the parsed JSON object, or an empty object if body is empty. - * @throws {Error} with `status: 413` if body exceeds 64 KB. - * @throws {Error} if the body contains invalid JSON. - */ -export function readJson(req: http.IncomingMessage): Promise<unknown> { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', (chunk) => { - body += chunk; - if (body.length > MAX_BODY) { - req.destroy(); - reject(Object.assign(new Error('payload too large'), { status: 413 })); - } - }); - req.on('end', () => { - try { - resolve(body ? JSON.parse(body) : {}); - } catch { - reject(new Error('invalid JSON')); - } - }); - req.on('error', reject); - }); -} +// readJson moved to http/request-body.ts; re-exported because the route golden test imports it here. +export { readJson } from './http/request-body'; /** * Main HTTP request handler for the webtty server. @@ -85,81 +51,64 @@ export async function handleRequest( setCors(res); if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); + sendText(res, 204); return; } if (req.method === 'POST' && pathname === '/api/server/stop') { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('stopping'); + sendText(res, 200, 'stopping', { 'Content-Type': 'text/plain' }); onStop(); return; } if (req.method === 'GET' && pathname === '/api/server/status') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ version: getVersion() })); + sendJson(res, 200, { version: getVersion() }); return; } if (req.method === 'GET' && pathname === '/api/config') { // Only the client-safe keys: server-side config (shell, host, logs, env…) stays here. const clientConfig = toClientConfig(loadConfig()); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(clientConfig)); + sendJson(res, 200, clientConfig); return; } if (pathname === '/api/sessions') { if (req.method === 'GET') { const list = store.list().map(sessionToJson); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(list)); + sendJson(res, 200, list); return; } if (req.method === 'POST') { - let body: { id?: string; baseDir?: string }; - try { - body = (await readJson(req)) as { id?: string; baseDir?: string }; - } catch (err) { - const status = (err as { status?: number }).status === 413 ? 413 : 400; - res.writeHead(status); - res.end(status === 413 ? 'Payload Too Large' : 'invalid JSON'); - return; - } + const read = await readJsonOrReply(req, res); + if (!read) return; + const body = read.value as { id?: string; baseDir?: string }; const id = body.id ?? generateId(); if (!isValidId(id)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `invalid id: ${id}` })); + sendJson(res, 400, { error: `invalid id: ${id}` }); return; } if (store.has(id)) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `session already exists: ${id}` })); + sendJson(res, 409, { error: `session already exists: ${id}` }); return; } const baseDir = body.baseDir ?? homedir(); if (!path.isAbsolute(baseDir)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'baseDir must be an absolute path' })); + sendJson(res, 400, { error: 'baseDir must be an absolute path' }); return; } if (!fs.existsSync(baseDir)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `baseDir does not exist: ${baseDir}` })); + sendJson(res, 400, { error: `baseDir does not exist: ${baseDir}` }); return; } if (!fs.statSync(baseDir).isDirectory()) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `baseDir is not a directory: ${baseDir}` })); + sendJson(res, 400, { error: `baseDir is not a directory: ${baseDir}` }); return; } const session = store.create(id, baseDir); - res.writeHead(201, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(sessionToJson(session))); + sendJson(res, 201, sessionToJson(session)); return; } } @@ -168,73 +117,57 @@ export async function handleRequest( if (sessionMatch) { const id = decodeId(sessionMatch[1]); if (!id) { - res.writeHead(400); - res.end('Bad Request'); + sendBadRequest(res); return; } if (req.method === 'GET') { const session = store.get(id); if (!session) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(sessionToJson(session))); + sendJson(res, 200, sessionToJson(session)); return; } if (req.method === 'PATCH') { - let body: { id?: string }; - try { - body = (await readJson(req)) as { id?: string }; - } catch (err) { - const status = (err as { status?: number }).status === 413 ? 413 : 400; - res.writeHead(status); - res.end(status === 413 ? 'Payload Too Large' : 'invalid JSON'); - return; - } + const read = await readJsonOrReply(req, res); + if (!read) return; + const body = read.value as { id?: string }; const session = store.get(id); if (!session) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } const newId = body.id; if (!newId) { - res.writeHead(400); - res.end('missing id'); + sendText(res, 400, 'missing id'); return; } if (!isValidId(newId)) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `invalid id: ${newId}` })); + sendJson(res, 400, { error: `invalid id: ${newId}` }); return; } if (store.has(newId)) { - res.writeHead(409, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `session already exists: ${newId}` })); + sendJson(res, 409, { error: `session already exists: ${newId}` }); return; } store.rename(id, newId); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(sessionToJson(session))); + sendJson(res, 200, sessionToJson(session)); return; } if (req.method === 'DELETE') { const session = store.remove(id); if (!session) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } session.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - res.writeHead(204, { 'X-Sessions-Remaining': String(store.size) }); - res.end(); + sendText(res, 204, undefined, { 'X-Sessions-Remaining': String(store.size) }); return; } } @@ -245,8 +178,7 @@ export async function handleRequest( if (!store.has('main')) store.create('main'); targetId = 'main'; } - res.writeHead(302, { Location: `/s/${targetId}` }); - res.end(); + redirect(res, `/s/${targetId}`); return; } @@ -254,8 +186,7 @@ export async function handleRequest( if (req.method === 'GET' && clientMatch) { const id = decodeId(clientMatch[1]); if (!id || !isValidId(id) || !store.has(id)) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } const clientHtml = path.resolve(clientDistPath, 'client.html'); @@ -267,59 +198,43 @@ export async function handleRequest( if (req.method === 'POST' && publishMatch) { const id = decodeId(publishMatch[1]); if (!id) { - res.writeHead(400); - res.end('Bad Request'); + sendBadRequest(res); return; } - if (!(req.headers['content-type'] ?? '').startsWith('application/json')) { - res.writeHead(400); - res.end('Bad Request'); + if (!hasJsonContentType(req)) { + sendBadRequest(res); return; } const session = store.get(id); if (!session) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } if (session.pty === null) { - res.writeHead(409); - res.end('PTY not running'); + sendText(res, 409, 'PTY not running'); return; } - let buf = ''; - req.on('data', (chunk: Buffer) => { - buf += chunk.toString('utf8'); - const lines = buf.split('\n'); - buf = lines.pop() ?? ''; - if (buf.length > MAX_BODY) buf = ''; - for (const line of lines) { - const trimmed = line.replace(/\r$/, ''); - if (!trimmed) continue; - try { - JSON.parse(trimmed); - session.broadcastEvent(trimmed); - } catch { - // skip invalid JSON lines - } + // One event per NDJSON line; invalid JSON lines are skipped, and a line that grows past + // 64 KB without a newline is dropped. + const broadcastIfJson = (line: string) => { + try { + JSON.parse(line); + session.broadcastEvent(line); + } catch { + // skip invalid JSON lines } + }; + const lines = new LineSplitter({ maxLength: MAX_BODY }); + req.on('data', (chunk: Buffer) => { + for (const line of lines.push(chunk.toString('utf8'))) broadcastIfJson(line); }); req.on('end', () => { - const trimmed = buf.replace(/\r$/, ''); - if (trimmed) { - try { - JSON.parse(trimmed); - session.broadcastEvent(trimmed); - } catch { - // skip invalid JSON - } - } - res.writeHead(204); - res.end(); + const last = lines.flush(); + if (last) broadcastIfJson(last); + sendText(res, 204); }); req.on('error', () => { - res.writeHead(500); - res.end(); + sendText(res, 500); }); return; } @@ -328,44 +243,31 @@ export async function handleRequest( if (req.method === 'POST' && executeMatch) { const id = decodeId(executeMatch[1]); if (!id) { - res.writeHead(400); - res.end('Bad Request'); + sendBadRequest(res); return; } - if (!(req.headers['content-type'] ?? '').startsWith('application/json')) { - res.writeHead(400); - res.end('Bad Request'); + if (!hasJsonContentType(req)) { + sendBadRequest(res); return; } const session = store.get(id); if (!session) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } if (session.pty === null) { - res.writeHead(409); - res.end('PTY not running'); - return; - } - let execBody: unknown; - try { - execBody = await readJson(req); - } catch (err) { - const status = (err as { status?: number }).status === 413 ? 413 : 400; - res.writeHead(status); - res.end(status === 413 ? 'Payload Too Large' : 'invalid JSON'); + sendText(res, 409, 'PTY not running'); return; } - const { cmd, args, stdin } = execBody as { cmd?: unknown; args?: unknown; stdin?: unknown }; + const read = await readJsonOrReply(req, res); + if (!read) return; + const { cmd, args, stdin } = read.value as { cmd?: unknown; args?: unknown; stdin?: unknown }; if (typeof cmd !== 'string' || cmd.length === 0) { - res.writeHead(400); - res.end('invalid body'); + sendText(res, 400, 'invalid body'); return; } if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) { - res.writeHead(400); - res.end('invalid body'); + sendText(res, 400, 'invalid body'); return; } let child: ReturnType<typeof spawn>; @@ -377,8 +279,7 @@ export async function handleRequest( windowsHide: true, }); } catch (err) { - res.writeHead(500); - res.end(`spawn error: ${String(err)}`); + sendText(res, 500, `spawn error: ${String(err)}`); return; } res.writeHead(200, { @@ -421,18 +322,15 @@ export async function handleRequest( if (req.method === 'GET' && pidMatch) { const pid = parseInt(pidMatch[1], 10); if (!Number.isFinite(pid) || pid <= 0) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } const session = store.findByPid(pid); if (!session) { - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } - res.writeHead(302, { Location: `/s/${session.id}` }); - res.end(); + redirect(res, `/s/${session.id}`); return; } @@ -442,8 +340,7 @@ export async function handleRequest( serveFile(ownFile, res); return; } - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); return; } @@ -452,6 +349,5 @@ export async function handleRequest( return; } - res.writeHead(404); - res.end('Not Found'); + sendNotFound(res); } diff --git a/src/test-support/response-recorder.ts b/src/test-support/response-recorder.ts new file mode 100644 index 0000000..29dd2d5 --- /dev/null +++ b/src/test-support/response-recorder.ts @@ -0,0 +1,24 @@ +// A real http.ServerResponse whose setHeader, writeHead and end only record their arguments, +// so the HTTP helper tests can pin the exact calls without a socket. Test support (ADR 064). +import { spyOn } from 'bun:test'; +import http from 'node:http'; +import { Socket } from 'node:net'; + +/** A response and the ordered list of `[method, ...args]` calls made on it. */ +export function recordResponse(): { res: http.ServerResponse; calls: unknown[][] } { + const res = new http.ServerResponse(new http.IncomingMessage(new Socket())); + const calls: unknown[][] = []; + spyOn(res, 'setHeader').mockImplementation((...a) => { + calls.push(['setHeader', ...a]); + return res; + }); + spyOn(res, 'writeHead').mockImplementation((...a: unknown[]) => { + calls.push(['writeHead', ...a]); + return res; + }); + spyOn(res, 'end').mockImplementation((...a: unknown[]) => { + calls.push(['end', ...a]); + return res; + }); + return { res, calls }; +} From 517286f343d805920a1b2388e43301037e390c29 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:52:12 -0400 Subject: [PATCH 181/295] refactor(client): split graphics.ts into features/kitty/graphics/ KittyGraphics becomes a facade (298 lines, was 662) over one module per part: kitty-limits (KittyLimits, DEFAULT_LIMITS), kitty-error (KittyError), kitty-terminal (the KittyTerminal slice it reads, cellSize, deviceScale, scrollbackLength, scrollOffset), image-decoder (pre-decode checks, decodeFootprint, decodeImage with the browser constructors injected), image-store (ImageStore: recency order, byte budget, eviction, install of a finished decode), placements (the Placements table), placement-geometry (placementAt, cursorAdvance, drawRect, assertCursorMovable; pure) and overlay-canvas (OverlayCanvas: the frame loop and repaint rules). The feed hot path is unchanged: splitter -> term.write + control scan, or the APC handler. The overlay still appends its canvas, queues its first frame and adds its document visibilitychange listener at construction, in that order, so the listener's position in the document order is unchanged. The overlay now takes document and window as deps (production default: the container's document and the window), keeps its frame id and gains an idempotent dispose() that cancels the frame, removes the listener and the canvas; KittyGraphics.dispose() also frees the images and silences running decodes. Nothing calls either at runtime. Tests: graphics.test.ts is redistributed. The facade tests run on a graphics harness (fake terminal and decoder, an injected happy-dom container, a manual clock) instead of stubbed globals, read images and placements through the facade's read-only parts instead of private casts, and observe repaints through frames. New per-module tests cover the image store's eviction order, the decoder, placement geometry, the terminal readers and the overlay, including its dispose. Every module under graphics/ is at 100% lines. The ratchet entry for the file is removed, and the kitty-graphics and architecture SPECs list the new files. --- docs/specs/architecture.md | 6 +- docs/specs/kitty-graphics.md | 17 +- scripts/architecture/ratchet-allowlist.ts | 2 - .../kitty/graphics/image-decoder.test.ts | 164 +++++ .../features/kitty/graphics/image-decoder.ts | 152 ++++ .../kitty/graphics/image-store.test.ts | 161 +++++ .../features/kitty/graphics/image-store.ts | 181 +++++ .../features/kitty/graphics/kitty-error.ts | 12 + .../kitty/graphics/kitty-graphics.test.ts | 612 +++++++--------- .../features/kitty/graphics/kitty-graphics.ts | 662 ++++-------------- .../features/kitty/graphics/kitty-limits.ts | 28 + .../kitty/graphics/kitty-terminal.test.ts | 57 ++ .../features/kitty/graphics/kitty-terminal.ts | 61 ++ .../kitty/graphics/overlay-canvas.test.ts | 193 +++++ .../features/kitty/graphics/overlay-canvas.ts | 146 ++++ .../kitty/graphics/placement-geometry.test.ts | 109 +++ .../kitty/graphics/placement-geometry.ts | 115 +++ .../kitty/graphics/placements.test.ts | 45 ++ .../features/kitty/graphics/placements.ts | 64 ++ .../graphics/test-support/graphics-harness.ts | 192 +++++ 20 files changed, 2085 insertions(+), 894 deletions(-) create mode 100644 src/client/features/kitty/graphics/image-decoder.test.ts create mode 100644 src/client/features/kitty/graphics/image-decoder.ts create mode 100644 src/client/features/kitty/graphics/image-store.test.ts create mode 100644 src/client/features/kitty/graphics/image-store.ts create mode 100644 src/client/features/kitty/graphics/kitty-error.ts create mode 100644 src/client/features/kitty/graphics/kitty-limits.ts create mode 100644 src/client/features/kitty/graphics/kitty-terminal.test.ts create mode 100644 src/client/features/kitty/graphics/kitty-terminal.ts create mode 100644 src/client/features/kitty/graphics/overlay-canvas.test.ts create mode 100644 src/client/features/kitty/graphics/overlay-canvas.ts create mode 100644 src/client/features/kitty/graphics/placement-geometry.test.ts create mode 100644 src/client/features/kitty/graphics/placement-geometry.ts create mode 100644 src/client/features/kitty/graphics/placements.test.ts create mode 100644 src/client/features/kitty/graphics/placements.ts create mode 100644 src/client/features/kitty/graphics/test-support/graphics-harness.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 853c808..8264152 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -149,7 +149,9 @@ src/ scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, scroll-feature.ts kitty/ protocol/ apc-splitter.ts, apc-command.ts, chunk-assembler.ts, kitty-reply.ts, png-size.ts, cell-geometry.ts, control-scanner.ts - graphics/ image-store.ts, image-decoder.ts, placements.ts, kitty-limits.ts, overlay-canvas.ts (+ dispose), kitty-graphics.ts (facade) + graphics/ image-store.ts, image-decoder.ts, placements.ts, placement-geometry.ts, kitty-limits.ts, + kitty-error.ts, kitty-terminal.ts, overlay-canvas.ts (+ dispose), kitty-graphics.ts (facade), + test-support/graphics-harness.ts kitty-feature.ts test-support/ fake-terminal.ts, fake-socket.ts, manual-clock.ts, recording-target.ts, dom-window.ts test-support.test.ts the fakes' self-tests (a test-support/ folder holds no *.test.ts) @@ -319,7 +321,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a | Architecture SPEC and conventions | Layers, folders, module kinds, naming, size limits, comments | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | -| Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` (protocol done) | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | +| Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | | Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | | Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ⬜ | diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index 5fb187f..407ad0f 100644 --- a/docs/specs/kitty-graphics.md +++ b/docs/specs/kitty-graphics.md @@ -24,7 +24,7 @@ the rejected alternatives. ``` PTY ──► server ──► WebSocket ──► ws.onmessage │ - KittyGraphics.feed(data) (features/kitty/graphics.ts) + KittyGraphics.feed(data) (features/kitty/graphics/kitty-graphics.ts) │ │ text segments APC commands │ │ @@ -32,7 +32,7 @@ PTY ──► server ──► WebSocket ──► ws.onmessage ▲ │ │ └─ cursor ───┘ ▼ advance / reply overlay canvas - (features/kitty/graphics.ts) + (features/kitty/graphics/overlay-canvas.ts) replies (a=q OK) ──► ws.send ──► PTY ``` @@ -134,8 +134,17 @@ src/client/ png-size.ts PNG IHDR width and height, from bytes or base64 cell-geometry.ts device cell size, per-axis scale, cells an image covers control-scanner.ts sequences that clear placements, across chunk boundaries - graphics.ts ← browser: decode, placement table, overlay canvas, scroll tracking - graphics.test.ts ← state handling against a stubbed DOM and image decoder + graphics/ ← browser side, one part per file, each with its x.test.ts: + kitty-graphics.ts the facade: feed(), command routing, replies, reset/clear/dispose + kitty-limits.ts image count, pixel, byte and transmission limits + kitty-error.ts the error a refused command is answered with + kitty-terminal.ts the slice of the terminal it reads: cell size, scrollback, scale + image-decoder.ts pre-decode checks and base64 / zlib / PNG / raw decode (deps injected) + image-store.ts images by id in recency order, the byte budget, eviction + placements.ts the placement table + placement-geometry.ts placement record, cursor advance, draw rectangle (pure) + overlay-canvas.ts the overlay canvas, its frame loop and repaint rules, dispose() + test-support/ graphics-harness.ts: fake terminal and decoder, happy-dom container index.ts ← wires ws.onmessage through KittyGraphics ``` diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index b0e85a0..e86c6c8 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -8,7 +8,6 @@ import type { Allowlist } from './ratchet'; const ENGINE_SPLIT = 'vendor engine split, ghostty.ts into engine/ (ADR 061)'; const VENDOR_TESTS = 'vendor tests split with their facades into vendor/tests/ (ADR 061, ADR 064)'; const CLIENT_ROOT = 'client composition root, index.ts an entry only (ADR 060)'; -const KITTY_SPLIT = 'kitty graphics split into features/kitty/ (ADR 060)'; const SERVER_HARNESS = 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; @@ -20,7 +19,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'scripts/update-ghostty-vt.ts': { lines: 360, step: 'scripts/ghostty-vt/ split (ADR 059)' }, 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/features/kitty/graphics/kitty-graphics.ts': { lines: 662, step: KITTY_SPLIT }, // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. 'src/client/index.ts': { lines: 568, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, diff --git a/src/client/features/kitty/graphics/image-decoder.test.ts b/src/client/features/kitty/graphics/image-decoder.test.ts new file mode 100644 index 0000000..95850db --- /dev/null +++ b/src/client/features/kitty/graphics/image-decoder.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { deflateSync } from 'node:zlib'; +import { + assertSupported, + assertWithinLimits, + BROWSER_DECODER, + decodeFootprint, + decodeImage, + transmissionSize, +} from './image-decoder'; +import { KittyError } from './kitty-error'; +import { b64, FakeDecoder, fakeBitmap, pngHeader, zeros } from './test-support/graphics-harness'; + +const keysOf = (s: string): Record<string, string> => + Object.fromEntries(s.split(',').map((kv) => kv.split('=') as [string, string])); + +/** The code and message a call is refused with, or null when it passes. */ +function refusal(run: () => void): string | null { + try { + run(); + return null; + } catch (e) { + return e instanceof KittyError ? `${e.code}:${e.message}` : String(e); + } +} + +describe('assertSupported', () => { + test.each([ + ['f=32', null], + ['f=24,t=d', null], + ['f=100', null], + ['t=f', 'EINVAL:unsupported transmission medium t=f'], + ['f=8', 'EINVAL:unsupported format f=8'], + ])('%s', (keys, expected) => { + expect(refusal(() => assertSupported(keysOf(keys)))).toBe(expected); + }); + + test('defaults to direct transmission of 32-bit pixels', () => { + expect(refusal(() => assertSupported({}))).toBeNull(); + }); +}); + +describe('transmissionSize', () => { + test('raw pixels are sized by s and v', () => { + expect(transmissionSize({ keys: keysOf('f=24,s=3,v=4'), data: '' })).toEqual({ + width: 3, + height: 4, + }); + }); + + test('a PNG is sized from its header, a compressed one not until decoded', () => { + const data = b64(pngHeader(20, 40)); + expect(transmissionSize({ keys: { f: '100' }, data })).toEqual({ width: 20, height: 40 }); + expect(transmissionSize({ keys: { f: '100', o: 'z' }, data })).toEqual({ width: 0, height: 0 }); + }); + + test('a payload that is not a PNG is refused', () => { + const run = () => transmissionSize({ keys: { f: '100' }, data: zeros(33) }); + expect(refusal(run)).toBe('EINVAL:not a PNG'); + }); +}); + +describe('assertWithinLimits', () => { + test.each([ + ['f=32', { width: 10, height: 10 }, null], + ['f=32', { width: 11, height: 10 }, 'EINVAL:image too large'], + ['f=32', { width: 0, height: 5 }, 'EINVAL:raw image needs positive integer s and v'], + ['f=24', { width: 2.5, height: 5 }, 'EINVAL:raw image needs positive integer s and v'], + ['f=100', { width: 0, height: 0 }, null], + ['f=100', { width: 20, height: 10 }, 'EINVAL:image too large'], + ])('%s %o', (keys, size, expected) => { + expect(refusal(() => assertWithinLimits(keysOf(keys), size, 100))).toBe(expected); + }); +}); + +describe('decodeFootprint', () => { + test('is the payload plus the RGBA, or the worst case when the size is unknown', () => { + expect(decodeFootprint({ width: 2, height: 3 }, 10, 100)).toBe(10 + 24); + expect(decodeFootprint({ width: 0, height: 0 }, 10, 100)).toBe(10 + 400); + }); +}); + +describe('decodeImage', () => { + test('RGBA pixels are handed over as they are', async () => { + const deps = new FakeDecoder(); + const bitmap = await decodeImage( + keysOf('f=32,s=1,v=1'), + b64(Uint8Array.of(1, 2, 3, 4)), + 100, + deps, + ); + expect(bitmap).toMatchObject({ width: 1, height: 1 }); + expect(deps.pixels).toEqual([[1, 2, 3, 4]]); + }); + + test('RGB pixels get an opaque alpha channel', async () => { + const deps = new FakeDecoder(); + await decodeImage(keysOf('f=24,s=2,v=1'), b64(Uint8Array.of(1, 2, 3, 4, 5, 6)), 100, deps); + expect(deps.pixels).toEqual([[1, 2, 3, 255, 4, 5, 6, 255]]); + }); + + test('compressed raw pixels are inflated', async () => { + const payload = b64(deflateSync(Uint8Array.of(9, 9, 9, 9))); + const bitmap = await decodeImage(keysOf('f=32,s=1,v=1,o=z'), payload, 100, new FakeDecoder()); + expect(bitmap.width).toBe(1); + }); + + test('a PNG goes to the browser as a file, compressed or not', async () => { + const deps = new FakeDecoder(); + const plain = await decodeImage({ f: '100' }, b64(pngHeader(3, 4)), 100, deps); + const zipped = await decodeImage( + { f: '100', o: 'z' }, + b64(deflateSync(pngHeader(5, 6))), + 100, + deps, + ); + expect([plain.width, plain.height, zipped.width, zipped.height]).toEqual([3, 4, 5, 6]); + }); + + test.each([ + ['too little pixel data', 'f=32,s=10,v=10', zeros(8), 'ENODATA:not enough pixel data'], + [ + 'a zlib bomb', + 'f=32,s=2,v=2,o=z', + b64(deflateSync(new Uint8Array(4096))), + 'EINVAL:decompressed data too large', + ], + [ + 'an inflated PNG over the limit', + 'f=100,o=z', + b64(deflateSync(pngHeader(11, 10))), + 'EINVAL:image too large', + ], + ['an inflated non-PNG', 'f=100,o=z', b64(deflateSync(new Uint8Array(40))), 'EINVAL:not a PNG'], + ])('%s is refused', async (_name, keys, payload, expected) => { + const decoding = decodeImage(keysOf(keys), payload, 100, new FakeDecoder()); + const error = await decoding.then( + () => null, + (e: KittyError) => `${e.code}:${e.message}`, + ); + expect(error).toBe(expected); + }); +}); + +describe('BROWSER_DECODER', () => { + const g = globalThis as { createImageBitmap?: unknown; ImageData?: unknown }; + const saved = { createImageBitmap: g.createImageBitmap, ImageData: g.ImageData }; + afterEach(() => Object.assign(g, saved)); + + test("calls the browser's own constructors when used", async () => { + g.ImageData = class { + constructor( + readonly data: Uint8ClampedArray, + readonly width: number, + readonly height: number, + ) {} + }; + g.createImageBitmap = async (source: { width: number; height: number }) => + fakeBitmap(source.width, source.height); + const data = BROWSER_DECODER.createImageData(new Uint8ClampedArray(4), 1, 1); + expect(data).toMatchObject({ width: 1, height: 1 }); + expect(await BROWSER_DECODER.createImageBitmap(data)).toMatchObject({ width: 1 }); + }); +}); diff --git a/src/client/features/kitty/graphics/image-decoder.ts b/src/client/features/kitty/graphics/image-decoder.ts new file mode 100644 index 0000000..c0187f9 --- /dev/null +++ b/src/client/features/kitty/graphics/image-decoder.ts @@ -0,0 +1,152 @@ +// Turns a kitty transmission into an ImageBitmap (service, async, ADR 032), and the checks +// that refuse one before anything is allocated: the medium, the format, the size it +// declares and the pixel limit. + +import { type KittyKeys, num } from '../protocol/apc-command'; +import { MAX_TRANSMISSION_BYTES, type Transmission } from '../protocol/chunk-assembler'; +import { pngSize, pngSizeFromBytes } from '../protocol/png-size'; +import { KittyError } from './kitty-error'; + +/** An image's size in device pixels; 0 x 0 when only the decode can tell. */ +export interface PixelSize { + width: number; + height: number; +} + +/** The browser's image constructors, injected so the decode runs without a browser. */ +export interface ImageDecoderDeps { + createImageBitmap(source: ImageBitmapSource): Promise<ImageBitmap>; + createImageData(rgba: Uint8ClampedArray<ArrayBuffer>, width: number, height: number): ImageData; +} + +/** The browser's own constructors, looked up when called. */ +export const BROWSER_DECODER: ImageDecoderDeps = { + createImageBitmap: (source) => createImageBitmap(source), + createImageData: (rgba, width, height) => new ImageData(rgba, width, height), +}; + +/** Refuses a transmission medium other than direct (`t=d`) and a format other than 24, 32 or 100. */ +export function assertSupported(keys: KittyKeys): void { + const medium = keys.t ?? 'd'; + if (medium !== 'd') throw new KittyError('EINVAL', `unsupported transmission medium t=${medium}`); + const format = num(keys, 'f', 32); + if (format !== 24 && format !== 32 && format !== 100) { + throw new KittyError('EINVAL', `unsupported format f=${format}`); + } +} + +/** Pixel size known synchronously from the command (raw `s`/`v` or PNG header). */ +export function transmissionSize(tx: Transmission): PixelSize { + if (num(tx.keys, 'f', 32) !== 100) return { width: num(tx.keys, 's'), height: num(tx.keys, 'v') }; + // A compressed PNG's size is only known once it is inflated. + if (tx.keys.o === 'z') return { width: 0, height: 0 }; + const size = pngSize(tx.data); + if (!size) throw new KittyError('EINVAL', 'not a PNG'); + return size; +} + +/** Rejects what would be too big to hold, before any allocation. */ +export function assertWithinLimits(keys: KittyKeys, size: PixelSize, maxImagePixels: number): void { + if (num(keys, 'f', 32) !== 100) { + const { width, height } = size; + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw new KittyError('EINVAL', 'raw image needs positive integer s and v'); + } + } + if (size.width * size.height > maxImagePixels) { + throw new KittyError('EINVAL', 'image too large'); + } +} + +/** + * What a transmission holds while it decodes: its encoded payload and the RGBA it will + * produce (the worst case, `maxImagePixels`, when only the decode can tell). + */ +export function decodeFootprint( + size: PixelSize, + payloadLength: number, + maxImagePixels: number, +): number { + const known = size.width && size.height; + return payloadLength + (known ? size.width * size.height : maxImagePixels) * 4; +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// The protocol's `o=z` is zlib-wrapped deflate, which the web API calls +// 'deflate'. Reads incrementally so a small payload that expands past `limit` +// is cut off instead of allocated. +async function inflate(bytes: Uint8Array, limit: number): Promise<Uint8Array> { + const reader = new Blob([bytes as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream('deflate')) + .getReader(); + const parts: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > limit) { + await reader.cancel(); + throw new KittyError('EINVAL', 'decompressed data too large'); + } + parts.push(value); + } + const out = new Uint8Array(size); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} + +/** + * Decodes a transmission's base64 payload (raw RGB or RGBA, or a PNG file, optionally + * zlib-compressed) into a bitmap. A PNG over `maxPixels` is refused once its header is + * known; raw data shorter than `s` x `v` pixels is ENODATA. + */ +export async function decodeImage( + keys: KittyKeys, + data: string, + maxPixels: number, + deps: ImageDecoderDeps = BROWSER_DECODER, +): Promise<ImageBitmap> { + const format = num(keys, 'f', 32); + const width = num(keys, 's'); + const height = num(keys, 'v'); + const bpp = format === 24 ? 3 : 4; + let bytes = base64ToBytes(data); + if (keys.o === 'z') { + // Raw pixels have an exact expected size; a PNG file is bounded by the + // image-size limit's worst case. + bytes = await inflate(bytes, format === 100 ? MAX_TRANSMISSION_BYTES : width * height * bpp); + } + if (format === 100) { + const size = pngSizeFromBytes(bytes); + if (!size) throw new KittyError('EINVAL', 'not a PNG'); + if (size.width * size.height > maxPixels) { + throw new KittyError('EINVAL', 'image too large'); + } + return deps.createImageBitmap(new Blob([bytes as BlobPart], { type: 'image/png' })); + } + if (bytes.length < width * height * bpp) throw new KittyError('ENODATA', 'not enough pixel data'); + const rgba = new Uint8ClampedArray(width * height * 4); + if (format === 32) { + rgba.set(bytes.subarray(0, width * height * 4)); + } else { + for (let s = 0, d = 0; d < rgba.length; s += 3, d += 4) { + rgba[d] = bytes[s]; + rgba[d + 1] = bytes[s + 1]; + rgba[d + 2] = bytes[s + 2]; + rgba[d + 3] = 255; + } + } + return deps.createImageBitmap(deps.createImageData(rgba, width, height)); +} diff --git a/src/client/features/kitty/graphics/image-store.test.ts b/src/client/features/kitty/graphics/image-store.test.ts new file mode 100644 index 0000000..84d8090 --- /dev/null +++ b/src/client/features/kitty/graphics/image-store.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from 'bun:test'; +import { ImageStore, imageIdOf } from './image-store'; +import { fakeBitmap } from './test-support/graphics-harness'; + +/** A store with generous limits unless overridden, recording what it drops. */ +function store(limits: { maxImages?: number; maxTotalBytes?: number } = {}) { + const dropped: number[] = []; + const images = new ImageStore({ maxImages: 256, maxTotalBytes: 1 << 20, ...limits }, (id) => + dropped.push(id), + ); + return { images, dropped }; +} + +/** Opens `id` and installs a decoded `w` x `h` bitmap into it. */ +function add(images: ImageStore, id: number, w = 2, h = 2) { + const { slot, seq } = images.open(id, { width: w, height: h }); + const bitmap = fakeBitmap(w, h); + images.install(id, slot, seq, bitmap); + return bitmap; +} + +describe('imageIdOf', () => { + test.each([ + [{ i: '7' }, 7], + [{ I: '3' }, 0x40000003], + [{ i: '7', I: '3' }, 7], + [{}, 0], + ])('%o names image %d', (keys, id) => { + expect(imageIdOf(keys)).toBe(id); + }); +}); + +describe('ImageStore', () => { + test('anonymous ids count down from -1', () => { + const { images } = store(); + expect([images.nextAnonymousId(), images.nextAnonymousId()]).toEqual([-1, -2]); + }); + + test('open creates a slot with the transmission size and numbers its transmissions', () => { + const { images } = store(); + expect(images.open(5, { width: 3, height: 4 }).seq).toBe(1); + const again = images.open(5, { width: 6, height: 8 }); + expect(again.seq).toBe(2); + expect(images.get(5)).toMatchObject({ width: 6, height: 8, latest: 2, applied: 0 }); + expect(images.bitmapOf(5)).toBeUndefined(); + }); + + describe('eviction order', () => { + test('past maxImages the oldest goes first, and its owner is told', () => { + const { images, dropped } = store({ maxImages: 2 }); + const first = add(images, 1); + add(images, 2); + add(images, 3); + expect(images.ids()).toEqual([2, 3]); + expect(dropped).toEqual([1]); + expect(first.closed).toBe(true); + }); + + test('re-transmitting an id makes it the newest, so it outlives older ones', () => { + const { images, dropped } = store({ maxImages: 2 }); + add(images, 1); + add(images, 2); + images.open(1, { width: 2, height: 2 }); + add(images, 3); + expect(images.ids()).toEqual([1, 3]); + expect(dropped).toEqual([2]); + }); + + test('the byte budget drops oldest first and keeps the newest', () => { + const { images, dropped } = store({ maxTotalBytes: 20 }); + for (const id of [1, 2, 3]) add(images, id); + expect(images.ids()).toEqual([3]); + expect(dropped).toEqual([1, 2]); + }); + + test('enforceBudget(false, extra) may drop every image and says whether it then fits', () => { + const { images } = store({ maxTotalBytes: 40 }); + add(images, 1); + add(images, 2); + expect(images.enforceBudget(false, 16)).toBe(true); + expect(images.ids()).toEqual([2]); + expect(images.enforceBudget(false, 100)).toBe(false); + expect(images.size).toBe(0); + }); + + test('in-flight bytes count against the budget until released', () => { + const { images } = store({ maxTotalBytes: 100 }); + images.holdInFlight(90); + expect(images.enforceBudget(false, 20)).toBe(false); + images.releaseInFlight(90); + expect(images.enforceBudget(false, 20)).toBe(true); + }); + }); + + describe('install', () => { + test('a newer bitmap replaces the older one and takes its size and bytes', () => { + const { images } = store({ maxTotalBytes: 64 }); + const old = add(images, 1, 2, 2); + const fresh = add(images, 1, 4, 4); + expect(old.closed).toBe(true); + expect(images.bitmapOf(1)).toBe(fresh); + expect(images.get(1)).toMatchObject({ width: 4, height: 4, bytes: 64 }); + // 64 bytes held, and the newest is kept: one more 2x2 does not fit beside it + expect(images.enforceBudget(true, 16)).toBe(false); + }); + + test('an older transmission finishing late is superseded and closed', () => { + const { images } = store(); + const first = images.open(1, { width: 1, height: 1 }); + const second = images.open(1, { width: 2, height: 2 }); + expect(images.install(1, second.slot, second.seq, fakeBitmap(2, 2))).toBe('installed'); + const late = fakeBitmap(1, 1); + expect(images.install(1, first.slot, first.seq, late)).toBe('superseded'); + expect(late.closed).toBe(true); + expect(images.bitmapOf(1)?.width).toBe(2); + }); + + test('an older transmission finishing first is shown but keeps the newer size', () => { + const { images } = store(); + const first = images.open(1, { width: 1, height: 1 }); + images.open(1, { width: 2, height: 2 }); + expect(images.install(1, first.slot, first.seq, fakeBitmap(1, 1))).toBe('installed'); + expect(images.get(1)).toMatchObject({ width: 2, height: 2, applied: 1 }); + }); + + test('a bitmap for a slot dropped meanwhile is closed and reported removed', () => { + const { images } = store(); + const { slot, seq } = images.open(1, { width: 1, height: 1 }); + images.drop(1); + const bitmap = fakeBitmap(1, 1); + expect(images.install(1, slot, seq, bitmap)).toBe('removed'); + expect(bitmap.closed).toBe(true); + expect(images.isNewest(1, slot, seq)).toBe(false); + }); + + test('isNewest is true only for the latest transmission into a held slot', () => { + const { images } = store(); + const first = images.open(1, { width: 1, height: 1 }); + expect(images.isNewest(1, first.slot, first.seq)).toBe(true); + images.open(1, { width: 1, height: 1 }); + expect(images.isNewest(1, first.slot, first.seq)).toBe(false); + }); + }); + + describe('drop', () => { + test('an unknown id is ignored', () => { + const { images, dropped } = store(); + images.drop(9); + expect(dropped).toEqual([]); + }); + + test('dropAll frees every bitmap, oldest first, and returns the bytes', () => { + const { images, dropped } = store({ maxTotalBytes: 32 }); + const bitmaps = [add(images, 1), add(images, 2)]; + images.dropAll(); + expect(dropped).toEqual([1, 2]); + expect(bitmaps.every((b) => b.closed)).toBe(true); + expect(images.enforceBudget(false, 32)).toBe(true); + }); + }); +}); diff --git a/src/client/features/kitty/graphics/image-store.ts b/src/client/features/kitty/graphics/image-store.ts new file mode 100644 index 0000000..d2fd223 --- /dev/null +++ b/src/client/features/kitty/graphics/image-store.ts @@ -0,0 +1,181 @@ +// The images a PTY program has transmitted, by id (object, ADR 032): their decoded +// bitmaps, oldest first, and the memory they and the decodes still running hold, kept +// within the image count and byte budget by dropping the oldest. + +import { type KittyKeys, num } from '../protocol/apc-command'; +import type { KittyLimits } from './kitty-limits'; + +// Kitty image numbers (`I=`) are per-client handles the terminal maps to ids. +// Give them ids far above anything a client picks itself. +const IMAGE_NUMBER_BASE = 0x40000000; + +/** The image id a command names with `i=` or `I=`, or 0 when it names none. */ +export function imageIdOf(keys: KittyKeys): number { + if (keys.i !== undefined) return num(keys, 'i'); + if (keys.I !== undefined) return IMAGE_NUMBER_BASE + num(keys, 'I'); + return 0; +} + +/** One image's slot; it outlives the transmissions that replace its bitmap. */ +export interface StoredImage { + bitmap?: ImageBitmap; + // Pixel size known when the transmission arrived (raw `s`/`v`, or an + // uncompressed PNG header), 0 when only the decode will tell. Lets a + // placement be recorded, and the cursor moved, before decoding finishes. + width: number; + height: number; + // Decoded size in bytes, counted against `maxTotalBytes`. + bytes: number; + // Decodes can finish out of order when frames arrive faster than they + // decode. A bitmap never replaces one from a newer transmission, but an + // older one that finishes first is shown until the newer is ready: dropping + // it would leave a stream of frames blank whenever decoding lags behind. + latest: number; + applied: number; +} + +/** What `install` did with a finished decode's bitmap. */ +export type InstallResult = 'installed' | 'superseded' | 'removed'; + +/** + * Images by id in recency order (a Map keeps insertion order, and a re-transmitted id is + * re-inserted). Every drop goes through `drop`, which tells the owner so the image's + * placements go with it. + */ +export class ImageStore { + private readonly images = new Map<number, StoredImage>(); + private totalBytes = 0; + // Encoded payloads and RGBA output of decodes that have not finished, which + // hold memory just as stored images do and count against the same budget. + private inFlightBytes = 0; + private anonymousId = 0; + + constructor( + private readonly limits: Pick<KittyLimits, 'maxImages' | 'maxTotalBytes'>, + /** Called after an image is dropped, for any reason. */ + private readonly onDrop: (id: number) => void, + ) {} + + /** Number of images held, decoded or still decoding. */ + get size(): number { + return this.images.size; + } + + /** Image ids, oldest first. */ + ids(): number[] { + return [...this.images.keys()]; + } + + /** The slot for `id`; a transmitted image exists while its decode is still running. */ + get(id: number): StoredImage | undefined { + return this.images.get(id); + } + + /** The bitmap to draw for `id`, once a decode of it has finished. */ + bitmapOf(id: number): ImageBitmap | undefined { + return this.images.get(id)?.bitmap; + } + + /** A fresh id for a transmission that names none, counting down so it never meets a client's. */ + nextAnonymousId(): number { + return --this.anonymousId; + } + + /** + * Starts a transmission into `id`: its slot (kept if it exists, so an older bitmap stays + * on screen) becomes the newest, takes the transmission's size, and the oldest images + * past `maxImages` are evicted. Returns the slot and the transmission's sequence number. + */ + open(id: number, size: { width: number; height: number }): { slot: StoredImage; seq: number } { + const slot = this.images.get(id) ?? { width: 0, height: 0, bytes: 0, latest: 0, applied: 0 }; + this.images.delete(id); // re-insert so the Map stays ordered by recency + this.images.set(id, slot); + // The size of this transmission, not of any bitmap still held from the + // previous one: placements are sized from it. + slot.width = size.width; + slot.height = size.height; + this.evict(); + return { slot, seq: ++slot.latest }; + } + + /** + * Hands a finished decode's bitmap to its slot. It is closed instead when the slot was + * removed meanwhile, or when a newer transmission's bitmap is already shown. + */ + install(id: number, slot: StoredImage, seq: number, bitmap: ImageBitmap): InstallResult { + if (this.images.get(id) !== slot) { + bitmap.close(); + return 'removed'; + } + if (seq <= slot.applied) { + bitmap.close(); + return 'superseded'; + } + slot.bitmap?.close(); + slot.bitmap = bitmap; + slot.applied = seq; + // For the newest transmission the decode is what tells a compressed + // PNG's size, which a later a=p is placed from. + if (seq === slot.latest) { + slot.width = bitmap.width; + slot.height = bitmap.height; + } + const bytes = bitmap.width * bitmap.height * 4; + this.totalBytes += bytes - slot.bytes; + slot.bytes = bytes; + this.enforceBudget(); + return 'installed'; + } + + /** Whether transmission `seq` is still the newest one into a slot that is still held. */ + isNewest(id: number, slot: StoredImage, seq: number): boolean { + return this.images.get(id) === slot && seq === slot.latest; + } + + /** Counts a running decode's memory against the budget until `releaseInFlight`. */ + holdInFlight(bytes: number): void { + this.inFlightBytes += bytes; + } + + /** Releases what `holdInFlight` counted, when the decode settles. */ + releaseInFlight(bytes: number): void { + this.inFlightBytes -= bytes; + } + + /** Frees one image's bitmap and memory; the owner drops its placements. */ + drop(id: number): void { + const slot = this.images.get(id); + if (!slot) return; + slot.bitmap?.close(); + this.totalBytes -= slot.bytes; + this.images.delete(id); + this.onDrop(id); + } + + /** Frees every image. */ + dropAll(): void { + for (const id of [...this.images.keys()]) this.drop(id); + } + + /** + * Drops the oldest images until stored plus in-flight bytes, and `extra` + * more, fit the budget; the most recent transmission is kept unless + * `protectNewest` is false. Returns whether it fits. + */ + enforceBudget(protectNewest = true, extra = 0): boolean { + const fits = () => this.totalBytes + this.inFlightBytes + extra <= this.limits.maxTotalBytes; + const ids = [...this.images.keys()]; + if (protectNewest) ids.pop(); + for (const id of ids) { + if (fits()) return true; + this.drop(id); + } + return fits(); + } + + private evict(): void { + while (this.images.size > this.limits.maxImages) { + this.drop(this.images.keys().next().value as number); + } + } +} diff --git a/src/client/features/kitty/graphics/kitty-error.ts b/src/client/features/kitty/graphics/kitty-error.ts new file mode 100644 index 0000000..8bdd848 --- /dev/null +++ b/src/client/features/kitty/graphics/kitty-error.ts @@ -0,0 +1,12 @@ +// The error a kitty graphics command is refused with (object, ADR 032): a protocol code +// such as EINVAL or ENOENT and the message the reply carries after it. + +/** A refusal the reply reports as `<code>:<message>`; any other error is answered EBADF. */ +export class KittyError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} diff --git a/src/client/features/kitty/graphics/kitty-graphics.test.ts b/src/client/features/kitty/graphics/kitty-graphics.test.ts index 6eed4bd..c2918df 100644 --- a/src/client/features/kitty/graphics/kitty-graphics.test.ts +++ b/src/client/features/kitty/graphics/kitty-graphics.test.ts @@ -1,294 +1,189 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, test } from 'bun:test'; import { deflateSync } from 'node:zlib'; import { deviceCellSize } from '../protocol/cell-geometry'; -import { KittyGraphics, type Limits } from './kitty-graphics'; - -// KittyGraphics needs a DOM, a canvas and image decoding. These tests stub just -// enough of them to drive its state handling: what is placed, what is answered, -// and what is freed. Drawing is exercised only as far as the overlay's size. - -const ESC = '\x1b'; -const apc = (keys: string, payload = ''): string => `${ESC}_G${keys};${payload}${ESC}\\`; -const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64'); -const zeros = (n: number): string => b64(new Uint8Array(n)); - -// A PNG file is only inspected for its IHDR before the decode is handed to the -// (stubbed) browser, so a header is enough. -function pngHeader(width: number, height: number): Uint8Array { - const out = new Uint8Array(33); - out.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); - new DataView(out.buffer).setUint32(16, width); - new DataView(out.buffer).setUint32(20, height); - return out; -} - -interface FakeBitmap { - width: number; - height: number; - closed: boolean; - close(): void; -} - -let bitmaps: FakeBitmap[] = []; -let listeners = new Map<string, () => void>(); -let visibility = 'visible'; -// The overlay canvas the last KittyGraphics created, and its frame callback. -let overlay: { width: number; height: number } | null = null; -let frame: (() => void) | null = null; -let gate: Promise<void> | null = null; -// Gates for the next decodes, in the order they start: lets a test finish them in any order. -let gates: Array<Promise<void>> = []; -const hold = (): (() => void) => { - let release = () => {}; - gates.push( - new Promise<void>((r) => { - release = r; - }), - ); - return release; -}; -const saved: Record<string, unknown> = {}; - -function makeBitmap(width: number, height: number): FakeBitmap { - const bitmap: FakeBitmap = { - width, - height, - closed: false, - close() { - this.closed = true; - }, - }; - bitmaps.push(bitmap); - return bitmap; -} - -beforeEach(() => { - bitmaps = []; - gate = null; - gates = []; - listeners = new Map(); - visibility = 'visible'; - const g = globalThis as Record<string, unknown>; - for (const key of [ - 'document', - 'window', - 'requestAnimationFrame', - 'createImageBitmap', - 'ImageData', - ]) { - saved[key] = g[key]; - } - g.document = { - addEventListener: (type: string, cb: () => void) => listeners.set(type, cb), - get visibilityState() { - return visibility; - }, - // A new canvas is 300x150, as in a browser - createElement: () => { - const ctx = { setTransform() {}, clearRect() {}, drawImage() {} }; - overlay = { dataset: {}, style: {}, width: 300, height: 150, getContext: () => ctx } as never; - return overlay; - }, - }; - g.window = { devicePixelRatio: 1 }; - frame = null; - g.requestAnimationFrame = (cb: () => void) => { - frame = cb; - return 0; - }; - g.ImageData = class { - constructor( - readonly data: Uint8ClampedArray, - readonly width: number, - readonly height: number, - ) {} - }; - g.createImageBitmap = async (src: { width?: number; height?: number } | Blob) => { - if (gate) await gate; - const own = gates.shift(); - if (own) await own; - if (src instanceof Blob) { - const head = new Uint8Array(await src.arrayBuffer()); - const view = new DataView(head.buffer, head.byteOffset); - return makeBitmap(view.getUint32(16), view.getUint32(20)); - } - return makeBitmap(src.width ?? 0, src.height ?? 0); - }; -}); - -afterEach(() => { - const g = globalThis as Record<string, unknown>; - for (const [key, value] of Object.entries(saved)) g[key] = value; -}); - -const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 20)); - -interface Harness { - g: KittyGraphics; - written: string[]; - sent: string[]; - cursor: { x: number; y: number }; - placements: () => Map<string, unknown>; - images: () => Map<number, { bitmap?: FakeBitmap }>; - dirty: () => boolean; - clearDirty: () => void; -} - -function harness( - limits: Partial<Limits> = {}, - cell: { width: number; height: number } = { width: 10, height: 20 }, - canvas: object | null = null, -): Harness { - const written: string[] = []; - const sent: string[] = []; - const cursor = { x: 2, y: 3 }; - const term = { - cols: 80, - rows: 24, - viewportY: 0, - write: (s: string) => written.push(s), - buffer: { - active: { - get cursorX() { - return cursor.x; - }, - get cursorY() { - return cursor.y; - }, - }, - }, - wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0 }, - renderer: { getMetrics: () => cell }, - }; - const container = { appendChild: () => {}, querySelector: () => canvas }; - const g = new KittyGraphics(term as never, container as never, (d) => sent.push(d), limits); - const internals = g as unknown as { - placements: Map<string, unknown>; - images: Map<number, { bitmap?: FakeBitmap }>; - dirty: boolean; - }; - return { - g, - written, - sent, - cursor, - placements: () => internals.placements, - images: () => internals.images, - dirty: () => internals.dirty, - clearDirty: () => { - internals.dirty = false; - }, - }; -} +import { + apc, + b64, + closeHarnesses, + ESC, + flush, + graphicsHarness, + pngHeader, + zeros, +} from './test-support/graphics-harness'; + +// KittyGraphics against a fake terminal, a fake decoder and a happy-dom container: what is +// placed, what is answered, what is freed, and when the overlay repaints. The parts have +// their own tests; these pin the protocol behaviour the facade composes from them. + +afterEach(closeHarnesses); describe('a=t followed by a=p', () => { test('in the same chunk keeps the placement instead of answering ENOENT', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=t,f=32,s=20,v=40,i=1', zeros(20 * 40 * 4)) + apc('a=p,i=1')); - expect(h.placements().size).toBe(1); + expect(h.g.placements.size).toBe(1); expect(h.sent.join('')).not.toContain('ENOENT'); await flush(); - expect(h.images().get(1)?.bitmap).toBeDefined(); + expect(h.g.images.get(1)?.bitmap).toBeDefined(); // one OK for the placement, one for the transmission once it decoded expect(h.sent.filter((r) => r === `${ESC}_Gi=1;OK${ESC}\\`)).toHaveLength(2); }); test('moves the cursor from the size known before the decode finishes', () => { - const h = harness(); + const h = graphicsHarness(); // 20x40 px at 10x20 px cells = 2 columns, 2 rows, from cursor column 2 h.g.feed(apc('a=t,f=32,s=20,v=40,i=1', zeros(20 * 40 * 4)) + apc('a=p,i=1')); expect(h.written).toEqual([`\n${ESC}[5G`]); }); test('a=p for an id never transmitted is ENOENT', () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=p,i=9')); expect(h.sent).toEqual([`${ESC}_Gi=9;ENOENT:no such image${ESC}\\`]); - expect(h.placements().size).toBe(0); + expect(h.g.placements.size).toBe(0); }); test('q=1 silences the placement acknowledgement', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4)) + apc('a=p,i=1,q=1')); await flush(); expect(h.sent).toEqual([`${ESC}_Gi=1;OK${ESC}\\`]); // the transmission's, not the placement's }); }); +describe('a=q', () => { + test('answers OK for what it supports and an error for what it does not, storing nothing', () => { + const h = graphicsHarness(); + h.g.feed(apc('a=q,i=31,f=24,s=1,v=1', zeros(3)) + apc('a=q,i=32,t=f', zeros(3))); + expect(h.sent).toEqual([ + `${ESC}_Gi=31;OK${ESC}\\`, + `${ESC}_Gi=32;EINVAL:unsupported transmission medium t=f${ESC}\\`, + ]); + expect(h.g.images.size).toBe(0); + }); +}); + +// A repaint with no placement left empties the overlay (width 0), so a frame after a +// removal shows whether the removal asked for one. describe('a=d', () => { test('removes placements, repaints, and sends no reply', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); await flush(); + h.clock.frame(); + expect(h.overlay.width).toBe(800); h.sent.length = 0; - h.clearDirty(); h.g.feed(apc('a=d,d=i,i=1')); - expect(h.placements().size).toBe(0); - expect(h.dirty()).toBe(true); + expect(h.g.placements.size).toBe(0); + h.clock.frame(); + expect(h.overlay.width).toBe(0); expect(h.sent).toEqual([]); }); + + test('d=a removes every placement and keeps the images; d=A drops the images too', async () => { + const h = graphicsHarness(); + h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4)) + apc('a=T,f=32,s=1,v=1,i=2,C=1', zeros(4))); + await flush(); + h.g.feed(apc('a=d')); + expect(h.g.placements.size).toBe(0); + expect(h.g.images.ids()).toEqual([1, 2]); + h.g.feed(apc('a=p,i=1,C=1') + apc('a=d,d=A')); + expect(h.g.placements.size).toBe(0); + expect(h.g.images.size).toBe(0); + }); + + test('d=i with p= removes only that placement; an unknown target removes nothing', async () => { + const h = graphicsHarness(); + h.g.feed( + apc('a=t,f=32,s=1,v=1,i=1', zeros(4)) + apc('a=p,i=1,p=1,C=1') + apc('a=p,i=1,p=2,C=1'), + ); + await flush(); + h.g.feed(apc('a=d,d=i,i=1,p=1')); + expect(h.g.placements.keys()).toEqual(['1:2']); + h.g.feed(apc('a=d,d=z')); + expect(h.g.placements.keys()).toEqual(['1:2']); + }); }); describe('overlay invalidation', () => { - test('replacing an image marks the overlay dirty even though it only removes placements', async () => { - const h = harness(); + test('replacing an image repaints the overlay even though it only removes placements', async () => { + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); await flush(); - h.clearDirty(); + h.clock.frame(); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4))); - expect(h.placements().size).toBe(0); - expect(h.dirty()).toBe(true); + expect(h.g.placements.size).toBe(0); + h.clock.frame(); + expect(h.overlay.width).toBe(0); }); test('a failed decode removes what it placed, drops the image, and repaints', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=10,v=10,i=1,C=1', zeros(8))); // far too little pixel data - expect(h.placements().size).toBe(1); - h.clearDirty(); - await flush(); - expect(h.placements().size).toBe(0); - expect(h.images().size).toBe(0); - expect(h.dirty()).toBe(true); + expect(h.g.placements.size).toBe(1); + h.clock.frame(); + expect(h.overlay.width).toBe(800); + await flush(); + expect(h.g.placements.size).toBe(0); + expect(h.g.images.size).toBe(0); + h.clock.frame(); + expect(h.overlay.width).toBe(0); expect(h.sent).toEqual([`${ESC}_Gi=1;ENODATA:not enough pixel data${ESC}\\`]); }); - test('a clear repaints even when no placement is left to remove', () => { - const h = harness(); - h.clearDirty(); + test('a clear in the output drops the placements and repaints', async () => { + const h = graphicsHarness(); + h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); + await flush(); + h.clock.frame(); h.g.feed(`${ESC}[2J`); - expect(h.dirty()).toBe(true); + h.clock.frame(); + expect(h.overlay.width).toBe(0); + }); + + test('a decoded bitmap is drawn on the next frame, and invalidate() forces a redraw', async () => { + const h = graphicsHarness(); + h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); + h.clock.frame(); + expect(h.counts.drawn).toBe(0); + await flush(); + h.clock.frame(); + expect(h.counts.drawn).toBe(1); + h.clock.frame(); + expect(h.counts.repaints).toBe(2); + h.g.invalidate(); + h.clock.frame(); + expect(h.counts.repaints).toBe(3); }); test('a failed re-transmission keeps the previous bitmap for a=p to re-place', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); await flush(); h.g.feed(apc('a=T,f=32,s=10,v=10,i=1,C=1', zeros(8))); await flush(); - expect(h.placements().size).toBe(0); - expect(h.images().get(1)?.bitmap).toBeDefined(); + expect(h.g.placements.size).toBe(0); + expect(h.g.images.get(1)?.bitmap).toBeDefined(); }); }); describe('reconnect', () => { test('a decode that finishes after reset() neither installs its bitmap nor replies', async () => { - const h = harness(); - let release = () => {}; - gate = new Promise<void>((r) => { - release = r; - }); + const h = graphicsHarness(); + const release = h.decoder.hold(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); h.g.reset(); release(); await flush(); - expect(h.images().size).toBe(0); - expect(h.placements().size).toBe(0); + expect(h.g.images.size).toBe(0); + expect(h.g.placements.size).toBe(0); expect(h.sent).toEqual([]); - expect(bitmaps.every((b) => b.closed)).toBe(true); + expect(h.decoder.bitmaps.every((b) => b.closed)).toBe(true); }); test('a decode error after reset() does not reply into the new stream either', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=10,v=10,i=1,C=1', zeros(8))); h.g.reset(); await flush(); @@ -296,11 +191,11 @@ describe('reconnect', () => { }); test('images from the old stream are freed and cannot be placed by the new one', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4))); await flush(); h.sent.length = 0; - const old = bitmaps[0]; + const old = h.decoder.bitmaps[0]; h.g.reset(); expect(old.closed).toBe(true); h.g.feed(apc('a=p,i=1')); @@ -308,84 +203,84 @@ describe('reconnect', () => { }); test('the new stream may reuse an image id', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); h.g.reset(); h.g.feed(apc('a=T,f=32,s=2,v=2,i=1,C=1', zeros(16))); await flush(); - expect(h.images().get(1)?.bitmap).toMatchObject({ width: 2, height: 2, closed: false }); + expect(h.g.images.get(1)?.bitmap).toMatchObject({ width: 2, height: 2, closed: false }); }); }); describe('memory limits', () => { test('raw dimensions over the pixel limit are rejected before anything is decoded', () => { - const h = harness({ maxImagePixels: 100 }); + const h = graphicsHarness({ maxImagePixels: 100 }); h.g.feed(apc('a=T,f=32,s=11,v=10,i=1,q=0', zeros(4))); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:image too large${ESC}\\`]); - expect(h.images().size).toBe(0); - expect(h.placements().size).toBe(0); + expect(h.g.images.size).toBe(0); + expect(h.g.placements.size).toBe(0); expect(h.written).toEqual([]); }); test('a PNG header over the limit is rejected up front', () => { - const h = harness({ maxImagePixels: 100 }); + const h = graphicsHarness({ maxImagePixels: 100 }); h.g.feed(apc('a=t,f=100,i=1', b64(pngHeader(11, 10)))); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:image too large${ESC}\\`]); }); test('a compressed PNG that inflates to a too-large image is rejected after inflating', async () => { - const h = harness({ maxImagePixels: 100 }); + const h = graphicsHarness({ maxImagePixels: 100 }); h.g.feed(apc('a=t,f=100,o=z,i=1', b64(deflateSync(pngHeader(11, 10))))); await flush(); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:image too large${ESC}\\`]); - expect(h.images().size).toBe(0); + expect(h.g.images.size).toBe(0); }); test('a small zlib payload that expands past the expected size is cut off', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=t,f=32,s=2,v=2,o=z,i=1', b64(deflateSync(new Uint8Array(4 * 1024 * 1024))))); await flush(); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:decompressed data too large${ESC}\\`]); - expect(h.images().size).toBe(0); + expect(h.g.images.size).toBe(0); }); test.each(['s=0,v=5', 's=-3,v=5', 's=2.5,v=5', 's=5'])( 'invalid raw size %s is rejected', (dims) => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc(`a=t,f=32,${dims},i=1`, zeros(64))); expect(h.sent).toHaveLength(1); expect(h.sent[0]).toContain('EINVAL'); - expect(h.images().size).toBe(0); + expect(h.g.images.size).toBe(0); }, ); test('the total decoded size is a budget: the oldest images go first, the newest stays', async () => { // each 2x2 image is 16 bytes decoded and, while decoding, holds 40 (16 of // RGBA plus its 24-character payload): room for two stored plus one decoding - const h = harness({ maxTotalBytes: 60 }); + const h = graphicsHarness({ maxTotalBytes: 60 }); for (const id of [1, 2, 3]) { h.g.feed(apc(`a=T,f=32,s=2,v=2,i=${id},C=1`, zeros(16))); await flush(); } - expect([...h.images().keys()]).toEqual([2, 3]); - expect(bitmaps[0].closed).toBe(true); - expect(bitmaps[2].closed).toBe(false); - expect([...h.placements().keys()]).toEqual(['2:0', '3:0']); + expect(h.g.images.ids()).toEqual([2, 3]); + expect(h.decoder.bitmaps[0].closed).toBe(true); + expect(h.decoder.bitmaps[2].closed).toBe(false); + expect(h.g.placements.keys()).toEqual(['2:0', '3:0']); }); test('the image count is capped and eviction removes the placements too', async () => { - const h = harness({ maxImages: 2 }); + const h = graphicsHarness({ maxImages: 2 }); for (const id of [1, 2, 3]) { h.g.feed(apc(`a=T,f=32,s=1,v=1,i=${id},C=1`, zeros(4))); await flush(); } - expect([...h.images().keys()]).toEqual([2, 3]); - expect([...h.placements().keys()]).toEqual(['2:0', '3:0']); + expect(h.g.images.ids()).toEqual([2, 3]); + expect(h.g.placements.keys()).toEqual(['2:0', '3:0']); }); test('a chunked transmission over the size cap gets one error and its later chunks are ignored', async () => { - const h = harness({ maxTransmissionBytes: 100 }); + const h = graphicsHarness({ maxTransmissionBytes: 100 }); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1,m=1', 'A'.repeat(40))); h.g.feed(apc('m=1', 'A'.repeat(70))); // 110 > 100: dropped h.g.feed(apc('m=1', 'AAAA')); @@ -393,44 +288,44 @@ describe('memory limits', () => { h.g.feed(apc('m=0', 'AA')); await flush(); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:transmission too large${ESC}\\`]); - expect(h.images().size).toBe(0); + expect(h.g.images.size).toBe(0); // and nothing that follows is affected h.g.feed(apc('a=t,f=32,s=1,v=1,i=2', zeros(4))); await flush(); - expect(h.images().get(2)?.bitmap).toBeDefined(); + expect(h.g.images.get(2)?.bitmap).toBeDefined(); }); }); describe('compressed PNG', () => { test('a=T without C=1 is rejected: the cursor cannot be moved without the size', () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=100,o=z,i=1', b64(deflateSync(pngHeader(20, 40))))); expect(h.sent).toHaveLength(1); expect(h.sent[0]).toContain('EINVAL'); expect(h.sent[0]).toContain('C=1'); - expect(h.images().size).toBe(0); - expect(h.placements().size).toBe(0); + expect(h.g.images.size).toBe(0); + expect(h.g.placements.size).toBe(0); expect(h.written).toEqual([]); }); test('a=T with C=1 is placed and decoded', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=100,o=z,i=1,C=1', b64(deflateSync(pngHeader(20, 40))))); - expect(h.placements().size).toBe(1); + expect(h.g.placements.size).toBe(1); await flush(); - expect(h.images().get(1)?.bitmap).toMatchObject({ width: 20, height: 40 }); + expect(h.g.images.get(1)?.bitmap).toMatchObject({ width: 20, height: 40 }); }); test('a=t alone needs no size', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=t,f=100,o=z,i=1', b64(deflateSync(pngHeader(20, 40))))); await flush(); - expect(h.images().get(1)?.bitmap).toBeDefined(); + expect(h.g.images.get(1)?.bitmap).toBeDefined(); expect(h.sent).toEqual([`${ESC}_Gi=1;OK${ESC}\\`]); }); test('a=p of it works once the decode has finished, using the decoded size', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=t,f=100,o=z,i=1', b64(deflateSync(pngHeader(20, 40))))); await flush(); h.written.length = 0; @@ -441,8 +336,8 @@ describe('compressed PNG', () => { describe('source rectangle and display scale', () => { test('w/h are converted like the image before the cursor is moved', () => { - (globalThis as { window: { devicePixelRatio: number } }).window.devicePixelRatio = 2; - const h = harness(); + const h = graphicsHarness(); + h.win.devicePixelRatio = 2; // cells are 10x20 CSS px = 20x40 device px. A 20x40 device-px region is one // cell; treating w/h as CSS px would advance two columns and two rows. h.g.feed(apc('a=T,f=32,s=100,v=100,w=20,h=40,i=1', zeros(100 * 100 * 4))); @@ -450,14 +345,14 @@ describe('source rectangle and display scale', () => { }); test('without a source rectangle the whole image is used', () => { - (globalThis as { window: { devicePixelRatio: number } }).window.devicePixelRatio = 2; - const h = harness(); + const h = graphicsHarness(); + h.win.devicePixelRatio = 2; h.g.feed(apc('a=T,f=32,s=40,v=80,i=1', zeros(40 * 80 * 4))); expect(h.written).toEqual([`\n${ESC}[5G`]); }); test('x/y shrink the default region', () => { - const h = harness(); + const h = graphicsHarness(); // 30x60 image, origin at 10,20: the visible 20x40 is 2 columns and 2 rows h.g.feed(apc('a=T,f=32,s=30,v=60,x=10,y=20,i=1', zeros(30 * 60 * 4))); expect(h.written).toEqual([`\n${ESC}[5G`]); @@ -466,18 +361,18 @@ describe('source rectangle and display scale', () => { describe('sequences split across chunks', () => { test('a clear split over two feeds drops the placements', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); await flush(); - expect(h.placements().size).toBe(1); + expect(h.g.placements.size).toBe(1); h.g.feed(`text${ESC}[`); - expect(h.placements().size).toBe(1); + expect(h.g.placements.size).toBe(1); h.g.feed('2Jmore'); - expect(h.placements().size).toBe(0); + expect(h.g.placements.size).toBe(0); }); test('the text is still written to the terminal in the same pieces', () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(`a${ESC}[`); h.g.feed('2Jb'); expect(h.written.join('')).toBe(`a${ESC}[2Jb`); @@ -486,7 +381,7 @@ describe('sequences split across chunks', () => { describe('size queries', () => { test('are left to Ghostty, which answers them (ADR 042, ADR 047)', () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(`${ESC}[14t${ESC}[16t${ESC}[18t`); expect(h.sent).toEqual([]); expect(h.written.join('')).toBe(`${ESC}[14t${ESC}[16t${ESC}[18t`); @@ -508,8 +403,8 @@ describe('cursor movement at scaled display', () => { ])( '$cols x $rows cells of $cell.width x $cell.height at dpr $dpr', ({ dpr, cell, cols, rows }) => { - (globalThis as { window: { devicePixelRatio: number } }).window.devicePixelRatio = dpr; - const h = harness({}, cell); + const h = graphicsHarness({}, cell); + h.win.devicePixelRatio = dpr; const w = cols * deviceCellSize(cell.width, dpr); const hgt = rows * deviceCellSize(cell.height, dpr); h.g.feed(apc(`a=T,f=32,s=${w},v=${hgt},i=1`, zeros(w * hgt * 4))); @@ -521,15 +416,15 @@ describe('cursor movement at scaled display', () => { describe('an oversized sequence', () => { test('is answered with an error and none of its payload reaches the terminal', () => { - const h = harness({ maxTransmissionBytes: 200 }); + const h = graphicsHarness({ maxTransmissionBytes: 200 }); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', 'A'.repeat(300))); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:transmission too large${ESC}\\`]); expect(h.written).toEqual([]); - expect(h.images().size).toBe(0); + expect(h.g.images.size).toBe(0); }); test('the rest of a chunked transmission it started is ignored, with one error', () => { - const h = harness({ maxTransmissionBytes: 200 }); + const h = graphicsHarness({ maxTransmissionBytes: 200 }); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1,m=1', 'A'.repeat(300))); // the first chunk is itself too big h.g.feed(apc('m=1', 'AAAA')); expect(h.sent).toEqual([]); @@ -538,7 +433,7 @@ describe('an oversized sequence', () => { }); test('one that is never terminated is ended by CAN, and text resumes', () => { - const h = harness({ maxTransmissionBytes: 100 }); + const h = graphicsHarness({ maxTransmissionBytes: 100 }); h.g.feed(`${ESC}_Ga=t,i=1;${'A'.repeat(300)}`); h.g.feed('still payload'); expect(h.written).toEqual([]); @@ -550,25 +445,25 @@ describe('an oversized sequence', () => { describe('decodes in flight', () => { test('count against the memory budget, so a flood of transmissions is refused', async () => { // each 2x2 image holds 40 while decoding: 16 of RGBA and a 24-character payload - const h = harness({ maxTotalBytes: 100 }); - const release = [hold(), hold()]; + const h = graphicsHarness({ maxTotalBytes: 100 }); + const release = [h.decoder.hold(), h.decoder.hold()]; h.g.feed(apc('a=t,f=32,s=2,v=2,i=1', zeros(16)) + apc('a=t,f=32,s=2,v=2,i=2', zeros(16))); h.g.feed(apc('a=t,f=32,s=2,v=2,i=3', zeros(16))); expect(h.sent).toEqual([`${ESC}_Gi=3;EINVAL:too many images being decoded${ESC}\\`]); - expect(h.images().has(3)).toBe(false); + expect(h.g.images.get(3)).toBeUndefined(); for (const r of release) r(); await flush(); h.sent.length = 0; // the reservations were released, so there is room again h.g.feed(apc('a=t,f=32,s=2,v=2,i=4', zeros(16))); await flush(); - expect(h.images().get(4)?.bitmap).toBeDefined(); + expect(h.g.images.get(4)?.bitmap).toBeDefined(); }); test('a compressed PNG, whose size is unknown until decoded, reserves the worst case', async () => { // worst case: 100 pixels of RGBA (400 bytes) plus the payload - const h = harness({ maxImagePixels: 100, maxTotalBytes: 500 }); - const release = hold(); + const h = graphicsHarness({ maxImagePixels: 100, maxTotalBytes: 500 }); + const release = h.decoder.hold(); h.g.feed(apc('a=t,f=100,o=z,i=1', b64(deflateSync(pngHeader(2, 2))))); h.g.feed(apc('a=t,f=100,o=z,i=2', b64(deflateSync(pngHeader(2, 2))))); expect(h.sent).toEqual([`${ESC}_Gi=2;EINVAL:too many images being decoded${ESC}\\`]); @@ -577,7 +472,7 @@ describe('decodes in flight', () => { }); test('a failed decode releases its reservation', async () => { - const h = harness({ maxTotalBytes: 500 }); + const h = graphicsHarness({ maxTotalBytes: 500 }); const tooLittle = () => h.g.feed(apc('a=t,f=32,s=10,v=10,i=1', zeros(8))); // reserves 412 tooLittle(); await flush(); @@ -588,19 +483,19 @@ describe('decodes in flight', () => { }); test('stored images are dropped, oldest first, to make room for a new one', async () => { - const h = harness({ maxTotalBytes: 60 }); + const h = graphicsHarness({ maxTotalBytes: 60 }); for (const id of [1, 2, 3]) { h.g.feed(apc(`a=t,f=32,s=2,v=2,i=${id}`, zeros(16))); await flush(); } - expect([...h.images().keys()]).toEqual([2, 3]); + expect(h.g.images.ids()).toEqual([2, 3]); }); }); describe('every transmission is answered', () => { test('one removed while it decodes gets an error instead of silence', async () => { - const h = harness({ maxImages: 1 }); - const releases = [hold(), hold()]; + const h = graphicsHarness({ maxImages: 1 }); + const releases = [h.decoder.hold(), h.decoder.hold()]; h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4))); h.g.feed(apc('a=t,f=32,s=1,v=1,i=2', zeros(4))); // evicts image 1 while it is still decoding for (const r of releases) r(); @@ -609,13 +504,13 @@ describe('every transmission is answered', () => { `${ESC}_Gi=1;EINVAL:image was removed before it finished decoding${ESC}\\`, ); expect(h.sent).toContain(`${ESC}_Gi=2;OK${ESC}\\`); - expect(h.images().has(1)).toBe(false); - expect(bitmaps[0].closed).toBe(true); + expect(h.g.images.get(1)).toBeUndefined(); + expect(h.decoder.bitmaps[0].closed).toBe(true); }); test('one deleted while it decodes gets the same error', async () => { - const h = harness(); - const release = hold(); + const h = graphicsHarness(); + const release = h.decoder.hold(); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4))); h.g.feed(apc('a=d,d=I,i=1')); release(); @@ -626,40 +521,40 @@ describe('every transmission is answered', () => { }); test('one superseded by a newer transmission that already finished still gets its OK', async () => { - const h = harness(); - const releaseOld = hold(); - const releaseNew = hold(); + const h = graphicsHarness(); + const releaseOld = h.decoder.hold(); + const releaseNew = h.decoder.hold(); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4))); h.g.feed(apc('a=t,f=32,s=2,v=2,i=1', zeros(16))); // replaces it releaseNew(); await flush(); - expect(h.images().get(1)?.bitmap).toMatchObject({ width: 2 }); + expect(h.g.images.get(1)?.bitmap).toMatchObject({ width: 2 }); releaseOld(); await flush(); expect(h.sent).toEqual([`${ESC}_Gi=1;OK${ESC}\\`, `${ESC}_Gi=1;OK${ESC}\\`]); - expect(bitmaps.find((b) => b.width === 1)?.closed).toBe(true); - expect(h.images().get(1)?.bitmap).toMatchObject({ width: 2, closed: false }); + expect(h.decoder.bitmaps.find((b) => b.width === 1)?.closed).toBe(true); + expect(h.g.images.get(1)?.bitmap).toMatchObject({ width: 2, closed: false }); }); test('an older transmission that finishes first is shown until the newer one is ready', async () => { - const h = harness(); - const releaseOld = hold(); - const releaseNew = hold(); + const h = graphicsHarness(); + const releaseOld = h.decoder.hold(); + const releaseNew = h.decoder.hold(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); h.g.feed(apc('a=T,f=32,s=2,v=2,i=1,C=1', zeros(16))); releaseOld(); await flush(); // a stream of frames must not be blank while decoding lags behind it - expect(h.images().get(1)?.bitmap).toMatchObject({ width: 1 }); + expect(h.g.images.get(1)?.bitmap).toMatchObject({ width: 1 }); releaseNew(); await flush(); - expect(h.images().get(1)?.bitmap).toMatchObject({ width: 2 }); - expect(bitmaps[0].closed).toBe(true); + expect(h.g.images.get(1)?.bitmap).toMatchObject({ width: 2 }); + expect(h.decoder.bitmaps[0].closed).toBe(true); }); test('a stream reset answers nothing for decodes that were running', async () => { - const h = harness(); - const release = hold(); + const h = graphicsHarness(); + const release = h.decoder.hold(); h.g.feed(apc('a=t,f=32,s=1,v=1,i=1', zeros(4))); h.g.reset(); release(); @@ -670,10 +565,10 @@ describe('every transmission is answered', () => { describe('placements are sized from the current transmission', () => { test('a=p of a compressed PNG replacing a decoded image does not borrow the old bitmap size', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=20,v=40,i=1,C=1', zeros(20 * 40 * 4))); await flush(); - const release = hold(); + const release = h.decoder.hold(); h.g.feed(apc('a=t,f=100,o=z,i=1', b64(deflateSync(pngHeader(20, 40))))); h.sent.length = 0; h.written.length = 0; @@ -686,10 +581,10 @@ describe('placements are sized from the current transmission', () => { }); test('a=p of a resized image moves the cursor by the new size, not the old bitmap', async () => { - const h = harness(); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=10,v=20,i=1,C=1', zeros(10 * 20 * 4))); // one cell await flush(); - const release = hold(); + const release = h.decoder.hold(); h.g.feed(apc('a=t,f=32,s=40,v=40,i=1', zeros(40 * 40 * 4))); // 4 columns, 2 rows h.written.length = 0; h.g.feed(apc('a=p,i=1')); @@ -699,83 +594,50 @@ describe('placements are sized from the current transmission', () => { }); }); -describe('eviction order', () => { - test('enforcing the budget keeps the newest transmission, whichever decode finished last', async () => { - const h = harness(); - for (const id of [1, 2, 3]) { - h.g.feed(apc(`a=t,f=32,s=2,v=2,i=${id}`, zeros(16))); - await flush(); - } - const internals = h.g as unknown as { limits: Limits; enforceBudget(): boolean }; - internals.limits.maxTotalBytes = 20; - internals.enforceBudget(); - expect([...h.images().keys()]).toEqual([3]); - }); -}); - -describe('overlay repaint when the tab is shown again', () => { - test('a tab coming back to the foreground repaints the overlay', () => { - const h = harness(); - h.clearDirty(); - visibility = 'hidden'; - listeners.get('visibilitychange')?.(); - expect(h.dirty()).toBe(false); - visibility = 'visible'; - listeners.get('visibilitychange')?.(); - expect(h.dirty()).toBe(true); - }); - - test('invalidate() forces a repaint even when nothing about the geometry changed', () => { - const h = harness(); - (h.g as unknown as { lastSignature: string }).lastSignature = 'same'; - h.clearDirty(); - h.g.invalidate(); - expect(h.dirty()).toBe(true); - expect((h.g as unknown as { lastSignature: string }).lastSignature).toBe(''); - }); -}); - describe('overlay backing store', () => { - // The terminal's canvas, counting the layout reads the tick makes of it. - function terminalCanvas(): { reads: number } { - const box = { reads: 0, offsetLeft: 0, offsetTop: 0 }; - for (const [key, value] of [ - ['offsetWidth', 800], - ['offsetHeight', 600], - ] as const) { - Object.defineProperty(box, key, { - get: () => { - box.reads++; - return value; - }, - }); - } - return box; - } - test('a new overlay is empty and reads no layout while there is no image', () => { - const canvas = terminalCanvas(); - harness({}, undefined, canvas); - expect(overlay).toMatchObject({ width: 0, height: 0 }); - frame?.(); - frame?.(); - expect(canvas.reads).toBe(0); - expect(overlay).toMatchObject({ width: 0, height: 0 }); + const h = graphicsHarness(); + expect(h.overlay).toMatchObject({ width: 0, height: 0 }); + h.clock.frame(); + h.clock.frame(); + expect(h.counts.reads).toBe(0); + expect(h.overlay).toMatchObject({ width: 0, height: 0 }); }); test('the overlay grows for an image and shrinks back to empty when the last one goes', async () => { - const canvas = terminalCanvas(); - const h = harness({}, undefined, canvas); + const h = graphicsHarness(); h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); await flush(); - frame?.(); - expect(overlay).toMatchObject({ width: 800, height: 600 }); + h.clock.frame(); + expect(h.overlay).toMatchObject({ width: 800, height: 600 }); h.g.feed(apc('a=d,d=I,i=1')); - frame?.(); - expect(overlay).toMatchObject({ width: 0, height: 0 }); - canvas.reads = 0; - frame?.(); - expect(canvas.reads).toBe(0); + h.clock.frame(); + expect(h.overlay).toMatchObject({ width: 0, height: 0 }); + h.counts.reads = 0; + h.clock.frame(); + expect(h.counts.reads).toBe(0); + }); +}); + +describe('dispose', () => { + test('frees the images, removes the overlay, stops the frames and silences running decodes', async () => { + const h = graphicsHarness(); + h.g.feed(apc('a=T,f=32,s=1,v=1,i=1,C=1', zeros(4))); + await flush(); + const release = h.decoder.hold(); + h.g.feed(apc('a=t,f=32,s=1,v=1,i=2', zeros(4))); + h.sent.length = 0; + h.g.dispose(); + expect(h.g.images.size).toBe(0); + expect(h.g.placements.size).toBe(0); + expect(h.decoder.bitmaps[0].closed).toBe(true); + expect(h.overlay.isConnected).toBe(false); + expect(h.clock.pending.frames).toBe(0); + release(); + await flush(); + expect(h.sent).toEqual([]); + h.g.dispose(); + expect(h.clock.pending.frames).toBe(0); }); }); @@ -785,13 +647,13 @@ describe('a header that only looks like a PNG', () => { ['IHDR length', (b: Uint8Array) => (b[11] = 12)], ['IHDR type', (b: Uint8Array) => (b[15] = 0x58)], ])('with a bad %s is refused before the cursor moves', (_name, corrupt) => { - const h = harness(); + const h = graphicsHarness(); const bad = pngHeader(20, 40); corrupt(bad); h.g.feed(apc('a=T,f=100,i=1', b64(bad))); expect(h.sent).toEqual([`${ESC}_Gi=1;EINVAL:not a PNG${ESC}\\`]); expect(h.written).toEqual([]); - expect(h.placements().size).toBe(0); - expect(h.images().size).toBe(0); + expect(h.g.placements.size).toBe(0); + expect(h.g.images.size).toBe(0); }); }); diff --git a/src/client/features/kitty/graphics/kitty-graphics.ts b/src/client/features/kitty/graphics/kitty-graphics.ts index 9bb0457..f939f2a 100644 --- a/src/client/features/kitty/graphics/kitty-graphics.ts +++ b/src/client/features/kitty/graphics/kitty-graphics.ts @@ -1,225 +1,89 @@ -import type { Terminal } from '../../../vendor/ghostty-web'; +// Kitty graphics protocol on top of ghostty-web, whose WASM core discards the APC +// sequences (facade, ADR 032): it routes each command to the image store, the decoder and +// the placement table, answers it, and owns the overlay they are drawn on. + import { type KittyKeys, num, parseKittyApc } from '../protocol/apc-command'; import { ApcSplitter } from '../protocol/apc-splitter'; -import { axisScale, cellSpan } from '../protocol/cell-geometry'; -import { - ChunkAssembler, - MAX_TRANSMISSION_BYTES, - type Transmission, -} from '../protocol/chunk-assembler'; +import { ChunkAssembler, type Transmission } from '../protocol/chunk-assembler'; import { ControlScanner } from '../protocol/control-scanner'; import { kittyReply } from '../protocol/kitty-reply'; -import { pngSize, pngSizeFromBytes } from '../protocol/png-size'; - -// Sizes Ghostty reports to programs (CSI 14t/16t) are device pixels, as in kitty: -// programs such as terminal-browser divide them by the display's -// scale factor to get CSS pixels, so reporting CSS pixels makes their content -// render at the wrong size. On a 2x display this means 4x the pixels per -// full-window frame. Cell sizes are whole device pixels (see protocol/cell-geometry.ts), so the -// CSS-to-device scale is per axis. See ADR 032. -interface Scale { - x: number; - y: number; -} - -// Bounds on what a PTY program can make the tab hold. Raw dimensions drive the -// RGBA allocation and a small zlib payload can expand enormously, so limits are -// checked before anything is allocated or decoded, and again on what a decode -// produces. A 32M-pixel image is 128 MiB as RGBA; full-window frames on large -// HiDPI displays are around 15M pixels. -export interface Limits { - /** Images kept, whatever their size. */ - maxImages: number; - /** Pixels in one image. */ - maxImagePixels: number; - /** Decoded bytes across all images kept. */ - maxTotalBytes: number; - /** Encoded bytes in one transmission, however many chunks it is sent in. */ - maxTransmissionBytes: number; -} - -const DEFAULT_LIMITS: Limits = { - maxImages: 256, - maxImagePixels: 32 * 1024 * 1024, - maxTotalBytes: 512 * 1024 * 1024, - maxTransmissionBytes: MAX_TRANSMISSION_BYTES, -}; - -// Kitty image numbers (`I=`) are per-client handles the terminal maps to ids. -// Give them ids far above anything a client picks itself. -const IMAGE_NUMBER_BASE = 0x40000000; - -interface StoredImage { - bitmap?: ImageBitmap; - // Pixel size known when the transmission arrived (raw `s`/`v`, or an - // uncompressed PNG header), 0 when only the decode will tell. Lets a - // placement be recorded, and the cursor moved, before decoding finishes. - width: number; - height: number; - // Decoded size in bytes, counted against `maxTotalBytes`. - bytes: number; - // Decodes can finish out of order when frames arrive faster than they - // decode. A bitmap never replaces one from a newer transmission, but an - // older one that finishes first is shown until the newer is ready: dropping - // it would leave a stream of frames blank whenever decoding lags behind. - latest: number; - applied: number; -} - -interface Placement { - imageId: number; - placementId: number; - /** Row in absolute buffer coordinates: scrollback length + viewport row. */ - absRow: number; - col: number; - cols?: number; - rows?: number; - srcX: number; - srcY: number; - srcW: number; - srcH: number; - offX: number; - offY: number; - /** Device pixels per CSS pixel when placed; the image was sized for this. */ - scale: Scale; -} - -class KittyError extends Error { - constructor( - readonly code: string, - message: string, - ) { - super(message); - } -} - -function base64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -// The protocol's `o=z` is zlib-wrapped deflate, which the web API calls -// 'deflate'. Reads incrementally so a small payload that expands past `limit` -// is cut off instead of allocated. -async function inflate(bytes: Uint8Array, limit: number): Promise<Uint8Array> { - const reader = new Blob([bytes as BlobPart]) - .stream() - .pipeThrough(new DecompressionStream('deflate')) - .getReader(); - const parts: Uint8Array[] = []; - let size = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.length; - if (size > limit) { - await reader.cancel(); - throw new KittyError('EINVAL', 'decompressed data too large'); - } - parts.push(value); - } - const out = new Uint8Array(size); - let at = 0; - for (const part of parts) { - out.set(part, at); - at += part.length; - } - return out; +import { + assertSupported, + assertWithinLimits, + BROWSER_DECODER, + decodeFootprint, + decodeImage, + type ImageDecoderDeps, + transmissionSize, +} from './image-decoder'; +import { ImageStore, imageIdOf, type StoredImage } from './image-store'; +import { KittyError } from './kitty-error'; +import { DEFAULT_LIMITS, type KittyLimits } from './kitty-limits'; +import { cellSize, deviceScale, type KittyTerminal, scrollbackLength } from './kitty-terminal'; +import { OverlayCanvas, type OverlayDeps, type OverlaySource } from './overlay-canvas'; +import { assertCursorMovable, cursorAdvance, placementAt } from './placement-geometry'; +import { type Placement, Placements } from './placements'; + +/** The browser objects the overlay and the decoder use, injected so tests run on fakes. */ +export interface KittyGraphicsDeps extends OverlayDeps { + decoder: ImageDecoderDeps; } -async function decode(keys: KittyKeys, data: string, maxPixels: number): Promise<ImageBitmap> { - const format = num(keys, 'f', 32); - const width = num(keys, 's'); - const height = num(keys, 'v'); - const bpp = format === 24 ? 3 : 4; - let bytes = base64ToBytes(data); - if (keys.o === 'z') { - // Raw pixels have an exact expected size; a PNG file is bounded by the - // image-size limit's worst case. - bytes = await inflate(bytes, format === 100 ? MAX_TRANSMISSION_BYTES : width * height * bpp); - } - if (format === 100) { - const size = pngSizeFromBytes(bytes); - if (!size) throw new KittyError('EINVAL', 'not a PNG'); - if (size.width * size.height > maxPixels) { - throw new KittyError('EINVAL', 'image too large'); - } - return createImageBitmap(new Blob([bytes as BlobPart], { type: 'image/png' })); - } - if (bytes.length < width * height * bpp) throw new KittyError('ENODATA', 'not enough pixel data'); - const rgba = new Uint8ClampedArray(width * height * 4); - if (format === 32) { - rgba.set(bytes.subarray(0, width * height * 4)); - } else { - for (let s = 0, d = 0; d < rgba.length; s += 3, d += 4) { - rgba[d] = bytes[s]; - rgba[d + 1] = bytes[s + 1]; - rgba[d + 2] = bytes[s + 2]; - rgba[d + 3] = 255; - } - } - return createImageBitmap(new ImageData(rgba, width, height)); +/** One transmission whose decode is running, and what its result is checked against. */ +interface PendingDecode { + keys: KittyKeys; + id: number; + /** Whether the command named the id; an anonymous image is answered with id 0. */ + explicit: boolean; + slot: StoredImage; + seq: number; + generation: number; + /** Bytes held against the budget until the decode settles. */ + reserved: number; } /** - * Kitty graphics protocol on top of ghostty-web, whose WASM core discards the - * APC sequences. `feed()` replaces `term.write()` for PTY output: it strips the - * graphics commands, hands the rest to the terminal, and draws images on an - * overlay canvas. See ADR 032. + * `feed()` replaces `term.write()` for PTY output: it strips the graphics commands, hands + * the rest to the terminal, and draws images on an overlay canvas. See ADR 032. */ -export class KittyGraphics { +export class KittyGraphics implements OverlaySource { + /** Transmitted images; the overlay and tests read it, only this class changes it. */ + readonly images: ImageStore; + /** Placed images; the overlay and tests read it, only this class changes it. */ + readonly placements = new Placements(); private splitter: ApcSplitter; private assembler: ChunkAssembler; private scanner = new ControlScanner(); - private images = new Map<number, StoredImage>(); - private totalBytes = 0; - // Encoded payloads and RGBA output of decodes that have not finished, which - // hold memory just as stored images do and count against the same budget. - private inFlightBytes = 0; - private placements = new Map<string, Placement>(); - private anonymousId = 0; // Bumped by reset(). Decodes that started under an older value are stale: // their result belongs to a PTY stream that is gone. private generation = 0; - private limits: Limits; - private overlay: HTMLCanvasElement; - private dirty = false; - private lastSignature = ''; + private readonly limits: KittyLimits; + private readonly overlay: OverlayCanvas; constructor( - private term: Terminal, - private container: HTMLElement, + private readonly term: KittyTerminal, + container: HTMLElement, /** Sends bytes to the PTY as if typed: replies to queries go here. */ - private send: (data: string) => void, - limits: Partial<Limits> = {}, + private readonly send: (data: string) => void, + limits: Partial<KittyLimits> = {}, + private readonly deps: KittyGraphicsDeps = { + document: container.ownerDocument, + window, + decoder: BROWSER_DECODER, + }, ) { this.limits = { ...DEFAULT_LIMITS, ...limits }; + this.images = new ImageStore(this.limits, (id) => + this.removePlacements((p) => p.imageId === id), + ); this.splitter = new ApcSplitter(this.limits.maxTransmissionBytes); this.assembler = new ChunkAssembler(this.limits.maxTransmissionBytes); - this.overlay = document.createElement('canvas'); - this.overlay.dataset.kitty = ''; - // A new canvas is 300x150, which the tick reads as something to draw. At - // 0x0 the overlay holds no backing store and costs no layout read per - // frame until an image is placed. - this.overlay.width = 0; - this.overlay.height = 0; - this.overlay.style.cssText = 'position:absolute;pointer-events:none;'; - container.appendChild(this.overlay); - requestAnimationFrame(this.tick); - // Chromium can discard a canvas's contents while its tab is hidden. The - // overlay's geometry does not change when the tab comes back, so nothing - // else would make it repaint. - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') this.invalidate(); - }); + this.overlay = new OverlayCanvas(term, container, this, deps); } /** Repaint the overlay on the next frame, whatever has or has not changed. */ invalidate(): void { - this.dirty = true; - this.lastSignature = ''; + this.overlay.invalidate(); } /** @@ -234,7 +98,7 @@ export class KittyGraphics { this.splitter = new ApcSplitter(this.limits.maxTransmissionBytes); this.assembler = new ChunkAssembler(this.limits.maxTransmissionBytes); this.scanner = new ControlScanner(); - this.dropImages(); + this.images.dropAll(); this.clear(); } @@ -246,6 +110,25 @@ export class KittyGraphics { } } + /** + * Placements no longer match what is on screen. Image data is kept: clients may re-place + * it. Also called when the user clears the screen (ADR 054). + */ + clear(): void { + this.placements.clear(); + // Even when the table is already empty the overlay may still hold pixels + // from placements removed without a repaint. + this.overlay.markDirty(); + } + + /** Frees every image, stops answering running decodes and removes the overlay. Idempotent. */ + dispose(): void { + this.generation++; + this.images.dropAll(); + this.placements.clear(); + this.overlay.dispose(); + } + private writeText(text: string): void { this.term.write(text); // Size queries (CSI 14/16/18 t) are answered by Ghostty (OPT_SIZE). See ADR 042 @@ -274,82 +157,40 @@ export class KittyGraphics { } } - private imageIdOf(keys: KittyKeys): number { - if (keys.i !== undefined) return num(keys, 'i'); - if (keys.I !== undefined) return IMAGE_NUMBER_BASE + num(keys, 'I'); - return 0; - } - // A transmitted image counts as existing while its decode is still running: // programs send `a=t` and `a=p` back to back, and the placement simply waits // for the bitmap. private requireImage(keys: KittyKeys): number { - const id = this.imageIdOf(keys); - if (!this.images.has(id)) throw new KittyError('ENOENT', 'no such image'); + const id = imageIdOf(keys); + if (!this.images.get(id)) throw new KittyError('ENOENT', 'no such image'); return id; } - private assertSupported(keys: KittyKeys): void { - const medium = keys.t ?? 'd'; - if (medium !== 'd') - throw new KittyError('EINVAL', `unsupported transmission medium t=${medium}`); - const format = num(keys, 'f', 32); - if (format !== 24 && format !== 32 && format !== 100) { - throw new KittyError('EINVAL', `unsupported format f=${format}`); - } - } - private query(tx: Transmission): void { - this.assertSupported(tx.keys); - this.reply(tx.keys, this.imageIdOf(tx.keys)); - } - - /** Rejects what would be too big to hold, before any allocation. */ - private assertWithinLimits(keys: KittyKeys, size: { width: number; height: number }): void { - if (num(keys, 'f', 32) !== 100) { - const { width, height } = size; - if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { - throw new KittyError('EINVAL', 'raw image needs positive integer s and v'); - } - } - if (size.width * size.height > this.limits.maxImagePixels) { - throw new KittyError('EINVAL', 'image too large'); - } + assertSupported(tx.keys); + this.reply(tx.keys, imageIdOf(tx.keys)); } private transmit(tx: Transmission, display: boolean): void { - this.assertSupported(tx.keys); - const size = this.dimensions(tx); - this.assertWithinLimits(tx.keys, size); + assertSupported(tx.keys); + const size = transmissionSize(tx); + assertWithinLimits(tx.keys, size, this.limits.maxImagePixels); // Only a decode can tell a compressed PNG's size, and the cursor has to // move at this exact point in the stream, so it cannot be moved for one. - if (display) this.assertCursorMovable(tx.keys, size); + if (display) assertCursorMovable(tx.keys, size); - // What this transmission holds while it decodes: its encoded payload and the - // RGBA it will produce (the worst case when only the decode can tell). - // Stored images make room for it; if it still does not fit, it is refused. - const known = size.width && size.height; - const reserved = - tx.data.length + (known ? size.width * size.height : this.limits.maxImagePixels) * 4; - if (!this.enforceBudget(false, reserved)) { + // Stored images make room for what the decode holds; if it still does not fit, it is refused. + const reserved = decodeFootprint(size, tx.data.length, this.limits.maxImagePixels); + if (!this.images.enforceBudget(false, reserved)) { throw new KittyError('EINVAL', 'too many images being decoded'); } - const explicit = this.imageIdOf(tx.keys); - const id = explicit || --this.anonymousId; - const gen = this.generation; - + const explicit = imageIdOf(tx.keys); + const id = explicit || this.images.nextAnonymousId(); + const generation = this.generation; // Re-sending an id replaces the image and drops its old placements. this.removePlacements((p) => p.imageId === id); - const slot = this.images.get(id) ?? { width: 0, height: 0, bytes: 0, latest: 0, applied: 0 }; - this.images.delete(id); // re-insert so the Map stays ordered by recency - this.images.set(id, slot); - // The size of this transmission, not of any bitmap still held from the - // previous one: placements are sized from it. - slot.width = size.width; - slot.height = size.height; - this.evict(); - const seq = ++slot.latest; + const { slot, seq } = this.images.open(id, size); // Placement is recorded now, before the decode finishes; the draw simply // waits for the bitmap. @@ -357,111 +198,67 @@ export class KittyGraphics { // Every path below ends the transmission with a reply, except when the // stream it belongs to has been reset: nobody is waiting for it any more. - this.inFlightBytes += reserved; - decode(tx.keys, tx.data, this.limits.maxImagePixels).then( - (bitmap) => { - this.inFlightBytes -= reserved; - if (gen !== this.generation) { - bitmap.close(); - return; - } - if (this.images.get(id) !== slot) { - bitmap.close(); - this.replyError( - tx.keys, - new KittyError('EINVAL', 'image was removed before it finished decoding'), - ); - return; - } - if (seq <= slot.applied) { - // A newer transmission is already showing. This one was valid. - bitmap.close(); - this.reply(tx.keys, explicit ? id : 0); - return; - } - slot.bitmap?.close(); - slot.bitmap = bitmap; - slot.applied = seq; - // For the newest transmission the decode is what tells a compressed - // PNG's size, which a later a=p is placed from. - if (seq === slot.latest) { - slot.width = bitmap.width; - slot.height = bitmap.height; - } - const bytes = bitmap.width * bitmap.height * 4; - this.totalBytes += bytes - slot.bytes; - slot.bytes = bytes; - this.enforceBudget(); - this.dirty = true; - this.reply(tx.keys, explicit ? id : 0); - }, - (e) => { - this.inFlightBytes -= reserved; - if (gen !== this.generation) return; - // The newest transmission failed: nothing it placed can be drawn, and - // an image that never decoded is not worth keeping. - if (this.images.get(id) === slot && seq === slot.latest) { - this.removePlacements((p) => p.imageId === id); - if (!slot.bitmap) this.dropImage(id); - } - this.replyError(tx.keys, e); - }, + this.images.holdInFlight(reserved); + const pending: PendingDecode = { + keys: tx.keys, + id, + explicit: explicit !== 0, + slot, + seq, + generation, + reserved, + }; + decodeImage(tx.keys, tx.data, this.limits.maxImagePixels, this.deps.decoder).then( + (bitmap) => this.decoded(pending, bitmap), + (e) => this.decodeFailed(pending, e), ); } - /** Pixel size known synchronously from the command (raw `s`/`v` or PNG header). */ - private dimensions(tx: Transmission): { width: number; height: number } { - if (num(tx.keys, 'f', 32) !== 100) - return { width: num(tx.keys, 's'), height: num(tx.keys, 'v') }; - // A compressed PNG's size is only known once it is inflated. - if (tx.keys.o === 'z') return { width: 0, height: 0 }; - const size = pngSize(tx.data); - if (!size) throw new KittyError('EINVAL', 'not a PNG'); - return size; - } - - private assertCursorMovable(keys: KittyKeys, size: { width: number; height: number }): void { - if (num(keys, 'C') === 1 || (size.width && size.height)) return; - throw new KittyError('EINVAL', 'size of a compressed PNG is unknown until decoded: use C=1'); + private decoded(d: PendingDecode, bitmap: ImageBitmap): void { + this.images.releaseInFlight(d.reserved); + if (d.generation !== this.generation) { + bitmap.close(); + return; + } + const result = this.images.install(d.id, d.slot, d.seq, bitmap); + if (result === 'removed') { + this.replyError( + d.keys, + new KittyError('EINVAL', 'image was removed before it finished decoding'), + ); + return; + } + // 'superseded': a newer transmission is already showing. This one was valid. + if (result === 'installed') this.overlay.markDirty(); + this.reply(d.keys, d.explicit ? d.id : 0); + } + + private decodeFailed(d: PendingDecode, e: unknown): void { + this.images.releaseInFlight(d.reserved); + if (d.generation !== this.generation) return; + // The newest transmission failed: nothing it placed can be drawn, and + // an image that never decoded is not worth keeping. + if (this.images.isNewest(d.id, d.slot, d.seq)) { + this.removePlacements((p) => p.imageId === d.id); + if (!d.slot.bitmap) this.images.drop(d.id); + } + this.replyError(d.keys, e); } private place(keys: KittyKeys, imageId: number): void { // Sized from the slot, which describes the latest transmission; a bitmap // can still be the previous transmission's while this one decodes. const image = this.images.get(imageId) ?? { width: 0, height: 0 }; - this.assertCursorMovable(keys, image); + assertCursorMovable(keys, image); const buf = this.term.buffer.active; - const cell = this.cell(); - const scale = this.scale(); - const p: Placement = { - imageId, - placementId: num(keys, 'p'), - absRow: this.scrollbackLength() + buf.cursorY, - col: buf.cursorX, - srcX: num(keys, 'x'), - srcY: num(keys, 'y'), - srcW: num(keys, 'w'), - srcH: num(keys, 'h'), - offX: num(keys, 'X'), - offY: num(keys, 'Y'), - scale, - }; - if (keys.c !== undefined) p.cols = num(keys, 'c'); - if (keys.r !== undefined) p.rows = num(keys, 'r'); - this.placements.set(`${imageId}:${p.placementId}`, p); - this.dirty = true; - - // Default C=0: the cursor ends past the image (last row, right of the - // last column). The WASM cannot know an image took space, so move it. - if (num(keys, 'C') === 1) return; - // The shown region is the source rectangle when given; `w` / `h` are - // device pixels like the image, so they convert with the same scale. - const region = { - width: (p.srcW || Math.max(1, image.width - p.srcX)) / scale.x, - height: (p.srcH || Math.max(1, image.height - p.srcY)) / scale.y, - }; - const span = cellSpan(keys, region, cell.width, cell.height); - this.term.write(`${'\n'.repeat(span.rows - 1)}\x1b[${p.col + span.cols + 1}G`); + const cell = cellSize(this.term); + const scale = deviceScale(cell, this.deps.window.devicePixelRatio); + const at = { absRow: scrollbackLength(this.term) + buf.cursorY, col: buf.cursorX }; + const p = placementAt(keys, imageId, at, scale); + this.placements.add(p); + this.overlay.markDirty(); + const advance = cursorAdvance(keys, p, image, cell); + if (advance !== null) this.term.write(advance); } // The protocol defines no success reply for a delete, so none is sent. @@ -469,72 +266,22 @@ export class KittyGraphics { const target = keys.d ?? 'a'; if (target === 'a' || target === 'A') { this.removePlacements(() => true); - if (target === 'A') this.dropImages(); + if (target === 'A') this.images.dropAll(); } else if (target === 'i' || target === 'I') { - const id = this.imageIdOf(keys); + const id = imageIdOf(keys); const pid = keys.p !== undefined ? num(keys, 'p') : undefined; this.removePlacements( (p) => p.imageId === id && (pid === undefined || p.placementId === pid), ); - if (target === 'I') this.dropImage(id); + if (target === 'I') this.images.drop(id); } } // Every path that removes a placement goes through here so the overlay is - // repainted: a placement gone from the map but still painted would stay on + // repainted: a placement gone from the table but still painted would stay on // screen until something unrelated redraws. private removePlacements(match: (p: Placement) => boolean): void { - for (const [key, p] of this.placements) { - if (!match(p)) continue; - this.placements.delete(key); - this.dirty = true; - } - } - - private dropImage(id: number): void { - const slot = this.images.get(id); - if (!slot) return; - slot.bitmap?.close(); - this.totalBytes -= slot.bytes; - this.images.delete(id); - this.removePlacements((p) => p.imageId === id); - } - - private dropImages(): void { - for (const id of [...this.images.keys()]) this.dropImage(id); - } - - /** - * Placements no longer match what is on screen. Image data is kept: clients may re-place - * it. Also called when the user clears the screen (ADR 054). - */ - clear(): void { - this.placements.clear(); - // Even when the map is already empty the overlay may still hold pixels - // from placements removed without a repaint. - this.dirty = true; - } - - private evict(): void { - while (this.images.size > this.limits.maxImages) { - this.dropImage(this.images.keys().next().value as number); - } - } - - /** - * Drops the oldest images until stored plus in-flight bytes, and `extra` - * more, fit the budget; the most recent transmission is kept unless - * `protectNewest` is false. Returns whether it fits. - */ - private enforceBudget(protectNewest = true, extra = 0): boolean { - const fits = () => this.totalBytes + this.inFlightBytes + extra <= this.limits.maxTotalBytes; - const ids = [...this.images.keys()]; - if (protectNewest) ids.pop(); - for (const id of ids) { - if (fits()) return true; - this.dropImage(id); - } - return fits(); + if (this.placements.removeWhere(match)) this.overlay.markDirty(); } private reply(keys: KittyKeys, id: number): void { @@ -545,114 +292,7 @@ export class KittyGraphics { private replyError(keys: KittyKeys, e: unknown): void { const code = e instanceof KittyError ? e.code : 'EBADF'; const message = e instanceof Error ? e.message : 'decode failed'; - const r = kittyReply(keys, this.imageIdOf(keys), { code, message }); + const r = kittyReply(keys, imageIdOf(keys), { code, message }); if (r) this.send(r); } - - // ---- geometry and drawing -------------------------------------------- - - private scale(): Scale { - const cell = this.cell(); - const dpr = window.devicePixelRatio || 1; - return { x: axisScale(cell.width, dpr), y: axisScale(cell.height, dpr) }; - } - - private cell(): { width: number; height: number } { - const m = this.term.renderer?.getMetrics(); - return { width: m?.width ?? 8, height: m?.height ?? 16 }; - } - - /** The alternate screen has no scrollback, so rows there are viewport rows. */ - private scrollbackLength(): number { - const wasm = this.term.wasmTerm; - if (!wasm || wasm.isAlternateScreen()) return 0; - return wasm.getScrollbackLength(); - } - - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-kitty]):not([data-edge])'); - } - - /** The pixel offset of pixel-smooth scrolling (ADR 040, ADR 043). */ - private scrollOffset(): number { - return this.term.renderer?.scrollOffset ?? 0; - } - - private tick = (): void => { - requestAnimationFrame(this.tick); - if (this.placements.size === 0 && !this.overlay.width) return; - const canvas = this.terminalCanvas(); - if (!canvas) return; - const cell = this.cell(); - const signature = [ - this.scrollbackLength(), - this.term.viewportY, - this.scrollOffset(), - cell.width, - cell.height, - canvas.offsetLeft, - canvas.offsetTop, - canvas.offsetWidth, - canvas.offsetHeight, - window.devicePixelRatio, - ].join('|'); - if (!this.dirty && signature === this.lastSignature) return; - this.dirty = false; - this.lastSignature = signature; - this.draw(canvas, cell); - }; - - private draw(canvas: HTMLCanvasElement, cell: { width: number; height: number }): void { - const dpr = window.devicePixelRatio || 1; - const w = canvas.offsetWidth; - const h = canvas.offsetHeight; - const o = this.overlay; - if (this.placements.size === 0) { - // Nothing left to draw: free the backing store, which at full size is - // the size of the terminal's canvas, and let the tick go idle. - o.width = 0; - o.height = 0; - return; - } - if (o.width !== Math.round(w * dpr) || o.height !== Math.round(h * dpr)) { - o.width = Math.round(w * dpr); - o.height = Math.round(h * dpr); - } - o.style.left = `${canvas.offsetLeft}px`; - o.style.top = `${canvas.offsetTop}px`; - o.style.width = `${w}px`; - o.style.height = `${h}px`; - - const ctx = o.getContext('2d'); - if (!ctx) return; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); - - const topRow = this.scrollbackLength() - this.term.viewportY; - // Viewport row 0 starts the scroll offset below the canvas top (ADR 043) - const offset = this.scrollOffset(); - for (const p of this.placements.values()) { - const img = this.images.get(p.imageId)?.bitmap; - if (!img) continue; - const sx = p.srcX; - const sy = p.srcY; - const sw = p.srcW || img.width - sx; - const sh = p.srcH || img.height - sy; - const span = - p.cols !== undefined || p.rows !== undefined - ? cellSpan( - { c: String(p.cols ?? 0), r: String(p.rows ?? 0) }, - { width: sw / p.scale.x, height: sh / p.scale.y }, - cell.width, - cell.height, - ) - : null; - const dw = span ? span.cols * cell.width : sw / p.scale.x; - const dh = span ? span.rows * cell.height : sh / p.scale.y; - const x = p.col * cell.width + p.offX / p.scale.x; - const y = (p.absRow - topRow) * cell.height + p.offY / p.scale.y + offset; - if (y + dh < 0 || y > h || x + dw < 0 || x > w) continue; - ctx.drawImage(img, sx, sy, sw, sh, x, y, dw, dh); - } - } } diff --git a/src/client/features/kitty/graphics/kitty-limits.ts b/src/client/features/kitty/graphics/kitty-limits.ts new file mode 100644 index 0000000..11c512e --- /dev/null +++ b/src/client/features/kitty/graphics/kitty-limits.ts @@ -0,0 +1,28 @@ +// Bounds on what a PTY program can make the tab hold (constants, ADR 032). Raw dimensions +// drive the RGBA allocation and a small zlib payload can expand enormously, so limits are +// checked before anything is allocated or decoded, and again on what a decode produces. + +import { MAX_TRANSMISSION_BYTES } from '../protocol/chunk-assembler'; + +/** The limits one `KittyGraphics` enforces; tests lower them to reach each refusal. */ +export interface KittyLimits { + /** Images kept, whatever their size. */ + maxImages: number; + /** Pixels in one image. */ + maxImagePixels: number; + /** Decoded bytes across all images kept. */ + maxTotalBytes: number; + /** Encoded bytes in one transmission, however many chunks it is sent in. */ + maxTransmissionBytes: number; +} + +/** + * The production limits. A 32M-pixel image is 128 MiB as RGBA; full-window frames on + * large HiDPI displays are around 15M pixels. + */ +export const DEFAULT_LIMITS: KittyLimits = { + maxImages: 256, + maxImagePixels: 32 * 1024 * 1024, + maxTotalBytes: 512 * 1024 * 1024, + maxTransmissionBytes: MAX_TRANSMISSION_BYTES, +}; diff --git a/src/client/features/kitty/graphics/kitty-terminal.test.ts b/src/client/features/kitty/graphics/kitty-terminal.test.ts new file mode 100644 index 0000000..f70e258 --- /dev/null +++ b/src/client/features/kitty/graphics/kitty-terminal.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { + cellSize, + deviceScale, + type KittyTerminal, + scrollbackLength, + scrollOffset, +} from './kitty-terminal'; + +const bare: KittyTerminal = { + write: () => {}, + buffer: { active: { cursorX: 0, cursorY: 0 } }, + viewportY: 0, +}; + +describe('kitty terminal readers', () => { + test('before the renderer exists: 8x16 cells, no scroll offset, no scrollback', () => { + expect(cellSize(bare)).toEqual({ width: 8, height: 16 }); + expect(scrollOffset(bare)).toBe(0); + expect(scrollbackLength(bare)).toBe(0); + }); + + test('read the renderer and the wasm terminal once they exist', () => { + const term: KittyTerminal = { + ...bare, + renderer: { getMetrics: () => ({ width: 9, height: 18 }), scrollOffset: 7 }, + wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 120 }, + }; + expect(cellSize(term)).toEqual({ width: 9, height: 18 }); + expect(scrollOffset(term)).toBe(7); + expect(scrollbackLength(term)).toBe(120); + }); + + test('the alternate screen has no scrollback', () => { + const term: KittyTerminal = { + ...bare, + wasmTerm: { isAlternateScreen: () => true, getScrollbackLength: () => 120 }, + }; + expect(scrollbackLength(term)).toBe(0); + }); + + test('metrics not measured yet fall back per field', () => { + const term: KittyTerminal = { + ...bare, + renderer: { getMetrics: () => undefined, scrollOffset: 0 }, + }; + expect(cellSize(term)).toEqual({ width: 8, height: 16 }); + }); + + test.each([ + [{ width: 10, height: 20 }, 2, { x: 2, y: 2 }], + [{ width: 10, height: 20 }, 0, { x: 1, y: 1 }], + [{ width: 9.6, height: 20.4 }, 1.25, { x: 12 / 9.6, y: 26 / 20.4 }], + ])('deviceScale(%o, %d)', (cell, dpr, scale) => { + expect(deviceScale(cell, dpr)).toEqual(scale); + }); +}); diff --git a/src/client/features/kitty/graphics/kitty-terminal.ts b/src/client/features/kitty/graphics/kitty-terminal.ts new file mode 100644 index 0000000..e92831d --- /dev/null +++ b/src/client/features/kitty/graphics/kitty-terminal.ts @@ -0,0 +1,61 @@ +// The part of the terminal kitty graphics reads and writes (types + util, ADR 032): the +// cursor, the cell size, the scrollback and the smooth-scroll offset, and the +// CSS-to-device scale images are sized with. + +import { axisScale } from '../protocol/cell-geometry'; + +/** What kitty graphics needs from the terminal; the vendored `Terminal` satisfies it. */ +export interface KittyTerminal { + write(data: string): void; + readonly buffer: { readonly active: { readonly cursorX: number; readonly cursorY: number } }; + /** Rows scrolled back from the bottom. */ + readonly viewportY: number; + readonly renderer?: { + getMetrics(): { width: number; height: number } | undefined; + readonly scrollOffset: number; + }; + readonly wasmTerm?: { isAlternateScreen(): boolean; getScrollbackLength(): number }; +} + +/** A cell's size in CSS pixels, with ghostty-web's defaults before the renderer exists. */ +export interface CellSize { + width: number; + height: number; +} + +/** + * Device pixels per CSS pixel, per axis. Sizes Ghostty reports to programs (CSI 14t/16t) + * are device pixels, as in kitty: programs such as terminal-browser divide them by the + * display's scale factor to get CSS pixels, so reporting CSS pixels makes their content + * render at the wrong size. On a 2x display this means 4x the pixels per full-window + * frame. Cell sizes are whole device pixels (see protocol/cell-geometry.ts), so the + * CSS-to-device scale is per axis. See ADR 032. + */ +export interface Scale { + x: number; + y: number; +} + +/** The terminal's cell size, 8x16 until the renderer has measured the font. */ +export function cellSize(term: KittyTerminal): CellSize { + const m = term.renderer?.getMetrics(); + return { width: m?.width ?? 8, height: m?.height ?? 16 }; +} + +/** The scale an image placed now is sized for, from the cell size and `devicePixelRatio`. */ +export function deviceScale(cell: CellSize, devicePixelRatio: number): Scale { + const dpr = devicePixelRatio || 1; + return { x: axisScale(cell.width, dpr), y: axisScale(cell.height, dpr) }; +} + +/** Scrollback rows above the screen; the alternate screen has none, so its rows are viewport rows. */ +export function scrollbackLength(term: KittyTerminal): number { + const wasm = term.wasmTerm; + if (!wasm || wasm.isAlternateScreen()) return 0; + return wasm.getScrollbackLength(); +} + +/** The pixel offset of pixel-smooth scrolling (ADR 040, ADR 043). */ +export function scrollOffset(term: KittyTerminal): number { + return term.renderer?.scrollOffset ?? 0; +} diff --git a/src/client/features/kitty/graphics/overlay-canvas.test.ts b/src/client/features/kitty/graphics/overlay-canvas.test.ts new file mode 100644 index 0000000..1363c0e --- /dev/null +++ b/src/client/features/kitty/graphics/overlay-canvas.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { ManualClock } from '../../../test-support/manual-clock'; +import type { KittyTerminal } from './kitty-terminal'; +import { OverlayCanvas } from './overlay-canvas'; +import { placementAt } from './placement-geometry'; +import { Placements } from './placements'; +import { fakeBitmap } from './test-support/graphics-harness'; + +let dom: DomWindow; +afterEach(() => dom.close()); + +/** + * An overlay in a happy-dom container beside an 800x600 terminal canvas, over 10x20 px + * cells, with its frames stepped by a manual clock and its drawing recorded. + */ +function setup(withTerminalCanvas = true) { + dom = createDomWindow(); + const { document } = dom; + const container = document.createElement('div'); + document.body.append(container); + if (withTerminalCanvas) { + const canvas = document.createElement('canvas'); + for (const [key, value] of Object.entries({ + offsetLeft: 3, + offsetTop: 4, + offsetWidth: 800, + offsetHeight: 600, + })) { + Object.defineProperty(canvas, key, { value }); + } + container.append(canvas); + } + const term = { + write: () => {}, + buffer: { active: { cursorX: 0, cursorY: 0 } }, + viewportY: 0, + renderer: { getMetrics: () => ({ width: 10, height: 20 }), scrollOffset: 0 }, + } satisfies KittyTerminal; + const placements = new Placements(); + const bitmaps = new Map<number, ImageBitmap>(); + const clock = new ManualClock(); + const win = { + devicePixelRatio: 2, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }; + let visibility: DocumentVisibilityState = 'visible'; + Object.defineProperty(document, 'visibilityState', { get: () => visibility }); + const removed: string[] = []; + const remove = document.removeEventListener.bind(document); + Object.defineProperty(document, 'removeEventListener', { + value: (...args: Parameters<Document['removeEventListener']>) => { + removed.push(args[0]); + remove(...args); + }, + }); + const source = { placements, images: { bitmapOf: (id: number) => bitmaps.get(id) } }; + const overlay = new OverlayCanvas(term, container, source, { document, window: win }); + const canvas = container.querySelector('canvas[data-kitty]') as HTMLCanvasElement; + const draws: number[][] = []; + let repaints = 0; + const ctx = { + setTransform() {}, + clearRect: () => repaints++, + drawImage: (_img: unknown, ...rect: number[]) => draws.push(rect), + }; + let context: object | null = ctx; + Object.defineProperty(canvas, 'getContext', { value: () => context }); + const place = (imageId: number, col = 0, absRow = 0) => + placements.add(placementAt({}, imageId, { absRow, col }, { x: 2, y: 2 })); + return { + term, + container, + placements, + bitmaps, + clock, + win, + overlay, + canvas, + draws, + removed, + place, + repaints: () => repaints, + setVisibility: (v: DocumentVisibilityState) => { + visibility = v; + document.dispatchEvent(new dom.window.Event('visibilitychange')); + }, + noContext: () => { + context = null; + }, + }; +} + +describe('OverlayCanvas', () => { + test('is an empty, click-through canvas appended to the container, with one frame queued', () => { + const s = setup(); + expect(s.canvas.parentElement).toBe(s.container); + expect([s.canvas.width, s.canvas.height]).toEqual([0, 0]); + expect(s.canvas.style.position).toBe('absolute'); + expect(s.canvas.style.pointerEvents).toBe('none'); + expect(s.clock.pending.frames).toBe(1); + }); + + test("draws placed bitmaps at device resolution over the terminal's canvas", () => { + const s = setup(); + s.bitmaps.set(1, fakeBitmap(40, 20)); + s.place(1, 2); + s.overlay.markDirty(); + s.clock.frame(); + expect([s.canvas.width, s.canvas.height]).toEqual([1600, 1200]); + const st = s.canvas.style; + expect([st.left, st.top, st.width, st.height]).toEqual(['3px', '4px', '800px', '600px']); + expect(s.draws).toEqual([[0, 0, 40, 20, 20, 0, 20, 10]]); + }); + + test('skips placements without a bitmap and ones off screen', () => { + const s = setup(); + s.bitmaps.set(1, fakeBitmap(40, 20)); + s.place(2); + s.place(1, 200); + s.place(1, 0, 100); + s.clock.frame(); + expect(s.repaints()).toBe(1); + expect(s.draws).toEqual([]); + }); + + test('repaints only when marked dirty, invalidated or the geometry changes', () => { + const s = setup(); + s.bitmaps.set(1, fakeBitmap(4, 4)); + s.place(1); + s.clock.frame(); + s.clock.frame(); + expect(s.repaints()).toBe(1); + s.overlay.markDirty(); + s.clock.frame(); + s.overlay.invalidate(); + s.clock.frame(); + expect(s.repaints()).toBe(3); + s.win.devicePixelRatio = 1; + s.clock.frame(); + expect(s.repaints()).toBe(4); + expect(s.canvas.width).toBe(800); + }); + + test('a tab coming back to the foreground repaints; hiding it does not', () => { + const s = setup(); + s.place(1); + s.clock.frame(); + s.setVisibility('hidden'); + s.clock.frame(); + expect(s.repaints()).toBe(1); + s.setVisibility('visible'); + s.clock.frame(); + expect(s.repaints()).toBe(2); + }); + + test('frees its backing store when the last placement goes', () => { + const s = setup(); + s.place(1); + s.clock.frame(); + expect(s.canvas.width).toBe(1600); + s.placements.clear(); + s.overlay.markDirty(); + s.clock.frame(); + expect([s.canvas.width, s.canvas.height]).toEqual([0, 0]); + }); + + test('waits for the terminal canvas and for a 2D context', () => { + const none = setup(false); + none.place(1); + none.clock.frame(); + expect(none.canvas.width).toBe(0); + dom.close(); + const s = setup(); + s.noContext(); + s.place(1); + s.clock.frame(); + expect(s.canvas.width).toBe(1600); + expect(s.repaints()).toBe(0); + }); + + test('dispose cancels the frame, removes the listener and the canvas; twice is a no-op', () => { + const s = setup(); + s.overlay.dispose(); + expect(s.clock.pending.frames).toBe(0); + expect(s.removed).toEqual(['visibilitychange']); + expect(s.canvas.isConnected).toBe(false); + s.overlay.dispose(); + expect(s.removed).toEqual(['visibilitychange']); + expect(s.clock.pending.frames).toBe(0); + }); +}); diff --git a/src/client/features/kitty/graphics/overlay-canvas.ts b/src/client/features/kitty/graphics/overlay-canvas.ts new file mode 100644 index 0000000..d876e7d --- /dev/null +++ b/src/client/features/kitty/graphics/overlay-canvas.ts @@ -0,0 +1,146 @@ +// The canvas kitty images are drawn on, laid over the terminal's own (view, ADR 032): it +// repaints on the frame after anything changed, follows the terminal's scroll, size and +// pixel ratio, and frees its backing store while there is nothing to draw. + +import { DisposableStore, listen, toDisposable } from '../../../vendor/ghostty-web'; +import { + type CellSize, + cellSize, + type KittyTerminal, + scrollbackLength, + scrollOffset, +} from './kitty-terminal'; +import { drawRect } from './placement-geometry'; +import type { Placement } from './placements'; + +/** What the overlay draws: the placements and the bitmaps of their images. */ +export interface OverlaySource { + readonly placements: { readonly size: number; values(): Iterable<Placement> }; + /** An image's bitmap, undefined while its first decode is still running. */ + readonly images: { bitmapOf(imageId: number): ImageBitmap | undefined }; +} + +/** The browser objects the overlay is built in and repaints with. */ +export interface OverlayDeps { + document: Document; + window: Pick<Window, 'devicePixelRatio' | 'requestAnimationFrame' | 'cancelAnimationFrame'>; +} + +/** The overlay canvas, repainted from an animation-frame loop that runs until `dispose()`. */ +export class OverlayCanvas { + private readonly canvas: HTMLCanvasElement; + private readonly store = new DisposableStore(); + private frame = 0; + private dirty = false; + private lastSignature = ''; + + constructor( + private readonly term: KittyTerminal, + private readonly container: HTMLElement, + private readonly source: OverlaySource, + private readonly deps: OverlayDeps, + ) { + const canvas = deps.document.createElement('canvas'); + this.canvas = canvas; + canvas.dataset.kitty = ''; + // A new canvas is 300x150, which the tick reads as something to draw. At + // 0x0 the overlay holds no backing store and costs no layout read per + // frame until an image is placed. + canvas.width = 0; + canvas.height = 0; + canvas.style.cssText = 'position:absolute;pointer-events:none;'; + container.appendChild(canvas); + this.store.add(toDisposable(() => canvas.remove())); + this.frame = deps.window.requestAnimationFrame(this.tick); + this.store.add(toDisposable(() => deps.window.cancelAnimationFrame(this.frame))); + // Chromium can discard a canvas's contents while its tab is hidden. The + // overlay's geometry does not change when the tab comes back, so nothing + // else would make it repaint. + this.store.add( + listen(deps.document, 'visibilitychange', () => { + if (deps.document.visibilityState === 'visible') this.invalidate(); + }), + ); + } + + /** Repaint on the next frame: placements or bitmaps changed. */ + markDirty(): void { + this.dirty = true; + } + + /** Repaint the overlay on the next frame, whatever has or has not changed. */ + invalidate(): void { + this.dirty = true; + this.lastSignature = ''; + } + + /** Stops the frame loop, removes the listener and the canvas. Idempotent. */ + dispose(): void { + this.store.dispose(); + } + + private terminalCanvas(): HTMLCanvasElement | null { + return this.container.querySelector('canvas:not([data-kitty]):not([data-edge])'); + } + + private readonly tick = (): void => { + this.frame = this.deps.window.requestAnimationFrame(this.tick); + if (this.source.placements.size === 0 && !this.canvas.width) return; + const canvas = this.terminalCanvas(); + if (!canvas) return; + const cell = cellSize(this.term); + const signature = [ + scrollbackLength(this.term), + this.term.viewportY, + scrollOffset(this.term), + cell.width, + cell.height, + canvas.offsetLeft, + canvas.offsetTop, + canvas.offsetWidth, + canvas.offsetHeight, + this.deps.window.devicePixelRatio, + ].join('|'); + if (!this.dirty && signature === this.lastSignature) return; + this.dirty = false; + this.lastSignature = signature; + this.draw(canvas, cell); + }; + + private draw(canvas: HTMLCanvasElement, cell: CellSize): void { + const dpr = this.deps.window.devicePixelRatio || 1; + const w = canvas.offsetWidth; + const h = canvas.offsetHeight; + const o = this.canvas; + if (this.source.placements.size === 0) { + // Nothing left to draw: free the backing store, which at full size is + // the size of the terminal's canvas, and let the tick go idle. + o.width = 0; + o.height = 0; + return; + } + if (o.width !== Math.round(w * dpr) || o.height !== Math.round(h * dpr)) { + o.width = Math.round(w * dpr); + o.height = Math.round(h * dpr); + } + o.style.left = `${canvas.offsetLeft}px`; + o.style.top = `${canvas.offsetTop}px`; + o.style.width = `${w}px`; + o.style.height = `${h}px`; + + const ctx = o.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const topRow = scrollbackLength(this.term) - this.term.viewportY; + const offset = scrollOffset(this.term); + for (const p of this.source.placements.values()) { + const img = this.source.images.bitmapOf(p.imageId); + if (!img) continue; + const r = drawRect(p, img, cell, topRow, offset); + if (r.y + r.dh < 0 || r.y > h || r.x + r.dw < 0 || r.x > w) continue; + ctx.drawImage(img, r.sx, r.sy, r.sw, r.sh, r.x, r.y, r.dw, r.dh); + } + } +} diff --git a/src/client/features/kitty/graphics/placement-geometry.test.ts b/src/client/features/kitty/graphics/placement-geometry.test.ts new file mode 100644 index 0000000..1148957 --- /dev/null +++ b/src/client/features/kitty/graphics/placement-geometry.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test'; +import { KittyError } from './kitty-error'; +import { assertCursorMovable, cursorAdvance, drawRect, placementAt } from './placement-geometry'; + +const ESC = '\x1b'; +const cell = { width: 10, height: 20 }; +const unit = { x: 1, y: 1 }; + +describe('assertCursorMovable', () => { + test.each([ + [{}, { width: 2, height: 2 }, true], + [{ C: '1' }, { width: 0, height: 0 }, true], + [{}, { width: 0, height: 0 }, false], + [{}, { width: 4, height: 0 }, false], + ])('%o with %o: movable %p', (keys, size, movable) => { + const run = () => assertCursorMovable(keys, size); + if (movable) expect(run).not.toThrow(); + else expect(run).toThrow(KittyError); + }); +}); + +describe('placementAt', () => { + test('takes the source rectangle, offsets and placement id from the keys', () => { + const keys = { p: '4', x: '1', y: '2', w: '3', h: '5', X: '6', Y: '7' }; + expect(placementAt(keys, 9, { absRow: 12, col: 3 }, unit)).toEqual({ + imageId: 9, + placementId: 4, + absRow: 12, + col: 3, + srcX: 1, + srcY: 2, + srcW: 3, + srcH: 5, + offX: 6, + offY: 7, + scale: unit, + }); + }); + + test('records c and r only when given', () => { + const p = placementAt({ c: '2', r: '3' }, 1, { absRow: 0, col: 0 }, unit); + expect([p.cols, p.rows]).toEqual([2, 3]); + const q = placementAt({}, 1, { absRow: 0, col: 0 }, unit); + expect('cols' in q || 'rows' in q).toBe(false); + }); +}); + +describe('cursorAdvance', () => { + const place = (keys: Record<string, string>, scale = unit) => + placementAt(keys, 1, { absRow: 0, col: 2 }, scale); + + test.each([ + ['one cell stays on the row', {}, { width: 10, height: 20 }, `${ESC}[4G`], + ['2 x 2 cells', {}, { width: 20, height: 40 }, `\n${ESC}[5G`], + ['the source rectangle wins', { w: '10', h: '20' }, { width: 50, height: 50 }, `${ESC}[4G`], + ['x / y shrink the region', { x: '10', y: '20' }, { width: 30, height: 60 }, `\n${ESC}[5G`], + ['c / r win', { c: '3', r: '2' }, { width: 1, height: 1 }, `\n${ESC}[6G`], + ])('%s', (_name, keys, image, expected) => { + expect(cursorAdvance(keys, place(keys), image, cell)).toBe(expected); + }); + + test('device pixels are converted with the placement scale', () => { + const keys = {}; + expect(cursorAdvance(keys, place(keys, { x: 2, y: 2 }), { width: 20, height: 40 }, cell)).toBe( + `${ESC}[4G`, + ); + }); + + test('C=1 leaves the cursor where it is', () => { + const keys = { C: '1' }; + expect(cursorAdvance(keys, place(keys), { width: 20, height: 40 }, cell)).toBeNull(); + }); +}); + +describe('drawRect', () => { + test('a whole image at natural size, below the viewport top and the scroll offset', () => { + const p = placementAt({}, 1, { absRow: 5, col: 2 }, { x: 2, y: 2 }); + expect(drawRect(p, { width: 40, height: 20 }, cell, 3, 4)).toEqual({ + sx: 0, + sy: 0, + sw: 40, + sh: 20, + x: 20, + y: 2 * 20 + 4, + dw: 20, + dh: 10, + }); + }); + + test('a source rectangle with offsets, stretched over c x r cells', () => { + const keys = { x: '2', y: '3', w: '8', h: '6', X: '4', Y: '2', c: '2', r: '1' }; + const p = placementAt(keys, 1, { absRow: 0, col: 1 }, { x: 2, y: 2 }); + expect(drawRect(p, { width: 100, height: 100 }, cell, 0, 0)).toEqual({ + sx: 2, + sy: 3, + sw: 8, + sh: 6, + x: 12, + y: 1, + dw: 20, + dh: 20, + }); + }); + + test('only c given: the rows follow from the aspect ratio', () => { + const p = placementAt({ c: '1' }, 1, { absRow: 0, col: 0 }, unit); + expect(drawRect(p, { width: 10, height: 40 }, cell, 0, 0)).toMatchObject({ dw: 10, dh: 40 }); + }); +}); diff --git a/src/client/features/kitty/graphics/placement-geometry.ts b/src/client/features/kitty/graphics/placement-geometry.ts new file mode 100644 index 0000000..20c52ea --- /dev/null +++ b/src/client/features/kitty/graphics/placement-geometry.ts @@ -0,0 +1,115 @@ +// The arithmetic of a placement (util, pure, ADR 032): the record a command makes at the +// cursor, how far the cursor moves past it, and the rectangle it is drawn into. + +import { type KittyKeys, num } from '../protocol/apc-command'; +import { cellSpan } from '../protocol/cell-geometry'; +import { KittyError } from './kitty-error'; +import type { CellSize, Scale } from './kitty-terminal'; +import type { Placement } from './placements'; + +/** A size in pixels; 0 x 0 when only the decode can tell. */ +interface Size { + width: number; + height: number; +} + +/** + * Refuses a placement that would have to move the cursor (C=0, the default) by a size + * nobody knows yet: only a decode can tell a compressed PNG's size, and the cursor has to + * move at this exact point in the stream. + */ +export function assertCursorMovable(keys: KittyKeys, size: Size): void { + if (num(keys, 'C') === 1 || (size.width && size.height)) return; + throw new KittyError('EINVAL', 'size of a compressed PNG is unknown until decoded: use C=1'); +} + +/** The placement a command makes for `imageId` at the cursor cell `at`. */ +export function placementAt( + keys: KittyKeys, + imageId: number, + at: { absRow: number; col: number }, + scale: Scale, +): Placement { + const p: Placement = { + imageId, + placementId: num(keys, 'p'), + absRow: at.absRow, + col: at.col, + srcX: num(keys, 'x'), + srcY: num(keys, 'y'), + srcW: num(keys, 'w'), + srcH: num(keys, 'h'), + offX: num(keys, 'X'), + offY: num(keys, 'Y'), + scale, + }; + if (keys.c !== undefined) p.cols = num(keys, 'c'); + if (keys.r !== undefined) p.rows = num(keys, 'r'); + return p; +} + +/** + * What to write so the cursor ends past the image, as C=0 (the default) asks: on its last + * row, right of its last column. The WASM cannot know an image took space, so it is + * moved by hand. Null for C=1. + */ +export function cursorAdvance( + keys: KittyKeys, + p: Placement, + image: Size, + cell: CellSize, +): string | null { + if (num(keys, 'C') === 1) return null; + // The shown region is the source rectangle when given; `w` / `h` are + // device pixels like the image, so they convert with the same scale. + const region = { + width: (p.srcW || Math.max(1, image.width - p.srcX)) / p.scale.x, + height: (p.srcH || Math.max(1, image.height - p.srcY)) / p.scale.y, + }; + const span = cellSpan(keys, region, cell.width, cell.height); + return `${'\n'.repeat(span.rows - 1)}\x1b[${p.col + span.cols + 1}G`; +} + +/** A placement's source rectangle in the bitmap and destination box on the overlay. */ +export interface DrawRect { + sx: number; + sy: number; + sw: number; + sh: number; + x: number; + y: number; + dw: number; + dh: number; +} + +/** + * Where `p` is drawn, in CSS pixels from the overlay's top left. `topRow` is the absolute + * row at the top of the viewport and `offset` the smooth-scroll offset: viewport row 0 + * starts that far below the canvas top (ADR 043). + */ +export function drawRect( + p: Placement, + bitmap: Size, + cell: CellSize, + topRow: number, + offset: number, +): DrawRect { + const sx = p.srcX; + const sy = p.srcY; + const sw = p.srcW || bitmap.width - sx; + const sh = p.srcH || bitmap.height - sy; + const span = + p.cols !== undefined || p.rows !== undefined + ? cellSpan( + { c: String(p.cols ?? 0), r: String(p.rows ?? 0) }, + { width: sw / p.scale.x, height: sh / p.scale.y }, + cell.width, + cell.height, + ) + : null; + const dw = span ? span.cols * cell.width : sw / p.scale.x; + const dh = span ? span.rows * cell.height : sh / p.scale.y; + const x = p.col * cell.width + p.offX / p.scale.x; + const y = (p.absRow - topRow) * cell.height + p.offY / p.scale.y + offset; + return { sx, sy, sw, sh, x, y, dw, dh }; +} diff --git a/src/client/features/kitty/graphics/placements.test.ts b/src/client/features/kitty/graphics/placements.test.ts new file mode 100644 index 0000000..de057d4 --- /dev/null +++ b/src/client/features/kitty/graphics/placements.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { type Placement, Placements } from './placements'; + +const at = (imageId: number, placementId: number, col = 0): Placement => ({ + imageId, + placementId, + absRow: 0, + col, + srcX: 0, + srcY: 0, + srcW: 0, + srcH: 0, + offX: 0, + offY: 0, + scale: { x: 1, y: 1 }, +}); + +describe('Placements', () => { + test('keeps placement order and replaces a repeated image and placement id in place', () => { + const table = new Placements(); + table.add(at(1, 0)); + table.add(at(2, 0)); + table.add(at(1, 0, 5)); + expect(table.keys()).toEqual(['1:0', '2:0']); + expect([...table.values()].map((p) => p.col)).toEqual([5, 0]); + expect(table.size).toBe(2); + }); + + test('removeWhere removes the matches and says whether there were any', () => { + const table = new Placements(); + table.add(at(1, 0)); + table.add(at(1, 1)); + table.add(at(2, 0)); + expect(table.removeWhere((p) => p.imageId === 1)).toBe(true); + expect(table.keys()).toEqual(['2:0']); + expect(table.removeWhere((p) => p.imageId === 1)).toBe(false); + }); + + test('clear empties the table', () => { + const table = new Placements(); + table.add(at(1, 0)); + table.clear(); + expect(table.size).toBe(0); + }); +}); diff --git a/src/client/features/kitty/graphics/placements.ts b/src/client/features/kitty/graphics/placements.ts new file mode 100644 index 0000000..12c8da3 --- /dev/null +++ b/src/client/features/kitty/graphics/placements.ts @@ -0,0 +1,64 @@ +// Where images are placed on the terminal grid (object, ADR 032): one placement per image +// and placement id, anchored to an absolute buffer row so it scrolls with its text. + +import type { Scale } from './kitty-terminal'; + +/** One image shown at a cell, with the source rectangle and offsets the command gave. */ +export interface Placement { + imageId: number; + placementId: number; + /** Row in absolute buffer coordinates: scrollback length + viewport row. */ + absRow: number; + col: number; + cols?: number; + rows?: number; + srcX: number; + srcY: number; + srcW: number; + srcH: number; + offX: number; + offY: number; + /** Device pixels per CSS pixel when placed; the image was sized for this. */ + scale: Scale; +} + +/** The placement table, in the order placements were made (the order they are drawn in). */ +export class Placements { + private readonly table = new Map<string, Placement>(); + + /** Number of placements on the grid. */ + get size(): number { + return this.table.size; + } + + /** Keys as `<imageId>:<placementId>`, in placement order. */ + keys(): string[] { + return [...this.table.keys()]; + } + + /** Placements in the order they are drawn. */ + values(): IterableIterator<Placement> { + return this.table.values(); + } + + /** Records `p`, replacing an earlier placement with the same image and placement id. */ + add(p: Placement): void { + this.table.set(`${p.imageId}:${p.placementId}`, p); + } + + /** Removes every placement `match` accepts; returns whether any was removed. */ + removeWhere(match: (p: Placement) => boolean): boolean { + let removed = false; + for (const [key, p] of this.table) { + if (!match(p)) continue; + this.table.delete(key); + removed = true; + } + return removed; + } + + /** Removes every placement. */ + clear(): void { + this.table.clear(); + } +} diff --git a/src/client/features/kitty/graphics/test-support/graphics-harness.ts b/src/client/features/kitty/graphics/test-support/graphics-harness.ts new file mode 100644 index 0000000..9fe9ef8 --- /dev/null +++ b/src/client/features/kitty/graphics/test-support/graphics-harness.ts @@ -0,0 +1,192 @@ +// Drives KittyGraphics on fakes (test fake, ADR 064): a terminal that records what is +// written, a decoder whose bitmaps the test releases in any order, and an injected +// happy-dom container whose overlay frames the test steps with a manual clock. + +import { createDomWindow, type DomWindow } from '../../../../test-support/dom-window'; +import { ManualClock } from '../../../../test-support/manual-clock'; +import type { ImageDecoderDeps } from '../image-decoder'; +import { KittyGraphics } from '../kitty-graphics'; +import type { KittyLimits } from '../kitty-limits'; +import type { KittyTerminal } from '../kitty-terminal'; + +/** The escape character, for building sequences in expectations. */ +export const ESC = '\x1b'; +/** A kitty graphics APC with control keys and an optional base64 payload. */ +export const apc = (keys: string, payload = ''): string => `${ESC}_G${keys};${payload}${ESC}\\`; +/** Base64 of `bytes`, as a transmission carries them. */ +export const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64'); +/** A base64 payload of `n` zero bytes. */ +export const zeros = (n: number): string => b64(new Uint8Array(n)); +/** Lets pending decodes settle: the inflate stream needs real ticks, not just microtasks. */ +export const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 20)); + +/** + * The first 33 bytes of a PNG file: signature and IHDR. A PNG is only inspected for its + * IHDR before the decode is handed to the (fake) browser, so a header is enough. + */ +export function pngHeader(width: number, height: number): Uint8Array { + const out = new Uint8Array(33); + out.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + new DataView(out.buffer).setUint32(16, width); + new DataView(out.buffer).setUint32(20, height); + return out; +} + +/** An ImageBitmap stand-in that records whether it was closed. */ +export interface FakeBitmap extends ImageBitmap { + closed: boolean; +} + +/** Makes a fake bitmap of the given size. */ +export function fakeBitmap(width: number, height: number): FakeBitmap { + return { + width, + height, + closed: false, + close() { + this.closed = true; + }, + }; +} + +/** + * The browser's image constructors, faked: every bitmap made is kept in `bitmaps`, and + * `hold()` makes the next decode wait until the test releases it. + */ +export class FakeDecoder implements ImageDecoderDeps { + readonly bitmaps: FakeBitmap[] = []; + /** The RGBA of every raw image decoded, as plain numbers. */ + readonly pixels: number[][] = []; + private readonly gates: Array<Promise<void>> = []; + + /** Gates the next decode to start; returns the call that lets it finish. */ + hold(): () => void { + let release = () => {}; + this.gates.push( + new Promise<void>((r) => { + release = r; + }), + ); + return release; + } + + readonly createImageBitmap = async (source: ImageBitmapSource): Promise<ImageBitmap> => { + const gate = this.gates.shift(); + if (gate) await gate; + let size: { width: number; height: number }; + if (source instanceof Blob) { + const head = new DataView(await source.arrayBuffer()); + size = { width: head.getUint32(16), height: head.getUint32(20) }; + } else { + size = source as ImageData; + } + const bitmap = fakeBitmap(size.width, size.height); + this.bitmaps.push(bitmap); + return bitmap; + }; + + readonly createImageData = ( + data: Uint8ClampedArray<ArrayBuffer>, + width: number, + height: number, + ): ImageData => { + this.pixels.push([...data]); + return { data, width, height, colorSpace: 'srgb' }; + }; +} + +/** What a harness test drives and inspects. */ +export interface GraphicsHarness { + g: KittyGraphics; + /** Everything written to the terminal, one entry per write. */ + written: string[]; + /** Every reply sent to the PTY. */ + sent: string[]; + cursor: { x: number; y: number }; + decoder: FakeDecoder; + clock: ManualClock; + dom: DomWindow; + win: { devicePixelRatio: number }; + /** The overlay canvas. */ + overlay: HTMLCanvasElement; + /** Layout reads made of the terminal's canvas, and repaints (clearRect calls) of the overlay. */ + counts: { reads: number; repaints: number; drawn: number }; +} + +let open: DomWindow[] = []; + +/** Closes every harness window; call it from `afterEach`. */ +export async function closeHarnesses(): Promise<void> { + const windows = open; + open = []; + await Promise.all(windows.map((w) => w.close())); +} + +/** Defines a counted layout property on the terminal's canvas. */ +function countedLayout(el: HTMLElement, counts: { reads: number }): void { + const box = { offsetLeft: 0, offsetTop: 0, offsetWidth: 800, offsetHeight: 600 }; + for (const [key, value] of Object.entries(box)) { + Object.defineProperty(el, key, { + get: () => { + counts.reads++; + return value; + }, + }); + } +} + +/** A KittyGraphics over 10x20 px cells (by default) with its cursor at column 2, row 3. */ +export function graphicsHarness( + limits: Partial<KittyLimits> = {}, + cell: { width: number; height: number } = { width: 10, height: 20 }, +): GraphicsHarness { + const written: string[] = []; + const sent: string[] = []; + const cursor = { x: 2, y: 3 }; + const term: KittyTerminal = { + viewportY: 0, + write: (s: string) => written.push(s), + buffer: { active: activeOf(cursor) }, + wasmTerm: { isAlternateScreen: () => false, getScrollbackLength: () => 0 }, + renderer: { getMetrics: () => cell, scrollOffset: 0 }, + }; + const dom = createDomWindow(); + open.push(dom); + const container = dom.document.createElement('div'); + dom.document.body.append(container); + const counts = { reads: 0, repaints: 0, drawn: 0 }; + const terminalCanvas = dom.document.createElement('canvas'); + countedLayout(terminalCanvas, counts); + container.append(terminalCanvas); + const clock = new ManualClock(); + const decoder = new FakeDecoder(); + const win = { + devicePixelRatio: 1, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }; + const g = new KittyGraphics(term, container, (d) => sent.push(d), limits, { + document: dom.document, + window: win, + decoder, + }); + const overlay = container.querySelector('canvas[data-kitty]') as HTMLCanvasElement; + const ctx = { + setTransform() {}, + clearRect: () => counts.repaints++, + drawImage: () => counts.drawn++, + }; + Object.defineProperty(overlay, 'getContext', { value: () => ctx }); + return { g, written, sent, cursor, decoder, clock, dom, win, overlay, counts }; +} + +function activeOf(cursor: { x: number; y: number }): { cursorX: number; cursorY: number } { + return { + get cursorX() { + return cursor.x; + }, + get cursorY() { + return cursor.y; + }, + }; +} From ed797b87ee14bcd405646826eacf1969c46cdada Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:53:23 -0400 Subject: [PATCH 182/295] refactor(server): route table and per-resource route modules The handleRequest if-chain in src/server/routes.ts becomes an ordered route table (ADR 062): - http/router.ts: a (method, path, handler) list where the first match wins; the URL is parsed before CORS is set, and no match is the plain 404, so every response keeps its CORS headers. - routes/route-table.ts lists the routes in the old if-chain order (the order is commented and pinned by route-table.test.ts) and builds the request handler from injected deps (store, loadConfig, paths, onStop, spawn). - routes/{server,config,session,page,asset}-routes.ts, publish-route.ts and execute-route.ts hold the handlers by resource; running-session.ts is the shared /publish and /execute guard; run-command.ts streams the child process as NDJSON with spawn injected. /api/sessions/:id stays one any-method route so an undecodable ID is still a 400 for every method. The golden tests pass unchanged apart from readJson now imported from http/request-body. --- docs/specs/deep-link.md | 2 +- src/server/http/router.test.ts | 126 +++++++++ src/server/http/router.ts | 66 +++++ src/server/index.ts | 12 +- src/server/routes-golden.test.ts | 2 +- src/server/routes.ts | 353 -------------------------- src/server/routes/asset-routes.ts | 38 +++ src/server/routes/config-routes.ts | 22 ++ src/server/routes/execute-route.ts | 56 ++++ src/server/routes/page-routes.ts | 65 +++++ src/server/routes/publish-route.ts | 49 ++++ src/server/routes/route-table.test.ts | 57 +++++ src/server/routes/route-table.ts | 60 +++++ src/server/routes/run-command.ts | 85 +++++++ src/server/routes/running-session.ts | 34 +++ src/server/routes/server-routes.ts | 30 +++ src/server/routes/session-routes.ts | 131 ++++++++++ src/test-support/route-harness.ts | 14 +- 18 files changed, 842 insertions(+), 360 deletions(-) create mode 100644 src/server/http/router.test.ts create mode 100644 src/server/http/router.ts delete mode 100644 src/server/routes.ts create mode 100644 src/server/routes/asset-routes.ts create mode 100644 src/server/routes/config-routes.ts create mode 100644 src/server/routes/execute-route.ts create mode 100644 src/server/routes/page-routes.ts create mode 100644 src/server/routes/publish-route.ts create mode 100644 src/server/routes/route-table.test.ts create mode 100644 src/server/routes/route-table.ts create mode 100644 src/server/routes/run-command.ts create mode 100644 src/server/routes/running-session.ts create mode 100644 src/server/routes/server-routes.ts create mode 100644 src/server/routes/session-routes.ts diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index 58cc5a4..ce767a4 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -141,7 +141,7 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would ### PID-based navigation — detail -**New server route** (`src/server/routes.ts`): +**New server route** (`src/server/routes/page-routes.ts`): ``` GET /p/<pid> diff --git a/src/server/http/router.test.ts b/src/server/http/router.test.ts new file mode 100644 index 0000000..08f0c44 --- /dev/null +++ b/src/server/http/router.test.ts @@ -0,0 +1,126 @@ +// Tests for the ordered router: first match wins, RegExp groups become params, `*` matches +// any method, and a request no route takes ends in a CORS-carrying 404 (ADR 062). +import { describe, expect, test } from 'bun:test'; +import http from 'node:http'; +import { Socket } from 'node:net'; +import { recordResponse } from '../../test-support/response-recorder'; +import { CORS_HEADERS } from './respond'; +import { ANY_PATH, createRouter, matchRoute, type Route, type RouteContext } from './router'; + +/** A request with `method` and `url`; `host` is the Host header (default 127.0.0.1). */ +function request(method: string, url: string, host = '127.0.0.1'): http.IncomingMessage { + const req = new http.IncomingMessage(new Socket()); + req.method = method; + req.url = url; + req.headers = { host }; + return req; +} + +/** A route that records its name and params into `log`. */ +const route = (name: string, method: string, path: string | RegExp, log: string[]): Route => ({ + method, + path, + handle: ({ params }: RouteContext) => { + log.push(`${name}(${params.join(',')})`); + }, +}); + +const corsCalls = Object.entries(CORS_HEADERS).map(([k, v]) => ['setHeader', k, v]); + +describe('matchRoute', () => { + const log: string[] = []; + const routes = [ + route('exact', 'GET', '/a', log), + route('any', '*', '/a', log), + route('re', 'POST', /^\/s\/([^/]+)\/(\w+)$/, log), + route('all', 'OPTIONS', ANY_PATH, log), + ]; + + test.each<[string, string, string | null, string[]]>([ + ['GET', '/a', 'exact', []], + ['PUT', '/a', 'any', []], + ['POST', '/s/x/run', 're', ['x', 'run']], + ['GET', '/s/x/run', null, []], + ['OPTIONS', '/anything/at/all', 'all', []], + ['GET', '/a/', null, []], + ['GET', '/b', null, []], + ])('%s %s → %s', (method, path, name, params) => { + const match = matchRoute(routes, method, path); + if (name === null) { + expect(match).toBeNull(); + return; + } + expect(match?.route).toBe(routes[['exact', 'any', 're', 'all'].indexOf(name)]); + expect(match?.params).toEqual(params); + }); + + test('an undefined method only matches `*` routes', () => { + expect(matchRoute(routes, undefined, '/a')?.route).toBe(routes[1]); + }); +}); + +describe('createRouter', () => { + test('the first matching route runs, and no later one', async () => { + const log: string[] = []; + const handler = createRouter([ + route('first', 'GET', /^\/x\/(\d+)$/, log), + route('second', 'GET', /^\/x\/(.*)$/, log), + ]); + const { res, calls } = recordResponse(); + await handler(request('GET', '/x/42?q=1'), res); + expect(log).toEqual(['first(42)']); + expect(calls).toEqual(corsCalls); + }); + + test('the query string is not part of the pathname matched', async () => { + const log: string[] = []; + const { res } = recordResponse(); + await createRouter([route('a', 'GET', '/a', log)])(request('GET', '/a?x=/b'), res); + expect(log).toEqual(['a()']); + }); + + test('no match is a 404 after the CORS headers', async () => { + const log: string[] = []; + const { res, calls } = recordResponse(); + await createRouter([route('a', 'GET', '/a', log)])(request('POST', '/a'), res); + expect(log).toEqual([]); + expect(calls).toEqual([...corsCalls, ['writeHead', 404], ['end', 'Not Found']]); + }); + + test('the handler receives the request, response and pathname, and is awaited', async () => { + const seen: RouteContext[] = []; + let finished = false; + const handler = createRouter([ + { + method: '*', + path: '/p', + handle: async (ctx) => { + seen.push(ctx); + await Promise.resolve(); + finished = true; + }, + }, + ]); + const req = request('DELETE', '/p'); + const { res } = recordResponse(); + await handler(req, res); + expect(finished).toBe(true); + expect(seen).toEqual([{ req, res, pathname: '/p', params: [] }]); + }); + + test('a missing URL is treated as `/`', async () => { + const log: string[] = []; + const req = request('GET', '/'); + req.url = undefined; + const { res } = recordResponse(); + await createRouter([route('root', 'GET', '/', log)])(req, res); + expect(log).toEqual(['root()']); + }); + + test('a malformed Host header rejects before any header is set', async () => { + const { res, calls } = recordResponse(); + const handler = createRouter([]); + await expect(handler(request('GET', '/', 'bad host:x'), res)).rejects.toThrow(); + expect(calls).toEqual([]); + }); +}); diff --git a/src/server/http/router.ts b/src/server/http/router.ts new file mode 100644 index 0000000..307d236 --- /dev/null +++ b/src/server/http/router.ts @@ -0,0 +1,66 @@ +// A small ordered HTTP router: a list of (method, path, handler) routes where the first match +// wins, CORS on every response and a plain 404 when nothing matches (ADR 062). A utility; the +// webtty routes themselves are listed in routes/route-table.ts. +import type http from 'node:http'; +import { sendNotFound, setCors } from './respond'; + +/** What a matched route's handler receives. */ +export interface RouteContext { + req: http.IncomingMessage; + res: http.ServerResponse; + /** The request path, without the query string. */ + pathname: string; + /** The capture groups of a RegExp path, in order; empty for a string path. */ + params: string[]; +} + +/** One entry of the table: answers a request whose method and path match. */ +export interface Route { + /** An HTTP method such as `GET`, or `*` for any method. */ + method: string; + /** An exact pathname, or a RegExp tested against the pathname (its groups become params). */ + path: string | RegExp; + handle: (ctx: RouteContext) => void | Promise<void>; +} + +/** Matches every pathname, for a route that applies to any path (the CORS preflight). */ +export const ANY_PATH = /^/; + +/** The first route in `routes` matching `method` and `pathname`, with its params, or null. */ +export function matchRoute( + routes: readonly Route[], + method: string | undefined, + pathname: string, +): { route: Route; params: string[] } | null { + for (const route of routes) { + if (route.method !== '*' && route.method !== method) continue; + if (typeof route.path === 'string') { + if (route.path === pathname) return { route, params: [] }; + continue; + } + const m = pathname.match(route.path); + if (m) return { route, params: m.slice(1) }; + } + return null; +} + +/** + * Builds the request handler for `routes`. The URL is parsed before anything is written (a + * malformed Host header rejects with no headers set), then the CORS headers, then the first + * matching route runs; with no match the reply is `404 Not Found`. + */ +export function createRouter( + routes: readonly Route[], +): (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> { + return async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + const pathname = url.pathname; + setCors(res); + const match = matchRoute(routes, req.method, pathname); + if (!match) { + sendNotFound(res); + return; + } + await match.route.handle({ req, res, pathname, params: match.params }); + }; +} diff --git a/src/server/index.ts b/src/server/index.ts index a8dd62e..dc2581a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadConfig } from '../config'; import { findGhosttyWasm } from './http/static-files'; -import { handleRequest } from './routes'; +import { createRequestHandler } from './routes/route-table'; import { SessionStore } from './sessions/session-store'; import { WS_CLOSE } from './ws/close-codes'; import { createWebSocketServer } from './ws/ws-upgrade'; @@ -51,8 +51,16 @@ process.on('unhandledRejection', (reason) => { console.error('[webtty] unhandledRejection:', reason); }); +const handleRequest = createRequestHandler({ + store, + loadConfig, + wasmPath, + clientDistPath, + onStop: shutdown, +}); + const httpServer = http.createServer((req, res) => { - handleRequest(req, res, store, wasmPath, clientDistPath, shutdown).catch((err) => { + handleRequest(req, res).catch((err) => { console.error('[webtty] unhandled route error:', err); if (!res.headersSent) { res.writeHead(500); diff --git a/src/server/routes-golden.test.ts b/src/server/routes-golden.test.ts index 2f716f6..8e585bd 100644 --- a/src/server/routes-golden.test.ts +++ b/src/server/routes-golden.test.ts @@ -19,7 +19,7 @@ import { sessionBody, } from '../test-support/route-harness'; import { getVersion } from '../version'; -import { readJson } from './routes'; +import { readJson } from './http/request-body'; import type { Session } from './sessions/session'; const h = new RouteHarness(); diff --git a/src/server/routes.ts b/src/server/routes.ts deleted file mode 100644 index 0598f9f..0000000 --- a/src/server/routes.ts +++ /dev/null @@ -1,353 +0,0 @@ -// The webtty HTTP request handler: the REST API, the page routes and static assets, as one -// ordered if-chain (ADR 062). Response and body helpers live in http/; routes/ splits this next. -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import type http from 'node:http'; -import { homedir } from 'node:os'; -import path from 'node:path'; -import { loadConfig, toClientConfig } from '../config'; -import { LineSplitter } from '../ndjson'; -import { getVersion } from '../version'; -import { decodeId, hasJsonContentType, MAX_BODY, readJsonOrReply } from './http/request-body'; -import { - redirect, - sendBadRequest, - sendJson, - sendNotFound, - sendText, - setCors, -} from './http/respond'; -import { GHOSTTY_WASM, serveFile } from './http/static-files'; -import { sessionToJson } from './sessions/session'; -import { generateId, isValidId } from './sessions/session-id'; -import type { SessionStore } from './sessions/session-store'; -import { WS_CLOSE } from './ws/close-codes'; - -// readJson moved to http/request-body.ts; re-exported because the route golden test imports it here. -export { readJson } from './http/request-body'; - -/** - * Main HTTP request handler for the webtty server. - * Dispatches all REST API routes and serves static client assets. - * - * @param req - The incoming HTTP request. - * @param res - The HTTP response object. - * @param store - The server's sessions. - * @param wasmPath - Path to the wasm served at `/ghostty-vt-small.wasm`. - * @param clientDistPath - Path to the client dist directory. - * @param onStop - Callback invoked when `POST /api/server/stop` is received. - */ -export async function handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - store: SessionStore, - wasmPath: string, - clientDistPath: string, - onStop: () => void, -): Promise<void> { - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); - const pathname = url.pathname; - - setCors(res); - - if (req.method === 'OPTIONS') { - sendText(res, 204); - return; - } - - if (req.method === 'POST' && pathname === '/api/server/stop') { - sendText(res, 200, 'stopping', { 'Content-Type': 'text/plain' }); - onStop(); - return; - } - - if (req.method === 'GET' && pathname === '/api/server/status') { - sendJson(res, 200, { version: getVersion() }); - return; - } - - if (req.method === 'GET' && pathname === '/api/config') { - // Only the client-safe keys: server-side config (shell, host, logs, env…) stays here. - const clientConfig = toClientConfig(loadConfig()); - sendJson(res, 200, clientConfig); - return; - } - - if (pathname === '/api/sessions') { - if (req.method === 'GET') { - const list = store.list().map(sessionToJson); - sendJson(res, 200, list); - return; - } - - if (req.method === 'POST') { - const read = await readJsonOrReply(req, res); - if (!read) return; - const body = read.value as { id?: string; baseDir?: string }; - - const id = body.id ?? generateId(); - if (!isValidId(id)) { - sendJson(res, 400, { error: `invalid id: ${id}` }); - return; - } - if (store.has(id)) { - sendJson(res, 409, { error: `session already exists: ${id}` }); - return; - } - const baseDir = body.baseDir ?? homedir(); - if (!path.isAbsolute(baseDir)) { - sendJson(res, 400, { error: 'baseDir must be an absolute path' }); - return; - } - if (!fs.existsSync(baseDir)) { - sendJson(res, 400, { error: `baseDir does not exist: ${baseDir}` }); - return; - } - if (!fs.statSync(baseDir).isDirectory()) { - sendJson(res, 400, { error: `baseDir is not a directory: ${baseDir}` }); - return; - } - const session = store.create(id, baseDir); - sendJson(res, 201, sessionToJson(session)); - return; - } - } - - const sessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/); - if (sessionMatch) { - const id = decodeId(sessionMatch[1]); - if (!id) { - sendBadRequest(res); - return; - } - - if (req.method === 'GET') { - const session = store.get(id); - if (!session) { - sendNotFound(res); - return; - } - sendJson(res, 200, sessionToJson(session)); - return; - } - - if (req.method === 'PATCH') { - const read = await readJsonOrReply(req, res); - if (!read) return; - const body = read.value as { id?: string }; - - const session = store.get(id); - if (!session) { - sendNotFound(res); - return; - } - - const newId = body.id; - if (!newId) { - sendText(res, 400, 'missing id'); - return; - } - if (!isValidId(newId)) { - sendJson(res, 400, { error: `invalid id: ${newId}` }); - return; - } - if (store.has(newId)) { - sendJson(res, 409, { error: `session already exists: ${newId}` }); - return; - } - store.rename(id, newId); - sendJson(res, 200, sessionToJson(session)); - return; - } - - if (req.method === 'DELETE') { - const session = store.remove(id); - if (!session) { - sendNotFound(res); - return; - } - session.close(WS_CLOSE.SESSION_GONE, 'session deleted'); - sendText(res, 204, undefined, { 'X-Sessions-Remaining': String(store.size) }); - return; - } - } - - if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) { - let targetId = store.lastUsedId; - if (!targetId || !store.has(targetId)) { - if (!store.has('main')) store.create('main'); - targetId = 'main'; - } - redirect(res, `/s/${targetId}`); - return; - } - - const clientMatch = pathname.match(/^\/s\/([^/]+)$/); - if (req.method === 'GET' && clientMatch) { - const id = decodeId(clientMatch[1]); - if (!id || !isValidId(id) || !store.has(id)) { - sendNotFound(res); - return; - } - const clientHtml = path.resolve(clientDistPath, 'client.html'); - serveFile(clientHtml, res); - return; - } - - const publishMatch = pathname.match(/^\/s\/([^/]+)\/publish$/); - if (req.method === 'POST' && publishMatch) { - const id = decodeId(publishMatch[1]); - if (!id) { - sendBadRequest(res); - return; - } - if (!hasJsonContentType(req)) { - sendBadRequest(res); - return; - } - const session = store.get(id); - if (!session) { - sendNotFound(res); - return; - } - if (session.pty === null) { - sendText(res, 409, 'PTY not running'); - return; - } - // One event per NDJSON line; invalid JSON lines are skipped, and a line that grows past - // 64 KB without a newline is dropped. - const broadcastIfJson = (line: string) => { - try { - JSON.parse(line); - session.broadcastEvent(line); - } catch { - // skip invalid JSON lines - } - }; - const lines = new LineSplitter({ maxLength: MAX_BODY }); - req.on('data', (chunk: Buffer) => { - for (const line of lines.push(chunk.toString('utf8'))) broadcastIfJson(line); - }); - req.on('end', () => { - const last = lines.flush(); - if (last) broadcastIfJson(last); - sendText(res, 204); - }); - req.on('error', () => { - sendText(res, 500); - }); - return; - } - - const executeMatch = pathname.match(/^\/s\/([^/]+)\/execute$/); - if (req.method === 'POST' && executeMatch) { - const id = decodeId(executeMatch[1]); - if (!id) { - sendBadRequest(res); - return; - } - if (!hasJsonContentType(req)) { - sendBadRequest(res); - return; - } - const session = store.get(id); - if (!session) { - sendNotFound(res); - return; - } - if (session.pty === null) { - sendText(res, 409, 'PTY not running'); - return; - } - const read = await readJsonOrReply(req, res); - if (!read) return; - const { cmd, args, stdin } = read.value as { cmd?: unknown; args?: unknown; stdin?: unknown }; - if (typeof cmd !== 'string' || cmd.length === 0) { - sendText(res, 400, 'invalid body'); - return; - } - if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) { - sendText(res, 400, 'invalid body'); - return; - } - let child: ReturnType<typeof spawn>; - try { - const execConfig = loadConfig(); - child = spawn(cmd, args as string[], { - env: { ...process.env, ...execConfig.env }, - cwd: session.baseDir, - windowsHide: true, - }); - } catch (err) { - sendText(res, 500, `spawn error: ${String(err)}`); - return; - } - res.writeHead(200, { - 'Content-Type': 'application/x-ndjson', - 'Cache-Control': 'no-cache', - }); - let done = false; - child.on('error', (err) => { - if (done) return; - done = true; - const msg = err.message; - const code = (err as NodeJS.ErrnoException).code; - res.write(`${JSON.stringify({ stream: 'stderr', data: `${msg}\n` })}\n`); - res.write(`${JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) })}\n`); - res.end(); - }); - if (typeof stdin === 'string') { - child.stdin?.write(stdin); - } - child.stdin?.end(); - child.stdout?.on('data', (chunk: Buffer) => { - res.write(`${JSON.stringify({ stream: 'stdout', data: chunk.toString() })}\n`); - }); - child.stderr?.on('data', (chunk: Buffer) => { - res.write(`${JSON.stringify({ stream: 'stderr', data: chunk.toString() })}\n`); - }); - child.on('close', (code: number | null) => { - if (done) return; - done = true; - res.write(`${JSON.stringify({ exit: code ?? 1 })}\n`); - res.end(); - }); - req.on('close', () => { - if (!done) child.kill(); - }); - return; - } - - const pidMatch = pathname.match(/^\/p\/(\d+)$/); - if (req.method === 'GET' && pidMatch) { - const pid = parseInt(pidMatch[1], 10); - if (!Number.isFinite(pid) || pid <= 0) { - sendNotFound(res); - return; - } - const session = store.findByPid(pid); - if (!session) { - sendNotFound(res); - return; - } - redirect(res, `/s/${session.id}`); - return; - } - - if (pathname.startsWith('/dist/')) { - const ownFile = path.resolve(clientDistPath, pathname.slice(6)); - if (ownFile.startsWith(clientDistPath + path.sep)) { - serveFile(ownFile, res); - return; - } - sendNotFound(res); - return; - } - - if (pathname === `/${GHOSTTY_WASM}`) { - serveFile(wasmPath, res); - return; - } - - sendNotFound(res); -} diff --git a/src/server/routes/asset-routes.ts b/src/server/routes/asset-routes.ts new file mode 100644 index 0000000..a1434b4 --- /dev/null +++ b/src/server/routes/asset-routes.ts @@ -0,0 +1,38 @@ +// Static assets: the client bundle under `/dist/` and the libghostty-vt wasm (ADR 062). +// Route handlers; both answer any method, as the old if-chain did. +import path from 'node:path'; +import { sendNotFound } from '../http/respond'; +import type { Route } from '../http/router'; +import { GHOSTTY_WASM, serveFile } from '../http/static-files'; + +/** Where the assets are on disk. */ +export interface AssetRouteDeps { + /** The client dist directory (or src/client in a dev checkout). */ + clientDistPath: string; + /** The wasm served at `/ghostty-vt-small.wasm`. */ + wasmPath: string; +} + +/** The `/dist/*` and wasm routes. */ +export function assetRoutes(deps: AssetRouteDeps): Route[] { + return [ + { + method: '*', + path: /^\/dist\/(.*)$/, + handle: ({ res, params }) => { + // Only files inside the dist directory; `..` segments that climb out are a 404. + const ownFile = path.resolve(deps.clientDistPath, params[0]); + if (ownFile.startsWith(deps.clientDistPath + path.sep)) { + serveFile(ownFile, res); + return; + } + sendNotFound(res); + }, + }, + { + method: '*', + path: `/${GHOSTTY_WASM}`, + handle: ({ res }) => serveFile(deps.wasmPath, res), + }, + ]; +} diff --git a/src/server/routes/config-routes.ts b/src/server/routes/config-routes.ts new file mode 100644 index 0000000..5e87d2b --- /dev/null +++ b/src/server/routes/config-routes.ts @@ -0,0 +1,22 @@ +// `GET /api/config`: the client-safe view of the config file (ADR 009, ADR 062). A route +// handler; the config is loaded per request so an edited file applies on the next tab reload. +import type { Config } from '../../config'; +import { toClientConfig } from '../../config'; +import { sendJson } from '../http/respond'; +import type { Route } from '../http/router'; + +/** What the config route needs: the loader, called once per request. */ +export interface ConfigRouteDeps { + loadConfig: () => Config; +} + +/** The config route. Only the client keys leave the server; shell, host, logs and env stay. */ +export function configRoutes(deps: ConfigRouteDeps): Route[] { + return [ + { + method: 'GET', + path: '/api/config', + handle: ({ res }) => sendJson(res, 200, toClientConfig(deps.loadConfig())), + }, + ]; +} diff --git a/src/server/routes/execute-route.ts b/src/server/routes/execute-route.ts new file mode 100644 index 0000000..8d2b4a3 --- /dev/null +++ b/src/server/routes/execute-route.ts @@ -0,0 +1,56 @@ +// `POST /s/:id/execute` with `{cmd, args, stdin?}`: runs the command in the session's base +// directory with the config's env and streams it back as NDJSON (ADR 062). A route handler; +// the process work is run-command.ts. +import type { Config } from '../../config'; +import { readJsonOrReply } from '../http/request-body'; +import { sendText } from '../http/respond'; +import type { Route } from '../http/router'; +import type { SessionStore } from '../sessions/session-store'; +import { runCommand, type SpawnFn } from './run-command'; +import { runningSessionOrReply } from './running-session'; + +/** What the execute route needs. */ +export interface ExecuteRouteDeps { + store: SessionStore; + /** Read per command, so the env follows the config file. */ + loadConfig: () => Config; + /** The child-process spawner; node's when omitted. */ + spawn?: SpawnFn; +} + +/** The execute route. */ +export function executeRoutes(deps: ExecuteRouteDeps): Route[] { + return [ + { + method: 'POST', + path: /^\/s\/([^/]+)\/execute$/, + handle: async ({ req, res, params }) => { + const session = runningSessionOrReply(req, res, deps.store, params[0]); + if (!session) return; + const read = await readJsonOrReply(req, res); + if (!read) return; + const { cmd, args, stdin } = read.value as { + cmd?: unknown; + args?: unknown; + stdin?: unknown; + }; + if (typeof cmd !== 'string' || cmd.length === 0) { + sendText(res, 400, 'invalid body'); + return; + } + if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) { + sendText(res, 400, 'invalid body'); + return; + } + const options = { + cmd, + args: args as string[], + stdin: typeof stdin === 'string' ? stdin : undefined, + cwd: session.baseDir, + env: () => ({ ...process.env, ...deps.loadConfig().env }), + }; + runCommand(req, res, options, deps.spawn); + }, + }, + ]; +} diff --git a/src/server/routes/page-routes.ts b/src/server/routes/page-routes.ts new file mode 100644 index 0000000..5cde363 --- /dev/null +++ b/src/server/routes/page-routes.ts @@ -0,0 +1,65 @@ +// The pages a browser opens: `/` redirects to a session, `/s/:id` serves the client page and +// `/p/:pid` finds a session by its shell's PID (ADR 062, deep-link SPEC). Route handlers. +import path from 'node:path'; +import { decodeId } from '../http/request-body'; +import { redirect, sendNotFound } from '../http/respond'; +import type { Route } from '../http/router'; +import { serveFile } from '../http/static-files'; +import { isValidId } from '../sessions/session-id'; +import type { SessionStore } from '../sessions/session-store'; + +/** What the page routes need. */ +export interface PageRouteDeps { + store: SessionStore; + /** The directory holding client.html. */ + clientDistPath: string; +} + +/** The root redirect and the `/s/:id` page. */ +export function pageRoutes(deps: PageRouteDeps): Route[] { + const { store } = deps; + const root: Route['handle'] = ({ res }) => { + // The last session a client used, else `main`, created on demand. + let targetId = store.lastUsedId; + if (!targetId || !store.has(targetId)) { + if (!store.has('main')) store.create('main'); + targetId = 'main'; + } + redirect(res, `/s/${targetId}`); + }; + return [ + { method: 'GET', path: '/', handle: root }, + { method: 'GET', path: '/index.html', handle: root }, + { + method: 'GET', + path: /^\/s\/([^/]+)$/, + handle: ({ res, params }) => { + const id = decodeId(params[0]); + if (!id || !isValidId(id) || !store.has(id)) { + sendNotFound(res); + return; + } + serveFile(path.resolve(deps.clientDistPath, 'client.html'), res); + }, + }, + ]; +} + +/** `GET /p/:pid`: a 302 to the session whose shell has that PID, else 404. */ +export function pidRoutes(deps: Pick<PageRouteDeps, 'store'>): Route[] { + return [ + { + method: 'GET', + path: /^\/p\/(\d+)$/, + handle: ({ res, params }) => { + const pid = parseInt(params[0], 10); + const session = Number.isFinite(pid) && pid > 0 ? deps.store.findByPid(pid) : undefined; + if (!session) { + sendNotFound(res); + return; + } + redirect(res, `/s/${session.id}`); + }, + }, + ]; +} diff --git a/src/server/routes/publish-route.ts b/src/server/routes/publish-route.ts new file mode 100644 index 0000000..38317d1 --- /dev/null +++ b/src/server/routes/publish-route.ts @@ -0,0 +1,49 @@ +// `POST /s/:id/publish`: an NDJSON request body, each valid JSON line broadcast to the +// session's event subscribers (ADR 062). A route handler built on the shared LineSplitter. +import { LineSplitter } from '../../ndjson'; +import { MAX_BODY } from '../http/request-body'; +import { sendText } from '../http/respond'; +import type { Route } from '../http/router'; +import type { SessionStore } from '../sessions/session-store'; +import { runningSessionOrReply } from './running-session'; + +/** What the publish route needs. */ +export interface PublishRouteDeps { + store: SessionStore; +} + +/** The publish route; it answers 204 once the request body ends. */ +export function publishRoutes(deps: PublishRouteDeps): Route[] { + return [ + { + method: 'POST', + path: /^\/s\/([^/]+)\/publish$/, + handle: ({ req, res, params }) => { + const session = runningSessionOrReply(req, res, deps.store, params[0]); + if (!session) return; + // One event per NDJSON line; invalid JSON lines are skipped, and a line that grows + // past 64 KB without a newline is dropped. + const broadcastIfJson = (line: string) => { + try { + JSON.parse(line); + session.broadcastEvent(line); + } catch { + // skip invalid JSON lines + } + }; + const lines = new LineSplitter({ maxLength: MAX_BODY }); + req.on('data', (chunk: Buffer) => { + for (const line of lines.push(chunk.toString('utf8'))) broadcastIfJson(line); + }); + req.on('end', () => { + const last = lines.flush(); + if (last) broadcastIfJson(last); + sendText(res, 204); + }); + req.on('error', () => { + sendText(res, 500); + }); + }, + }, + ]; +} diff --git a/src/server/routes/route-table.test.ts b/src/server/routes/route-table.test.ts new file mode 100644 index 0000000..7798395 --- /dev/null +++ b/src/server/routes/route-table.test.ts @@ -0,0 +1,57 @@ +// The route precedence test: the table lists every route in the old handleRequest if-chain's +// order, and paths that look alike resolve to the route the if-chain picked (ADR 062, ADR 064). +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_CONFIG } from '../../config'; +import { matchRoute } from '../http/router'; +import { SessionStore } from '../sessions/session-store'; +import { createRouteTable } from './route-table'; + +const table = createRouteTable({ + store: new SessionStore(), + loadConfig: () => ({ ...DEFAULT_CONFIG }), + wasmPath: '/w.wasm', + clientDistPath: '/dist', + onStop: () => {}, +}); + +const label = (i: number) => `${table[i].method} ${String(table[i].path)}`; + +describe('route table', () => { + test('lists the routes in the old if-chain order', () => { + expect(table.map((_, i) => label(i))).toEqual([ + 'OPTIONS /^/', + 'POST /api/server/stop', + 'GET /api/server/status', + 'GET /api/config', + 'GET /api/sessions', + 'POST /api/sessions', + '* /^\\/api\\/sessions\\/([^/]+)$/', + 'GET /', + 'GET /index.html', + 'GET /^\\/s\\/([^/]+)$/', + 'POST /^\\/s\\/([^/]+)\\/publish$/', + 'POST /^\\/s\\/([^/]+)\\/execute$/', + 'GET /^\\/p\\/(\\d+)$/', + '* /^\\/dist\\/(.*)$/', + '* /ghostty-vt-small.wasm', + ]); + }); + + test.each<[string, string, string | null]>([ + ['OPTIONS', '/api/server/stop', 'OPTIONS /^/'], + ['GET', '/api/server/stop', null], + ['PUT', '/api/sessions', null], + ['PUT', '/api/sessions/x', '* /^\\/api\\/sessions\\/([^/]+)$/'], + ['GET', '/api/sessions/', null], + ['POST', '/', null], + ['GET', '/s/x/publish', null], + ['POST', '/s/x', null], + ['GET', '/p/abc', null], + ['HEAD', '/dist/client.css', '* /^\\/dist\\/(.*)$/'], + ['POST', '/ghostty-vt-small.wasm', '* /ghostty-vt-small.wasm'], + ['GET', '/dist', null], + ])('%s %s → %s', (method, path, expected) => { + const match = matchRoute(table, method, path); + expect(match ? label(table.indexOf(match.route)) : null).toBe(expected); + }); +}); diff --git a/src/server/routes/route-table.ts b/src/server/routes/route-table.ts new file mode 100644 index 0000000..2e16129 --- /dev/null +++ b/src/server/routes/route-table.ts @@ -0,0 +1,60 @@ +// The webtty HTTP routes as one ordered table, and the request handler built from it +// (ADR 062). Wiring: the order below is the order of the old handleRequest if-chain, and the +// first match wins, so precedence is unchanged. +import type http from 'node:http'; +import type { Config } from '../../config'; +import { sendText } from '../http/respond'; +import { ANY_PATH, createRouter, type Route } from '../http/router'; +import type { SessionStore } from '../sessions/session-store'; +import { assetRoutes } from './asset-routes'; +import { configRoutes } from './config-routes'; +import { executeRoutes } from './execute-route'; +import { pageRoutes, pidRoutes } from './page-routes'; +import { publishRoutes } from './publish-route'; +import type { SpawnFn } from './run-command'; +import { serverRoutes } from './server-routes'; +import { sessionRoutes } from './session-routes'; + +/** Everything the routes need from the composition root. */ +export interface RouteDeps { + store: SessionStore; + /** Called per request (`/api/config`) and per command (`/execute`), never cached. */ + loadConfig: () => Config; + /** The wasm served at `/ghostty-vt-small.wasm`. */ + wasmPath: string; + /** The client dist directory. */ + clientDistPath: string; + /** Invoked by `POST /api/server/stop` after it replies. */ + onStop: () => void; + /** The child-process spawner for `/execute`; node's when omitted. */ + spawn?: SpawnFn; +} + +/** The CORS preflight: `204` with no body on any path. */ +const preflight: Route = { + method: 'OPTIONS', + path: ANY_PATH, + handle: ({ res }) => sendText(res, 204), +}; + +/** Every route, in the old if-chain's order. Unmatched requests end in the router's 404. */ +export function createRouteTable(deps: RouteDeps): Route[] { + return [ + preflight, // 1. OPTIONS on any path + ...serverRoutes(deps), // 2. POST /api/server/stop, 3. GET /api/server/status + ...configRoutes(deps), // 4. GET /api/config + ...sessionRoutes(deps), // 5. /api/sessions (GET, POST), 6. /api/sessions/:id + ...pageRoutes(deps), // 7. GET / and /index.html, 8. GET /s/:id + ...publishRoutes(deps), // 9. POST /s/:id/publish + ...executeRoutes(deps), // 10. POST /s/:id/execute + ...pidRoutes(deps), // 11. GET /p/:pid + ...assetRoutes(deps), // 12. /dist/*, 13. /ghostty-vt-small.wasm + ]; +} + +/** The server's request handler: the route table behind the router. */ +export function createRequestHandler( + deps: RouteDeps, +): (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> { + return createRouter(createRouteTable(deps)); +} diff --git a/src/server/routes/run-command.ts b/src/server/routes/run-command.ts new file mode 100644 index 0000000..e97aef0 --- /dev/null +++ b/src/server/routes/run-command.ts @@ -0,0 +1,85 @@ +// Runs one child process for `/s/:id/execute` and streams it to the HTTP reply as NDJSON: +// `{stream, data}` lines for stdout and stderr, then one `{exit}` line (ADR 062). A service; +// `spawn` is injected so tests drive a fake child. +import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process'; +import type http from 'node:http'; +import { sendText } from '../http/respond'; + +/** The `node:child_process` spawn signature run-command uses. */ +export type SpawnFn = ( + cmd: string, + args: string[], + options: { env: NodeJS.ProcessEnv; cwd: string; windowsHide: boolean }, +) => ChildProcess; + +/** The command to run and where. */ +export interface RunCommandOptions { + cmd: string; + args: string[]; + /** Written to the child's stdin before it is closed; stdin is closed either way. */ + stdin?: string; + cwd: string; + /** The child's environment, built inside the spawn guard so a failure there is a 500 too. */ + env: () => NodeJS.ProcessEnv; +} + +/** One NDJSON line. */ +const line = (value: unknown) => `${JSON.stringify(value)}\n`; + +/** + * Spawns `options.cmd` and streams it to `res`. A synchronous spawn failure answers + * `500 spawn error: …`; an asynchronous one (such as ENOENT) ends the stream with a stderr + * line and `{exit: 1, error, code?}`. The child is killed if the client goes away first. + */ +export function runCommand( + req: http.IncomingMessage, + res: http.ServerResponse, + options: RunCommandOptions, + spawn: SpawnFn = nodeSpawn, +): void { + let child: ChildProcess; + try { + child = spawn(options.cmd, options.args, { + env: options.env(), + cwd: options.cwd, + windowsHide: true, + }); + } catch (err) { + sendText(res, 500, `spawn error: ${String(err)}`); + return; + } + res.writeHead(200, { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + }); + // Set once the exit line is written: an 'error' and a 'close' can both arrive. + let done = false; + child.on('error', (err) => { + if (done) return; + done = true; + const msg = err.message; + const code = (err as NodeJS.ErrnoException).code; + res.write(line({ stream: 'stderr', data: `${msg}\n` })); + res.write(line({ exit: 1, error: msg, ...(code ? { code } : {}) })); + res.end(); + }); + if (typeof options.stdin === 'string') { + child.stdin?.write(options.stdin); + } + child.stdin?.end(); + child.stdout?.on('data', (chunk: Buffer) => { + res.write(line({ stream: 'stdout', data: chunk.toString() })); + }); + child.stderr?.on('data', (chunk: Buffer) => { + res.write(line({ stream: 'stderr', data: chunk.toString() })); + }); + child.on('close', (code: number | null) => { + if (done) return; + done = true; + res.write(line({ exit: code ?? 1 })); + res.end(); + }); + req.on('close', () => { + if (!done) child.kill(); + }); +} diff --git a/src/server/routes/running-session.ts b/src/server/routes/running-session.ts new file mode 100644 index 0000000..2873daa --- /dev/null +++ b/src/server/routes/running-session.ts @@ -0,0 +1,34 @@ +// The checks `/s/:id/publish` and `/s/:id/execute` share before touching a session: a +// decodable ID, a JSON content type, a known session and a running shell (ADR 062). A utility. +import type http from 'node:http'; +import { decodeId, hasJsonContentType } from '../http/request-body'; +import { sendBadRequest, sendNotFound, sendText } from '../http/respond'; +import type { Session } from '../sessions/session'; +import type { SessionStore } from '../sessions/session-store'; + +/** + * The session named by the raw path segment `rawId` if its shell is running. Otherwise it has + * answered `res` (400 bad ID or content type, 404 unknown session, 409 no PTY) and returns null. + */ +export function runningSessionOrReply( + req: http.IncomingMessage, + res: http.ServerResponse, + store: SessionStore, + rawId: string, +): Session | null { + const id = decodeId(rawId); + if (!id || !hasJsonContentType(req)) { + sendBadRequest(res); + return null; + } + const session = store.get(id); + if (!session) { + sendNotFound(res); + return null; + } + if (session.pty === null) { + sendText(res, 409, 'PTY not running'); + return null; + } + return session; +} diff --git a/src/server/routes/server-routes.ts b/src/server/routes/server-routes.ts new file mode 100644 index 0000000..8dc291e --- /dev/null +++ b/src/server/routes/server-routes.ts @@ -0,0 +1,30 @@ +// The server's own API: `POST /api/server/stop` and `GET /api/server/status` (ADR 062). +// Route handlers, in the order route-table.ts lists them. +import { getVersion } from '../../version'; +import { sendJson, sendText } from '../http/respond'; +import type { Route } from '../http/router'; + +/** What the server routes need from the composition root. */ +export interface ServerRouteDeps { + /** Shuts the server down; called after the reply is written. */ + onStop: () => void; +} + +/** The stop and status routes. */ +export function serverRoutes(deps: ServerRouteDeps): Route[] { + return [ + { + method: 'POST', + path: '/api/server/stop', + handle: ({ res }) => { + sendText(res, 200, 'stopping', { 'Content-Type': 'text/plain' }); + deps.onStop(); + }, + }, + { + method: 'GET', + path: '/api/server/status', + handle: ({ res }) => sendJson(res, 200, { version: getVersion() }), + }, + ]; +} diff --git a/src/server/routes/session-routes.ts b/src/server/routes/session-routes.ts new file mode 100644 index 0000000..60c463d --- /dev/null +++ b/src/server/routes/session-routes.ts @@ -0,0 +1,131 @@ +// The sessions REST API: list and create on `/api/sessions`, read, rename and delete on +// `/api/sessions/:id` (ADR 062). Route handlers over the SessionStore. +import fs from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { decodeId, readJsonOrReply } from '../http/request-body'; +import { sendBadRequest, sendJson, sendNotFound, sendText } from '../http/respond'; +import type { Route, RouteContext } from '../http/router'; +import { sessionToJson } from '../sessions/session'; +import { generateId, isValidId } from '../sessions/session-id'; +import type { SessionStore } from '../sessions/session-store'; +import { WS_CLOSE } from '../ws/close-codes'; + +/** What the session routes need. */ +export interface SessionRouteDeps { + store: SessionStore; +} + +/** The two session resources. */ +export function sessionRoutes(deps: SessionRouteDeps): Route[] { + const { store } = deps; + return [ + { + method: 'GET', + path: '/api/sessions', + handle: ({ res }) => sendJson(res, 200, store.list().map(sessionToJson)), + }, + { method: 'POST', path: '/api/sessions', handle: (ctx) => createSession(ctx, store) }, + // Any method: an undecodable ID is a 400 whatever the method, as the old if-chain checked + // it first; a method other than GET, PATCH or DELETE then ends in the 404 it fell through to. + { + method: '*', + path: /^\/api\/sessions\/([^/]+)$/, + handle: async (ctx) => { + const id = decodeId(ctx.params[0]); + if (!id) { + sendBadRequest(ctx.res); + return; + } + if (ctx.req.method === 'GET') readSession(ctx, store, id); + else if (ctx.req.method === 'PATCH') await renameSession(ctx, store, id); + else if (ctx.req.method === 'DELETE') deleteSession(ctx, store, id); + else sendNotFound(ctx.res); + }, + }, + ]; +} + +/** `POST /api/sessions` with `{ id?, baseDir? }`: 201 and the new session, or 400/409. */ +async function createSession({ req, res }: RouteContext, store: SessionStore): Promise<void> { + const read = await readJsonOrReply(req, res); + if (!read) return; + const body = read.value as { id?: string; baseDir?: string }; + + const id = body.id ?? generateId(); + if (!isValidId(id)) { + sendJson(res, 400, { error: `invalid id: ${id}` }); + return; + } + if (store.has(id)) { + sendJson(res, 409, { error: `session already exists: ${id}` }); + return; + } + const baseDir = body.baseDir ?? homedir(); + if (!path.isAbsolute(baseDir)) { + sendJson(res, 400, { error: 'baseDir must be an absolute path' }); + return; + } + if (!fs.existsSync(baseDir)) { + sendJson(res, 400, { error: `baseDir does not exist: ${baseDir}` }); + return; + } + if (!fs.statSync(baseDir).isDirectory()) { + sendJson(res, 400, { error: `baseDir is not a directory: ${baseDir}` }); + return; + } + sendJson(res, 201, sessionToJson(store.create(id, baseDir))); +} + +/** `GET /api/sessions/:id`. */ +function readSession({ res }: RouteContext, store: SessionStore, id: string): void { + const session = store.get(id); + if (!session) { + sendNotFound(res); + return; + } + sendJson(res, 200, sessionToJson(session)); +} + +/** `PATCH /api/sessions/:id` with `{ id }`: renames the session. The body is read first. */ +async function renameSession( + { req, res }: RouteContext, + store: SessionStore, + id: string, +): Promise<void> { + const read = await readJsonOrReply(req, res); + if (!read) return; + const body = read.value as { id?: string }; + + const session = store.get(id); + if (!session) { + sendNotFound(res); + return; + } + const newId = body.id; + if (!newId) { + sendText(res, 400, 'missing id'); + return; + } + if (!isValidId(newId)) { + sendJson(res, 400, { error: `invalid id: ${newId}` }); + return; + } + if (store.has(newId)) { + sendJson(res, 409, { error: `session already exists: ${newId}` }); + return; + } + store.rename(id, newId); + sendJson(res, 200, sessionToJson(session)); +} + +/** `DELETE /api/sessions/:id`: closes its sockets and shell; 204 with the sessions left. */ +function deleteSession({ res }: RouteContext, store: SessionStore, id: string): void { + const session = store.remove(id); + if (!session) { + sendNotFound(res); + return; + } + session.close(WS_CLOSE.SESSION_GONE, 'session deleted'); + sendText(res, 204, undefined, { 'X-Sessions-Remaining': String(store.size) }); +} diff --git a/src/test-support/route-harness.ts b/src/test-support/route-harness.ts index 892c430..14c884e 100644 --- a/src/test-support/route-harness.ts +++ b/src/test-support/route-harness.ts @@ -5,7 +5,8 @@ import fs from 'node:fs'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import path from 'node:path'; -import { handleRequest } from '../server/routes'; +import { loadConfig } from '../config'; +import { createRequestHandler } from '../server/routes/route-table'; import type { Session } from '../server/sessions/session'; import { SessionStore } from '../server/sessions/session-store'; import { FakePty } from './fake-pty'; @@ -81,8 +82,15 @@ export class RouteHarness { fs.writeFileSync(path.join(this.tmpHome, 'secret.txt'), 'secret'); const server = http.createServer((req, res) => { - const onStop = () => this.stops++; - handleRequest(req, res, this.store, this.wasmPath, distDir, onStop).catch(() => { + // Built per request so a store replaced by reset() is the one the routes see. + const handleRequest = createRequestHandler({ + store: this.store, + loadConfig, + wasmPath: this.wasmPath, + clientDistPath: distDir, + onStop: () => this.stops++, + }); + handleRequest(req, res).catch(() => { if (!res.headersSent) { res.writeHead(500); res.end('Internal Server Error'); From 2cf99c73190871c61179aade3c1f8bb6292f9fbb Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:53:29 -0400 Subject: [PATCH 183/295] test(server): run-command with a fake spawn and the publish stream error run-command.test.ts drives a fake ChildProcess: the stdout, stderr and exit NDJSON lines, a synchronous spawn throw and a failing env (500), an asynchronous spawn error (exit 1 with code), stdin, the kill on client close, env and cwd. publish-route.test.ts covers the 500 on a request stream error, which the golden tests cannot reach over a socket (ADR 062, ADR 064). --- src/server/routes/publish-route.test.ts | 40 +++++++ src/server/routes/run-command.test.ts | 152 ++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/server/routes/publish-route.test.ts create mode 100644 src/server/routes/run-command.test.ts diff --git a/src/server/routes/publish-route.test.ts b/src/server/routes/publish-route.test.ts new file mode 100644 index 0000000..7f16a3c --- /dev/null +++ b/src/server/routes/publish-route.test.ts @@ -0,0 +1,40 @@ +// Unit tests for the publish route's stream handling that the golden tests cannot reach over +// a socket: a request stream error answers 500 (ADR 062). +import { describe, expect, spyOn, test } from 'bun:test'; +import http from 'node:http'; +import { Socket } from 'node:net'; +import { FakePty } from '../../test-support/fake-pty'; +import { recordResponse } from '../../test-support/response-recorder'; +import { SessionStore } from '../sessions/session-store'; +import { publishRoutes } from './publish-route'; + +/** A JSON POST to the session `live`, whose shell is a fake. */ +function setup() { + const store = new SessionStore(); + const session = store.create('live', '/'); + session.pty = new FakePty(); + const broadcast = spyOn(session, 'broadcastEvent').mockImplementation(() => {}); + const req = new http.IncomingMessage(new Socket()); + req.headers = { 'content-type': 'application/json' }; + const { res, calls } = recordResponse(); + const [route] = publishRoutes({ store }); + route.handle({ req, res, pathname: '/s/live/publish', params: ['live'] }); + return { req, calls, broadcast }; +} + +describe('publish route', () => { + test('broadcasts each JSON line, including an unterminated last one, then 204', () => { + const { req, calls, broadcast } = setup(); + req.emit('data', Buffer.from('{"a":1}\nnot json\n{"b"')); + req.emit('data', Buffer.from(':2}')); + req.emit('end'); + expect(broadcast.mock.calls).toEqual([['{"a":1}'], ['{"b":2}']]); + expect(calls).toEqual([['writeHead', 204], ['end']]); + }); + + test('a request stream error answers 500', () => { + const { req, calls } = setup(); + req.emit('error', new Error('reset')); + expect(calls).toEqual([['writeHead', 500], ['end']]); + }); +}); diff --git a/src/server/routes/run-command.test.ts b/src/server/routes/run-command.test.ts new file mode 100644 index 0000000..9b71c08 --- /dev/null +++ b/src/server/routes/run-command.test.ts @@ -0,0 +1,152 @@ +// Tests for run-command with a fake spawn: the NDJSON lines for stdout, stderr and exit, a +// synchronous and an asynchronous spawn failure, stdin, the kill on client close, env and cwd. +import { describe, expect, spyOn, test } from 'bun:test'; +import { ChildProcess } from 'node:child_process'; +import http from 'node:http'; +import { Socket } from 'node:net'; +import { PassThrough } from 'node:stream'; +import { recordResponse } from '../../test-support/response-recorder'; +import { type RunCommandOptions, runCommand, type SpawnFn } from './run-command'; + +const NDJSON_HEAD = [ + 'writeHead', + 200, + { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' }, +]; + +/** A ChildProcess with in-memory pipes; nothing is spawned. */ +function fakeChild() { + const child = new ChildProcess(); + const stdin = new PassThrough(); + const written: string[] = []; + stdin.on('data', (c: Buffer) => written.push(c.toString())); + // Bun's ChildProcess exposes the pipes as getters, so they are defined rather than assigned. + for (const [name, stream] of [ + ['stdin', stdin], + ['stdout', new PassThrough()], + ['stderr', new PassThrough()], + ] as const) { + Object.defineProperty(child, name, { value: stream }); + } + const kill = spyOn(child, 'kill').mockImplementation(() => true); + return { child, stdin, written, kill }; +} + +/** Runs `options` against a fake child and records the reply, the spawn calls and writes. */ +function run(options: Partial<RunCommandOptions> = {}, spawnImpl?: SpawnFn) { + const fake = fakeChild(); + const spawned: Parameters<SpawnFn>[] = []; + const spawn: SpawnFn = + spawnImpl ?? + ((...a) => { + spawned.push(a); + return fake.child; + }); + const req = new http.IncomingMessage(new Socket()); + const { res, calls } = recordResponse(); + spyOn(res, 'write').mockImplementation((chunk: unknown) => { + calls.push(['write', chunk]); + return true; + }); + const full: RunCommandOptions = { + cmd: 'echo', + args: ['hi'], + cwd: '/work', + env: () => ({ A: '1' }), + ...options, + }; + runCommand(req, res, full, spawn); + return { ...fake, req, res, calls, spawned }; +} + +describe('runCommand', () => { + test('spawns with the command, args, env, cwd and windowsHide', () => { + const { spawned } = run({ cmd: 'ls', args: ['-l', 'x'], cwd: '/tmp/x' }); + expect(spawned).toEqual([ + ['ls', ['-l', 'x'], { env: { A: '1' }, cwd: '/tmp/x', windowsHide: true }], + ]); + }); + + test('streams stdout and stderr chunks, then the exit code', () => { + const { child, calls } = run(); + (child.stdout as PassThrough).emit('data', Buffer.from('out')); + (child.stderr as PassThrough).emit('data', Buffer.from('err')); + child.emit('close', 3); + expect(calls).toEqual([ + NDJSON_HEAD, + ['write', '{"stream":"stdout","data":"out"}\n'], + ['write', '{"stream":"stderr","data":"err"}\n'], + ['write', '{"exit":3}\n'], + ['end'], + ]); + }); + + test('a null exit code (killed by a signal) is reported as 1', () => { + const { child, calls } = run(); + child.emit('close', null); + expect(calls.slice(1)).toEqual([['write', '{"exit":1}\n'], ['end']]); + }); + + test('writes stdin when given and closes it either way', async () => { + const withInput = run({ stdin: 'abc' }); + const without = run(); + await Bun.sleep(0); + expect(withInput.written).toEqual(['abc']); + expect(withInput.stdin.writableEnded).toBe(true); + expect(without.written).toEqual([]); + expect(without.stdin.writableEnded).toBe(true); + }); + + test('an asynchronous spawn error ends the stream with stderr and exit 1, once', () => { + const { child, calls } = run(); + child.emit('error', Object.assign(new Error('spawn nope ENOENT'), { code: 'ENOENT' })); + child.emit('close', 0); + child.emit('error', new Error('again')); + expect(calls).toEqual([ + NDJSON_HEAD, + ['write', '{"stream":"stderr","data":"spawn nope ENOENT\\n"}\n'], + ['write', '{"exit":1,"error":"spawn nope ENOENT","code":"ENOENT"}\n'], + ['end'], + ]); + }); + + test('an error without a code leaves `code` out', () => { + const { child, calls } = run(); + child.emit('error', new Error('boom')); + expect(calls[2]).toEqual(['write', '{"exit":1,"error":"boom"}\n']); + }); + + test('a synchronous spawn throw is a 500 with no stream', () => { + const { calls } = run({}, () => { + throw new Error('bad'); + }); + expect(calls).toEqual([ + ['writeHead', 500], + ['end', 'spawn error: Error: bad'], + ]); + }); + + test('an env that throws is answered like a spawn throw', () => { + const { calls, spawned } = run({ + env: () => { + throw new Error('config'); + }, + }); + expect(spawned).toEqual([]); + expect(calls).toEqual([ + ['writeHead', 500], + ['end', 'spawn error: Error: config'], + ]); + }); + + test('the child is killed if the client goes away before it exits, not after', () => { + const early = run(); + early.req.emit('close'); + expect(early.kill).toHaveBeenCalledTimes(1); + + const late = run(); + late.child.emit('close', 0); + late.req.emit('close'); + expect(late.kill).not.toHaveBeenCalled(); + }); +}); From 1704ec43baf1fbec23ec42bf3be379da1e3ce82a Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:56:45 -0400 Subject: [PATCH 184/295] refactor(client): the terminal canvas selectors get one module, layout/terminal-canvas.ts The fit, the edge extender, the composer and the kitty overlay each found the terminal's canvas with a querySelector of their own. The two selector strings are now named constants with terminalCanvas(); every call site keeps the exact string it used. --- src/client/features/compose/composer.ts | 7 +-- .../features/kitty/graphics/overlay-canvas.ts | 7 +-- src/client/index.ts | 3 +- src/client/layout/edge-extender.ts | 9 ++-- src/client/layout/terminal-canvas.test.ts | 45 +++++++++++++++++++ src/client/layout/terminal-canvas.ts | 20 +++++++++ 6 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 src/client/layout/terminal-canvas.test.ts create mode 100644 src/client/layout/terminal-canvas.ts diff --git a/src/client/features/compose/composer.ts b/src/client/features/compose/composer.ts index e5da47b..1207241 100644 --- a/src/client/features/compose/composer.ts +++ b/src/client/features/compose/composer.ts @@ -8,6 +8,7 @@ * ghostty-web's own compositionend handling. */ +import { terminalCanvas } from '../../layout/terminal-canvas'; import { DisposableStore, listen } from '../../vendor/ghostty-web'; import { type OverlayBox, overlayBox } from './composer-geometry'; @@ -116,7 +117,7 @@ export class Composer { private placeOverlay(spot: { left: number; top: number }): void { const o = this.overlay; - const canvas = this.terminalCanvas(); + const canvas = terminalCanvas(this.container); const box = overlayBox( spot, this.cell(), @@ -133,10 +134,6 @@ export class Composer { const metrics = this.term.renderer?.getMetrics(); return { width: metrics?.width ?? 0, height: metrics?.height ?? 0 }; } - - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-edge])'); - } } function setPx(el: HTMLElement, prop: keyof OverlayBox, value: number): void { diff --git a/src/client/features/kitty/graphics/overlay-canvas.ts b/src/client/features/kitty/graphics/overlay-canvas.ts index d876e7d..8e6b211 100644 --- a/src/client/features/kitty/graphics/overlay-canvas.ts +++ b/src/client/features/kitty/graphics/overlay-canvas.ts @@ -2,6 +2,7 @@ // repaints on the frame after anything changed, follows the terminal's scroll, size and // pixel ratio, and frees its backing store while there is nothing to draw. +import { TERMINAL_CANVAS_UNDER_OVERLAY, terminalCanvas } from '../../../layout/terminal-canvas'; import { DisposableStore, listen, toDisposable } from '../../../vendor/ghostty-web'; import { type CellSize, @@ -79,14 +80,10 @@ export class OverlayCanvas { this.store.dispose(); } - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-kitty]):not([data-edge])'); - } - private readonly tick = (): void => { this.frame = this.deps.window.requestAnimationFrame(this.tick); if (this.source.placements.size === 0 && !this.canvas.width) return; - const canvas = this.terminalCanvas(); + const canvas = terminalCanvas(this.container, TERMINAL_CANVAS_UNDER_OVERLAY); if (!canvas) return; const cell = cellSize(this.term); const signature = [ diff --git a/src/client/index.ts b/src/client/index.ts index e1bc756..35959f3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -14,6 +14,7 @@ import { WheelStream } from './features/scroll/wheel-stream'; import { TabTitle } from './features/tab-title/tab-title'; import { EdgeExtender } from './layout/edge-extender'; import { computeInsets } from './layout/fit-insets'; +import { terminalCanvas } from './layout/terminal-canvas'; import { FitAddon, Ghostty, Terminal, watchDevicePixelRatio } from './vendor/ghostty-web'; interface KeyboardBinding { @@ -154,7 +155,7 @@ function fit(): void { // already sent the new cell pixels with resize; this sends them when only the // pixels changed, for Ghostty's size reports (ADR 042). term.syncCellPixelSize(); - const canvas = container.querySelector('canvas:not([data-edge])') as HTMLElement | null; + const canvas = terminalCanvas(container); if (!canvas) return; const insets = computeInsets( Math.max(0, container.clientWidth - canvas.offsetWidth), diff --git a/src/client/layout/edge-extender.ts b/src/client/layout/edge-extender.ts index 6d2856a..a09a49d 100644 --- a/src/client/layout/edge-extender.ts +++ b/src/client/layout/edge-extender.ts @@ -8,6 +8,7 @@ * cells have: a dark sidebar stays dark, the main area stays light. */ import { computeEdgeRects, type Rect } from './edge-rects'; +import { terminalCanvas } from './terminal-canvas'; /** A canvas and the rows of it in view, in canvas pixels. */ export interface VisibleArea { @@ -93,7 +94,7 @@ export class EdgeExtender { /** Reposition the strips. Call after every fit, when the canvas or padding may have moved. */ update(): void { if (this.disposed) return; - const canvas = this.terminalCanvas(); + const canvas = terminalCanvas(this.container); if (!canvas) return; const rects = computeEdgeRects( { @@ -124,13 +125,9 @@ export class EdgeExtender { this.right?.el.remove(); } - private terminalCanvas(): HTMLCanvasElement | null { - return this.container.querySelector('canvas:not([data-edge])'); - } - private readonly paint = (): void => { this.frame = null; - const terminal = this.terminalCanvas(); + const terminal = terminalCanvas(this.container); const area = this.visibleArea() ?? (terminal diff --git a/src/client/layout/terminal-canvas.test.ts b/src/client/layout/terminal-canvas.test.ts new file mode 100644 index 0000000..0116165 --- /dev/null +++ b/src/client/layout/terminal-canvas.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../test-support/dom-window'; +import { TERMINAL_CANVAS, TERMINAL_CANVAS_UNDER_OVERLAY, terminalCanvas } from './terminal-canvas'; + +let dom: DomWindow; +beforeEach(() => { + dom = createDomWindow(); +}); +afterEach(() => dom.close()); + +/** A container holding the canvases webtty adds, in `order`, each tagged by its role. */ +function container(order: Array<'edge' | 'kitty' | 'terminal'>): HTMLElement { + const el = dom.document.createElement('div'); + for (const role of order) { + const canvas = dom.document.createElement('canvas'); + canvas.id = role; + if (role !== 'terminal') canvas.dataset[role] = ''; + el.append(canvas); + } + return el; +} + +describe('terminal-canvas', () => { + test('the selectors are the exact strings the call sites have always used', () => { + expect(TERMINAL_CANVAS).toBe('canvas:not([data-edge])'); + expect(TERMINAL_CANVAS_UNDER_OVERLAY).toBe('canvas:not([data-kitty]):not([data-edge])'); + }); + + test('skips the edge strips by default', () => { + expect(terminalCanvas(container(['edge', 'edge', 'terminal']))?.id).toBe('terminal'); + }); + + test('the default selector takes a kitty overlay placed before the terminal canvas', () => { + expect(terminalCanvas(container(['kitty', 'terminal']))?.id).toBe('kitty'); + }); + + test('the overlay selector skips the overlay and the strips', () => { + const el = container(['edge', 'kitty', 'terminal']); + expect(terminalCanvas(el, TERMINAL_CANVAS_UNDER_OVERLAY)?.id).toBe('terminal'); + }); + + test('is null without a terminal canvas', () => { + expect(terminalCanvas(container(['edge']))).toBeNull(); + }); +}); diff --git a/src/client/layout/terminal-canvas.ts b/src/client/layout/terminal-canvas.ts new file mode 100644 index 0000000..31245ec --- /dev/null +++ b/src/client/layout/terminal-canvas.ts @@ -0,0 +1,20 @@ +// The selectors that find the terminal's own canvas among the canvases webtty adds to the +// container: the edge strips (`data-edge`, ADR 035) and the kitty overlay (`data-kitty`, +// ADR 032) (constants + util). Each call site keeps the exact selector it used before. + +/** The terminal canvas as the fit, the edge extender and the composer see it: not an edge strip. */ +export const TERMINAL_CANVAS = 'canvas:not([data-edge])'; + +/** + * The terminal canvas as the kitty overlay sees it: neither an edge strip nor the overlay + * itself, which is a canvas in the same container. + */ +export const TERMINAL_CANVAS_UNDER_OVERLAY = 'canvas:not([data-kitty]):not([data-edge])'; + +/** The first canvas in `container` matching `selector`, the terminal's by default. */ +export function terminalCanvas( + container: ParentNode, + selector: string = TERMINAL_CANVAS, +): HTMLCanvasElement | null { + return container.querySelector<HTMLCanvasElement>(selector); +} From ea97a8c7deb279663b462465739b08502cc3ffde Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:58:21 -0400 Subject: [PATCH 185/295] refactor(client): config loader, terminal options and platform helpers move to app/ index.ts declared its own copies of the config, theme and key binding types, built the Terminal options inline, and read the platform and the focus state in place. app/client-config.ts now takes the types from src/config (type-only imports, so the bundle has no Node code) and loads /api/config through an injected fetch; app/terminal-options.ts maps the config onto the Terminal options; app/platform.ts holds detectMac and isTerminalFocused. Each has its tests. --- src/client/app/client-config.test.ts | 23 ++++++ src/client/app/client-config.ts | 42 +++++++++++ src/client/app/platform.test.ts | 69 ++++++++++++++++++ src/client/app/platform.ts | 28 ++++++++ src/client/app/terminal-options.test.ts | 49 +++++++++++++ src/client/app/terminal-options.ts | 35 +++++++++ src/client/index.ts | 95 +++---------------------- 7 files changed, 254 insertions(+), 87 deletions(-) create mode 100644 src/client/app/client-config.test.ts create mode 100644 src/client/app/client-config.ts create mode 100644 src/client/app/platform.test.ts create mode 100644 src/client/app/platform.ts create mode 100644 src/client/app/terminal-options.test.ts create mode 100644 src/client/app/terminal-options.ts diff --git a/src/client/app/client-config.test.ts b/src/client/app/client-config.test.ts new file mode 100644 index 0000000..4cd75ed --- /dev/null +++ b/src/client/app/client-config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test'; +import { type ConfigFetch, loadClientConfig } from './client-config'; + +describe('loadClientConfig', () => { + test('fetches /api/config and returns its body as is', async () => { + const body = { cols: 80, rows: 24, padding: 4, scrollback: 1024 }; + const urls: string[] = []; + const fetch: ConfigFetch = async (url) => { + urls.push(url); + return { json: async () => body }; + }; + const config: object = await loadClientConfig(fetch); + expect(urls).toEqual(['/api/config']); + expect(config).toBe(body); + }); + + test('a failed fetch rejects, so the page stays blank as before', async () => { + const fetch: ConfigFetch = async () => { + throw new Error('offline'); + }; + await expect(loadClientConfig(fetch)).rejects.toThrow('offline'); + }); +}); diff --git a/src/client/app/client-config.ts b/src/client/app/client-config.ts new file mode 100644 index 0000000..76750b8 --- /dev/null +++ b/src/client/app/client-config.ts @@ -0,0 +1,42 @@ +// The config the client reads, and the one call that fetches it from the server +// (service + types, ADR 009, ADR 060). The keys are src/config's own types, imported as +// types only, so the bundle carries no Node code. + +import type { Config } from '../../config'; + +export type { KeyboardBinding, Theme } from '../../config'; + +/** + * The config keys the client reads from `/api/config`. Replaced by the src/config + * ClientConfig at C-F, once the server branch has merged. + */ +export type ClientConfig = Pick< + Config, + | 'cols' + | 'rows' + | 'fontSize' + | 'fontFamily' + | 'cursorStyle' + | 'cursorStyleBlink' + | 'scrollbackLines' + | 'theme' + | 'copyOnSelect' + | 'rightClickBehavior' + | 'clipboardWrite' + | 'bellTitle' + | 'desktopNotifications' + | 'mouseScrollSpeed' + | 'padding' + | 'keyboardBindings' +>; + +/** The part of `fetch` the loader uses, so a test passes a fake. */ +export type ConfigFetch = (url: string) => Promise<Pick<Response, 'json'>>; + +/** + * The config as the server resolves it, fetched on every page load so an edited config + * file takes effect on the next reload (ADR 009). The body is trusted as is, as before. + */ +export async function loadClientConfig(fetch: ConfigFetch): Promise<ClientConfig> { + return fetch('/api/config').then((r) => r.json()); +} diff --git a/src/client/app/platform.test.ts b/src/client/app/platform.test.ts new file mode 100644 index 0000000..8a808cd --- /dev/null +++ b/src/client/app/platform.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../test-support/dom-window'; +import { detectMac, isTerminalFocused } from './platform'; + +describe('detectMac', () => { + test.each([ + [{ platform: 'MacIntel' }, true], + [{ platform: 'iPad' }, true], + [{ platform: 'Win32' }, false], + [{ platform: 'Linux x86_64' }, false], + // Client Hints win over the legacy field when the browser has them + [{ platform: 'MacIntel', userAgentData: { platform: 'Windows' } }, false], + [{ platform: 'Win32', userAgentData: { platform: 'macOS' } }, true], + // An empty Client Hints platform falls back to the legacy field + [{ platform: 'MacIntel', userAgentData: { platform: '' } }, true], + ])('%o is mac: %p', (navigator, mac) => { + expect(detectMac(navigator)).toBe(mac); + }); +}); + +describe('isTerminalFocused', () => { + let dom: DomWindow; + let container: HTMLElement; + let inside: HTMLTextAreaElement; + let outside: HTMLInputElement; + + beforeEach(() => { + dom = createDomWindow(); + const { document } = dom; + container = document.createElement('div'); + inside = document.createElement('textarea'); + outside = document.createElement('input'); + container.append(inside); + document.body.append(container, outside); + }); + afterEach(() => dom.close()); + + /** The document with its window focus and tab visibility set by the test. */ + function doc(hasFocus: boolean, visibilityState: DocumentVisibilityState = 'visible') { + const { document } = dom; + return { + visibilityState, + hasFocus: () => hasFocus, + get activeElement() { + return document.activeElement; + }, + }; + } + + test('focus inside the container, the window focused and the tab shown', () => { + inside.focus(); + expect(isTerminalFocused(doc(true), container)).toBe(true); + }); + + test('focus outside the container', () => { + outside.focus(); + expect(isTerminalFocused(doc(true), container)).toBe(false); + }); + + test('the window not focused', () => { + inside.focus(); + expect(isTerminalFocused(doc(false), container)).toBe(false); + }); + + test('the tab hidden', () => { + inside.focus(); + expect(isTerminalFocused(doc(true, 'hidden'), container)).toBe(false); + }); +}); diff --git a/src/client/app/platform.ts b/src/client/app/platform.ts new file mode 100644 index 0000000..485f79d --- /dev/null +++ b/src/client/app/platform.ts @@ -0,0 +1,28 @@ +// What the client asks of the browser's platform and focus state (util; ADR 053, +// ADR 054). The navigator and the document come in as arguments. + +import { isMacPlatform } from '../features/keyboard/shortcut-map'; + +/** The navigator fields the platform check reads; `userAgentData` is Chromium-only. */ +export interface PlatformSource { + readonly platform: string; + readonly userAgentData?: { readonly platform: string }; +} + +/** + * Whether Ghostty's macOS key defaults apply (ADR 054): the User-Agent Client Hints + * platform where the browser has it, else the legacy `navigator.platform`. + */ +export function detectMac(navigator: PlatformSource): boolean { + return isMacPlatform(navigator.userAgentData?.platform || navigator.platform); +} + +/** The terminal has focus: its tab is shown, its window focused, and focus is inside it (ADR 053). */ +export function isTerminalFocused( + doc: Pick<Document, 'visibilityState' | 'hasFocus' | 'activeElement'>, + container: Pick<Node, 'contains'>, +): boolean { + return ( + doc.visibilityState === 'visible' && doc.hasFocus() && container.contains(doc.activeElement) + ); +} diff --git a/src/client/app/terminal-options.test.ts b/src/client/app/terminal-options.test.ts new file mode 100644 index 0000000..abd6842 --- /dev/null +++ b/src/client/app/terminal-options.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test'; +import type { Ghostty } from '../vendor/ghostty-web'; +import type { ClientConfig } from './client-config'; +import { toTerminalOptions } from './terminal-options'; + +const config: ClientConfig = { + cols: 100, + rows: 30, + fontSize: 14, + fontFamily: 'Menlo', + cursorStyle: 'bar', + cursorStyleBlink: true, + scrollbackLines: 5000, + theme: { background: '#000000', padding: '#111111' }, + copyOnSelect: false, + rightClickBehavior: 'paste', + clipboardWrite: false, + bellTitle: true, + desktopNotifications: false, + mouseScrollSpeed: 2, + padding: 8, + keyboardBindings: [{ key: 'enter', mods: ['shift'], chars: '\n' }], +}; + +describe('toTerminalOptions', () => { + test('maps the config onto the Terminal options (snapshot)', () => { + // The engine is passed through untouched; a tagged object stands in for it. + const ghostty = { engine: 'test' } as unknown as Ghostty; + const theme = { background: '#000000' }; + const options = toTerminalOptions(config, theme, ghostty); + const { ghostty: engine, theme: terminalTheme, ...rest } = options; + expect(engine).toBe(ghostty); + expect(terminalTheme).toBe(theme); + expect(rest).toEqual({ + cols: 100, + rows: 30, + cursorStyle: 'bar', + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo', + scrollbackLines: 5000, + mouseScrollSpeed: 2, + copyOnSelect: false, + rightClickBehavior: 'paste', + clipboardWrite: false, + desktopNotifications: false, + }); + }); +}); diff --git a/src/client/app/terminal-options.ts b/src/client/app/terminal-options.ts new file mode 100644 index 0000000..1f79d04 --- /dev/null +++ b/src/client/app/terminal-options.ts @@ -0,0 +1,35 @@ +// The vendored Terminal's options from the client config (util, pure; ADR 011, ADR 046, +// ADR 051, ADR 052, ADR 056). The engine and the translated theme come in as arguments. + +import type { Ghostty, ITerminalOptions } from '../vendor/ghostty-web'; +import type { ClientConfig } from './client-config'; + +/** The options `new Terminal()` gets, one place mapping webtty's config names to its own. */ +export function toTerminalOptions( + config: ClientConfig, + terminalTheme: Record<string, string>, + ghostty: Ghostty, +): ITerminalOptions { + return { + ghostty, + cols: config.cols, + rows: config.rows, + cursorStyle: config.cursorStyle, + cursorBlink: config.cursorStyleBlink, + fontSize: config.fontSize, + fontFamily: config.fontFamily, + scrollbackLines: config.scrollbackLines, + mouseScrollSpeed: config.mouseScrollSpeed, + // The terminal copies a mouse selection on mouseup, once, only when this is on (ADR 011). + // A drag the app takes, while it tracks the mouse and Shift is up, selects nothing. + copyOnSelect: config.copyOnSelect, + // What a right-click the app does not take does; the terminal handles every + // contextmenu, and asks for a paste through onPasteRequest (ADR 051) + rightClickBehavior: config.rightClickBehavior, + // Programs may write the clipboard with OSC 52; off, Ghostty ignores it (ADR 052) + clipboardWrite: config.clipboardWrite, + // Programs may ask for desktop notifications with OSC 9 / OSC 777 (ADR 056) + desktopNotifications: config.desktopNotifications, + theme: terminalTheme, + }; +} diff --git a/src/client/index.ts b/src/client/index.ts index 35959f3..c57f882 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,10 +1,13 @@ +import { type KeyboardBinding, loadClientConfig } from './app/client-config'; +import { detectMac, isTerminalFocused } from './app/platform'; +import { toTerminalOptions } from './app/terminal-options'; import { toTerminalTheme } from './app/terminal-theme'; import { OutputAck } from './connection/output-ack'; import { ReplayWindow } from './connection/replay-window'; import { ClipboardWriter } from './features/clipboard/clipboard-writer'; import { Composer } from './features/compose/composer'; import { FindBar } from './features/find/find-bar'; -import { isMacPlatform, shortcutFor } from './features/keyboard/shortcut-map'; +import { shortcutFor } from './features/keyboard/shortcut-map'; import { KittyGraphics } from './features/kitty/graphics/kitty-graphics'; import { activates, browserNotifications } from './features/notifications/browser-notifications'; import { DesktopNotifier } from './features/notifications/desktop-notifier'; @@ -17,63 +20,9 @@ import { computeInsets } from './layout/fit-insets'; import { terminalCanvas } from './layout/terminal-canvas'; import { FitAddon, Ghostty, Terminal, watchDevicePixelRatio } from './vendor/ghostty-web'; -interface KeyboardBinding { - key: string; - mods?: string[]; - chars: string; -} - -interface Theme { - background?: string; - foreground?: string; - cursor?: string; - selection?: string; - selectionForeground?: string; - searchBackground?: string; - searchForeground?: string; - searchSelectedBackground?: string; - searchSelectedForeground?: string; - padding?: string; - black?: string; - red?: string; - green?: string; - yellow?: string; - blue?: string; - purple?: string; - cyan?: string; - white?: string; - brightBlack?: string; - brightRed?: string; - brightGreen?: string; - brightYellow?: string; - brightBlue?: string; - brightPurple?: string; - brightCyan?: string; - brightWhite?: string; -} - -interface ClientConfig { - cols: number; - rows: number; - fontSize: number; - fontFamily: string; - cursorStyle: 'block' | 'bar' | 'underline'; - cursorStyleBlink: boolean; - scrollbackLines: number; - theme: Theme; - copyOnSelect: boolean; - rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none'; - clipboardWrite: boolean; - bellTitle: boolean; - desktopNotifications: boolean; - mouseScrollSpeed: number; - padding: number; - keyboardBindings: KeyboardBinding[]; -} - const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; -const config: ClientConfig = await fetch('/api/config').then((r) => r.json()); +const config = await loadClientConfig(fetch); // Whether the output being parsed is the replay sent on connect (ADR 053) const replay = new ReplayWindow(); @@ -94,28 +43,7 @@ const ghostty = await Ghostty.load('/ghostty-vt-small.wasm'); // setting and not a terminal colour, out of the theme it receives (ADR 036). const terminalTheme = toTerminalTheme(config.theme ?? {}); -const term = new Terminal({ - ghostty, - cols: config.cols, - rows: config.rows, - cursorStyle: config.cursorStyle, - cursorBlink: config.cursorStyleBlink, - fontSize: config.fontSize, - fontFamily: config.fontFamily, - scrollbackLines: config.scrollbackLines, - mouseScrollSpeed: config.mouseScrollSpeed, - // The terminal copies a mouse selection on mouseup, once, only when this is on (ADR 011). - // A drag the app takes, while it tracks the mouse and Shift is up, selects nothing. - copyOnSelect: config.copyOnSelect, - // What a right-click the app does not take does; the terminal handles every - // contextmenu, and asks for a paste through onPasteRequest (ADR 051) - rightClickBehavior: config.rightClickBehavior, - // Programs may write the clipboard with OSC 52; off, Ghostty ignores it (ADR 052) - clipboardWrite: config.clipboardWrite, - // Programs may ask for desktop notifications with OSC 9 / OSC 777 (ADR 056) - desktopNotifications: config.desktopNotifications, - theme: terminalTheme, -}); +const term = new Terminal(toTerminalOptions(config, terminalTheme, ghostty)); const container = document.getElementById('terminal') as HTMLElement; // The canvas paints theme.background; the container shows only in the padding around @@ -240,11 +168,7 @@ for (const type of ['keydown', 'mousedown', 'paste', 'drop'] as const) { window.addEventListener(type, () => clipboardWriter.userInput(), { capture: true }); } -/** The terminal has focus: its tab is shown, its window focused, and focus is inside it (ADR 053). */ -const terminalFocused = (): boolean => - document.visibilityState === 'visible' && - document.hasFocus() && - container.contains(document.activeElement); +const terminalFocused = (): boolean => isTerminalFocused(document, container); // The title and the bell mark (ADR 053). Ghostty clears the mark when the terminal // gains focus (a window focus reaches the container as a focusin too; Chrome switching @@ -490,10 +414,7 @@ term.onPasteRequest(() => { // configured-binding listener above, so skip a combo the user has already bound // (including an intentionally empty binding meant to consume the key and suppress the // default). Both run before the input handler, which listens on the container too. -const mac = isMacPlatform( - (navigator as Navigator & { userAgentData?: { platform: string } }).userAgentData?.platform || - navigator.platform, -); +const mac = detectMac(navigator); container.addEventListener( 'keydown', (e: KeyboardEvent) => { From 7b9300b462469775db126f46ef47e328aff6db1b Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:59:40 -0400 Subject: [PATCH 186/295] refactor(server): createServer factory, server paths and a thin entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/server/index.ts did all its work at import time, so the server could only be tested as a subprocess. Split it into: - app.ts: createServer({ loadConfig, store, spawnPty, paths, onStop }) returns { httpServer, wss, shutdown }; the route table, the 500 fallback and the WebSocket server are wired in the old order, and shutdown keeps the 1 s grace before onStop. - server-paths.ts: projectRoot, clientRoot and wasmPath from an injected existsSync; replaces findGhosttyWasm, which now throws MissingWasmError instead of exiting. - index.ts: main() reads PORT and the config, prints the same "Error: Could not find ghostty-vt-small.wasm." and exits 1 when the wasm is missing, creates the store (onEmpty → shutdown), installs the uncaught/SIGINT handlers and listens. onStop is process.exit(0). loadConfig is still called per request and per spawn. The findGhosttyWasm spyOn cases move to server-paths.test.ts against a fake existsSync for the src and dist layouts; app.test.ts runs the server in-process on port 0. The subprocess route and websocket tests are unchanged. Refs: ADR 062 --- src/server/app.test.ts | 144 +++++++++++++++++++++++++++ src/server/app.ts | 89 +++++++++++++++++ src/server/http/static-files.test.ts | 42 +------- src/server/http/static-files.ts | 23 +---- src/server/index.ts | 122 ++++++++++------------- src/server/server-paths.test.ts | 104 +++++++++++++++++++ src/server/server-paths.ts | 81 +++++++++++++++ 7 files changed, 472 insertions(+), 133 deletions(-) create mode 100644 src/server/app.test.ts create mode 100644 src/server/app.ts create mode 100644 src/server/server-paths.test.ts create mode 100644 src/server/server-paths.ts diff --git a/src/server/app.test.ts b/src/server/app.test.ts new file mode 100644 index 0000000..2b3203b --- /dev/null +++ b/src/server/app.test.ts @@ -0,0 +1,144 @@ +import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; +import { WebSocket } from 'ws'; +import { type Config, DEFAULT_CONFIG } from '../config'; +import { FakePty } from '../test-support/fake-pty'; +import { cleanupTmpHome, makeTmpHome } from '../test-support/server-harness'; +import { getVersion } from '../version'; +import { createServer, type WebttyServer } from './app'; +import type { ServerPaths } from './server-paths'; +import { SessionStore } from './sessions/session-store'; + +let tmpDir = ''; +let paths: ServerPaths; +let running: WebttyServer | null = null; + +beforeAll(() => { + tmpDir = makeTmpHome('app'); + fs.writeFileSync(path.join(tmpDir, 'client.html'), '<p>client</p>'); + fs.writeFileSync(path.join(tmpDir, 'ghostty-vt-small.wasm'), 'wasm'); + paths = { + projectRoot: tmpDir, + clientRoot: tmpDir, + wasmPath: path.join(tmpDir, 'ghostty-vt-small.wasm'), + }; +}); + +afterAll(() => cleanupTmpHome(tmpDir)); + +afterEach(async () => { + const server = running; + running = null; + if (!server?.httpServer.listening) return; + server.httpServer.closeAllConnections(); + await new Promise<void>((resolve) => server.httpServer.close(() => resolve())); +}); + +/** Builds the server with a fake PTY and a counting stop hook, listening on port 0. */ +async function start(loadConfig: () => Config = () => DEFAULT_CONFIG) { + const log: string[] = []; + const pty = new FakePty(log); + const stops: string[] = []; + const store = new SessionStore(); + const server = createServer({ + loadConfig, + store, + paths, + spawnPty: (cols, rows) => { + log.push(`spawn ${cols}x${rows}`); + return pty; + }, + onStop: () => stops.push('stop'), + }); + running = server; + await new Promise<void>((resolve) => server.httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = server.httpServer.address() as AddressInfo; + return { server, store, log, pty, stops, port, base: `http://127.0.0.1:${port}` }; +} + +/** Polls `cond` every 5 ms for up to 2 s. */ +async function until(cond: () => boolean): Promise<void> { + const deadline = Date.now() + 2000; + while (!cond()) { + if (Date.now() > deadline) throw new Error('condition not met in time'); + await Bun.sleep(5); + } +} + +describe('createServer', () => { + test('serves the routes once listening: GET /api/server/status', async () => { + const { base } = await start(); + const res = await fetch(`${base}/api/server/status`); + expect(res.status).toBe(200); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); + expect(await res.json()).toEqual({ version: getVersion() }); + }); + + test('serves the client page and the wasm from the given paths', async () => { + const { base } = await start(); + await fetch(`${base}/api/sessions`, { method: 'POST', body: '{"id":"x"}' }); + expect(await (await fetch(`${base}/s/x`)).text()).toBe('<p>client</p>'); + expect(await (await fetch(`${base}/ghostty-vt-small.wasm`)).text()).toBe('wasm'); + }); + + test('calls loadConfig on every config request, never caching it', async () => { + let calls = 0; + const { base } = await start(() => { + calls++; + return { ...DEFAULT_CONFIG, fontSize: 10 + calls }; + }); + const first = await (await fetch(`${base}/api/config`)).json(); + const second = await (await fetch(`${base}/api/config`)).json(); + expect([first.fontSize, second.fontSize]).toEqual([11, 12]); + }); + + test('answers 500 when a route throws before writing', async () => { + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + const { base } = await start(() => { + throw new Error('bad config'); + }); + const res = await fetch(`${base}/api/config`); + expect(res.status).toBe(500); + expect(await res.text()).toBe('Internal Server Error'); + expect(errorSpy).toHaveBeenCalledWith('[webtty] unhandled route error:', expect.any(Error)); + errorSpy.mockRestore(); + }); + + test('a PTY socket spawns its shell with the injected spawner', async () => { + const { store, log, port } = await start(); + store.create('x'); + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/x/pty?cols=90&rows=30`); + await until(() => log.includes('spawn 90x30')); + ws.terminate(); + }); + + test('shutdown closes sockets as the server stops, kills shells, then calls onStop', async () => { + const { server, store, log, stops, port } = await start(); + store.create('x'); + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/x/pty`); + const closed = new Promise<string>((resolve) => + ws.on('close', (_code, r) => resolve(String(r))), + ); + await until(() => log.some((e) => e.startsWith('spawn'))); + + server.shutdown(); + + // The reason is the one closeAll(SERVER_STOPPED) sends; Bun's ws client reports the + // 1001 code as 1000, so the code itself is pinned by session.test.ts. + expect(await closed).toBe('server stopped'); + expect(log).toContain('pty.kill'); + await until(() => stops.length > 0); + expect(server.httpServer.listening).toBe(false); + }); + + test('POST /api/server/stop replies, then shuts the server down', async () => { + const { base, stops, server } = await start(); + const res = await fetch(`${base}/api/server/stop`, { method: 'POST' }); + expect(await res.text()).toBe('stopping'); + server.httpServer.closeAllConnections(); + await until(() => stops.length > 0); + expect(stops).toEqual(['stop']); + }); +}); diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..6cc4006 --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,89 @@ +// The server's composition root: builds the HTTP server, the route table and the WebSocket +// server around one SessionStore, and returns them with the shutdown that stops them. A +// factory with no import-time work, so tests run the whole server in-process (ADR 062). +import http from 'node:http'; +import type { WebSocketServer } from 'ws'; +import type { Config } from '../config'; +import { spawnForSession } from '../pty'; +import { packageRunner } from '../runtime'; +import { createRequestHandler } from './routes/route-table'; +import type { ServerPaths } from './server-paths'; +import type { SpawnPty } from './sessions/session'; +import type { SessionStore } from './sessions/session-store'; +import { WS_CLOSE } from './ws/close-codes'; +import { createWebSocketServer } from './ws/ws-upgrade'; + +/** How long shutdown waits for open HTTP connections before calling `onStop` anyway. */ +export const SHUTDOWN_GRACE_MS = 1000; + +/** Everything the server is built from; the entry passes the production values. */ +export interface ServerDeps { + /** Called per request and per spawn, never cached, so config edits apply on the next reload (ADR 009). */ + loadConfig: () => Config; + /** The one registry of sessions; its `onEmpty` should call {@link WebttyServer.shutdown}. */ + store: SessionStore; + /** Starts a session's shell; `spawnForSession` from src/pty when omitted. */ + spawnPty?: SpawnPty; + /** Where the client files and the wasm are. */ + paths: ServerPaths; + /** Called once the server has stopped (the entry exits the process). */ + onStop: () => void; +} + +/** The built server. Nothing listens until the caller calls `httpServer.listen`. */ +export interface WebttyServer { + httpServer: http.Server; + wss: WebSocketServer; + /** Kills every shell, closes every socket and the servers, then calls `onStop`. */ + shutdown: () => void; +} + +/** + * Builds the webtty server: the request handler (with its 500 fallback) on a new HTTP server, + * and the WebSocket server on its `upgrade` event. + * + * @param deps - The config loader, the session store, the PTY spawner, the paths and the stop hook. + * @returns The HTTP server, the WebSocket server and `shutdown`. + */ +export function createServer(deps: ServerDeps): WebttyServer { + const { store, loadConfig } = deps; + + // 1. The routes; `POST /api/server/stop` shuts the server down after it replies. + const handleRequest = createRequestHandler({ + store, + loadConfig, + wasmPath: deps.paths.wasmPath, + clientDistPath: deps.paths.clientRoot, + onStop: () => shutdown(), + }); + + // 2. The HTTP server; a route that throws ends in a bare 500 if nothing was sent yet. + const httpServer = http.createServer((req, res) => { + handleRequest(req, res).catch((err) => { + console.error('[webtty] unhandled route error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); + }); + + // 3. The WebSocket server on the HTTP server's upgrade event. + const wss = createWebSocketServer(httpServer, store, { + loadConfig, + spawnPty: deps.spawnPty ?? spawnForSession, + runner: packageRunner(), + }); + + function shutdown(): void { + store.closeAll(WS_CLOSE.SERVER_STOPPED, 'server stopped'); + wss.close(); + const shutdownTimeout = setTimeout(deps.onStop, SHUTDOWN_GRACE_MS); + httpServer.close(() => { + clearTimeout(shutdownTimeout); + deps.onStop(); + }); + } + + return { httpServer, wss, shutdown }; +} diff --git a/src/server/http/static-files.test.ts b/src/server/http/static-files.test.ts index 3ace329..99c54f7 100644 --- a/src/server/http/static-files.test.ts +++ b/src/server/http/static-files.test.ts @@ -1,9 +1,6 @@ import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; -import path from 'node:path'; -import { findGhosttyWasm, mimeType, serveFile } from './static-files'; - -const PROJECT_ROOT = path.resolve(import.meta.dir, '..', '..', '..'); +import { mimeType, serveFile } from './static-files'; describe('mimeType', () => { test('returns correct type for known extensions', () => { @@ -29,43 +26,6 @@ describe('mimeType', () => { }); }); -describe('findGhosttyWasm', () => { - const VENDORED = path.join('src', 'client', 'vendor', 'ghostty-vt', 'ghostty-vt-small.wasm'); - const BUILT = path.join('dist', 'ghostty-vt-small.wasm'); - - test('prefers the wasm copied into dist/ by the build', () => { - const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => String(p).endsWith(BUILT)); - expect(findGhosttyWasm(PROJECT_ROOT)).toBe(path.join(PROJECT_ROOT, BUILT)); - existsSpy.mockRestore(); - }); - - test('falls back to the vendored wasm when dist/ has none', () => { - const realExistsSync = fs.existsSync.bind(fs); - const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => { - const s = String(p); - return s.endsWith(BUILT) ? false : realExistsSync(s); - }); - const wasmPath = findGhosttyWasm(PROJECT_ROOT); - expect(wasmPath).toBe(path.join(PROJECT_ROOT, VENDORED)); - expect(realExistsSync(wasmPath)).toBe(true); - existsSpy.mockRestore(); - }); - - test('exits naming the missing file', () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(false); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - findGhosttyWasm(PROJECT_ROOT); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(errorSpy).toHaveBeenCalledWith('Error: Could not find ghostty-vt-small.wasm.'); - existsSpy.mockRestore(); - exitSpy.mockRestore(); - errorSpy.mockRestore(); - }); -}); - describe('serveFile', () => { test('serves file with correct content type', async () => { const existsSpy = spyOn(fs, 'readFile').mockImplementation((( diff --git a/src/server/http/static-files.ts b/src/server/http/static-files.ts index a1f66a0..29063f8 100644 --- a/src/server/http/static-files.ts +++ b/src/server/http/static-files.ts @@ -1,5 +1,5 @@ -// Static files for the HTTP server: MIME types, the wasm lookup and serveFile (ADR 062). -// A small service module; the caller passes the package root and file paths in. +// Static files for the HTTP server: MIME types, the wasm's file name and serveFile (ADR 062). +// A small service module; where the files are on disk is server-paths.ts's job. import fs from 'node:fs'; import type http from 'node:http'; import path from 'node:path'; @@ -31,25 +31,6 @@ export function mimeType(filePath: string): string { /** The wasm the client loads: Ghostty's official libghostty-vt (ADR 042, ADR 047). */ export const GHOSTTY_WASM = 'ghostty-vt-small.wasm'; -/** - * Locates {@link GHOSTTY_WASM}, preferring the copy the build puts in `dist/` (npm install) - * over the vendored source copy (local dev). Exits the process if neither exists. - * - * @returns The absolute path to the wasm file. - * @throws Exits the process with code 1 if the wasm cannot be found. - */ -export function findGhosttyWasm(projectRoot: string): string { - const candidates = [ - path.join(projectRoot, 'dist', GHOSTTY_WASM), - path.join(projectRoot, 'src', 'client', 'vendor', 'ghostty-vt', GHOSTTY_WASM), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - console.error(`Error: Could not find ${GHOSTTY_WASM}.`); - process.exit(1); -} - /** * Reads `filePath` from disk and writes it to `res` with the correct Content-Type header. * Responds with 404 if the file cannot be read. diff --git a/src/server/index.ts b/src/server/index.ts index dc2581a..96c6655 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,81 +1,61 @@ -import fs from 'node:fs'; -import http from 'node:http'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +// The server entry (dist/server/index.js): reads the environment and the config, builds the +// server with createServer, listens, and installs the process handlers. A thin entry; the +// wiring is in app.ts (ADR 062). import { loadConfig } from '../config'; -import { findGhosttyWasm } from './http/static-files'; -import { createRequestHandler } from './routes/route-table'; +import { createServer, type WebttyServer } from './app'; +import { MissingWasmError, resolveServerPaths, type ServerPaths } from './server-paths'; import { SessionStore } from './sessions/session-store'; -import { WS_CLOSE } from './ws/close-codes'; -import { createWebSocketServer } from './ws/ws-upgrade'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const config = loadConfig(); -const HTTP_PORT = Number(process.env.PORT) || config.port; -// PORT is consumed for our own listen port above; clear it so it doesn't leak -// into PTY shells and `/execute` invocations, which inherit process.env. -delete process.env.PORT; -// 'localhost' resolves to ::1 (IPv6) on modern macOS/Node; bind to 127.0.0.1 instead -// but keep 'localhost' as the display host so browser URLs use it as intended. -const HTTP_HOST_DISPLAY = config.host; -const HTTP_HOST = config.host === 'localhost' ? '127.0.0.1' : config.host; - -const projectRoot = path.resolve(__dirname, '..', '..'); -const wasmPath = findGhosttyWasm(projectRoot); -const builtDistPath = path.join(projectRoot, 'dist'); -const srcClientPath = path.join(projectRoot, 'src', 'client'); -const clientDistPath = fs.existsSync(path.join(builtDistPath, 'client.html')) - ? builtDistPath - : srcClientPath; - -// The one registry of sessions. Once the last shell exits the server stops itself. -const store = new SessionStore({ onEmpty: shutdown }); - -function shutdown() { - store.closeAll(WS_CLOSE.SERVER_STOPPED, 'server stopped'); - wss.close(); - const exit = () => process.exit(0); - const shutdownTimeout = setTimeout(exit, 1000); - httpServer.close(() => { - clearTimeout(shutdownTimeout); - exit(); - }); +/** Resolves the server's files, or prints which one is missing and exits 1. */ +function pathsOrExit(): ServerPaths { + try { + return resolveServerPaths(); + } catch (err) { + if (!(err instanceof MissingWasmError)) throw err; + console.error(`Error: ${err.message}`); + process.exit(1); + } } -process.on('uncaughtException', (err) => { - console.error('[webtty] uncaughtException:', err); -}); -process.on('unhandledRejection', (reason) => { - console.error('[webtty] unhandledRejection:', reason); -}); - -const handleRequest = createRequestHandler({ - store, - loadConfig, - wasmPath, - clientDistPath, - onStop: shutdown, -}); +/** Starts webtty on the configured (or `PORT`) host and port. */ +function main(): void { + const config = loadConfig(); + const port = Number(process.env.PORT) || config.port; + // PORT is consumed for our own listen port above; clear it so it doesn't leak + // into PTY shells and `/execute` invocations, which inherit process.env. + delete process.env.PORT; + // 'localhost' resolves to ::1 (IPv6) on modern macOS/Node; bind to 127.0.0.1 instead + // but keep 'localhost' as the display host so browser URLs use it as intended. + const displayHost = config.host; + const host = config.host === 'localhost' ? '127.0.0.1' : config.host; + + const paths = pathsOrExit(); + + // The one registry of sessions. Once the last shell exits the server stops itself. + const store = new SessionStore({ onEmpty: () => server.shutdown() }); + + process.on('uncaughtException', (err) => { + console.error('[webtty] uncaughtException:', err); + }); + process.on('unhandledRejection', (reason) => { + console.error('[webtty] unhandledRejection:', reason); + }); -const httpServer = http.createServer((req, res) => { - handleRequest(req, res).catch((err) => { - console.error('[webtty] unhandled route error:', err); - if (!res.headersSent) { - res.writeHead(500); - res.end('Internal Server Error'); - } + const server: WebttyServer = createServer({ + loadConfig, + store, + paths, + onStop: () => process.exit(0), }); -}); -const wss = createWebSocketServer(httpServer, store); + process.on('SIGINT', () => { + console.log('\n\nShutting down...'); + server.shutdown(); + }); -process.on('SIGINT', () => { - console.log('\n\nShutting down...'); - shutdown(); -}); + server.httpServer.listen(port, host, () => { + console.log(`listening on http://${displayHost}:${port}`); + }); +} -httpServer.listen(HTTP_PORT, HTTP_HOST, () => { - console.log(`listening on http://${HTTP_HOST_DISPLAY}:${HTTP_PORT}`); -}); +main(); diff --git a/src/server/server-paths.test.ts b/src/server/server-paths.test.ts new file mode 100644 index 0000000..888d9e2 --- /dev/null +++ b/src/server/server-paths.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + findClientRoot, + findWasm, + MissingWasmError, + projectRootOf, + resolveServerPaths, +} from './server-paths'; + +const ROOT = path.resolve('/pkg'); +const BUILT_WASM = path.join(ROOT, 'dist', 'ghostty-vt-small.wasm'); +const VENDORED_WASM = path.join( + ROOT, + 'src', + 'client', + 'vendor', + 'ghostty-vt', + 'ghostty-vt-small.wasm', +); +const BUILT_HTML = path.join(ROOT, 'dist', 'client.html'); + +/** An existsSync that sees only `files`, and records every path it was asked about. */ +function fakeFs(...files: string[]) { + const asked: string[] = []; + const existsSync = (p: string) => { + asked.push(p); + return files.includes(p); + }; + return { existsSync, asked }; +} + +describe('projectRootOf', () => { + test.each([ + ['src/server (dev checkout)', path.join(ROOT, 'src', 'server')], + ['dist/server (bundled entry)', path.join(ROOT, 'dist', 'server')], + ])('is two levels above %s', (_layout, moduleDir) => { + expect(projectRootOf(moduleDir)).toBe(ROOT); + }); +}); + +describe('findWasm', () => { + test('prefers the wasm copied into dist/ by the build', () => { + const { existsSync, asked } = fakeFs(BUILT_WASM, VENDORED_WASM); + expect(findWasm(ROOT, existsSync)).toBe(BUILT_WASM); + expect(asked).toEqual([BUILT_WASM]); + }); + + test('falls back to the vendored wasm when dist/ has none', () => { + const { existsSync, asked } = fakeFs(VENDORED_WASM); + expect(findWasm(ROOT, existsSync)).toBe(VENDORED_WASM); + expect(asked).toEqual([BUILT_WASM, VENDORED_WASM]); + }); + + test('throws naming the missing file when neither exists', () => { + const { existsSync } = fakeFs(); + expect(() => findWasm(ROOT, existsSync)).toThrow(MissingWasmError); + expect(() => findWasm(ROOT, existsSync)).toThrow('Could not find ghostty-vt-small.wasm.'); + }); +}); + +describe('findClientRoot', () => { + test('is dist/ once the build has put client.html there', () => { + expect(findClientRoot(ROOT, fakeFs(BUILT_HTML).existsSync)).toBe(path.join(ROOT, 'dist')); + }); + + test('is src/client/ in a checkout that has not been built', () => { + expect(findClientRoot(ROOT, fakeFs().existsSync)).toBe(path.join(ROOT, 'src', 'client')); + }); +}); + +describe('resolveServerPaths', () => { + test('the built layout: dist/server/index.js with everything in dist/', () => { + const { existsSync } = fakeFs(BUILT_WASM, BUILT_HTML); + expect(resolveServerPaths(path.join(ROOT, 'dist', 'server'), existsSync)).toEqual({ + projectRoot: ROOT, + clientRoot: path.join(ROOT, 'dist'), + wasmPath: BUILT_WASM, + }); + }); + + test('the source layout: src/server/index.ts with the vendored wasm and src/client', () => { + const { existsSync } = fakeFs(VENDORED_WASM); + expect(resolveServerPaths(path.join(ROOT, 'src', 'server'), existsSync)).toEqual({ + projectRoot: ROOT, + clientRoot: path.join(ROOT, 'src', 'client'), + wasmPath: VENDORED_WASM, + }); + }); + + test('throws MissingWasmError when the wasm is in neither place', () => { + expect(() => resolveServerPaths(path.join(ROOT, 'src', 'server'), fakeFs().existsSync)).toThrow( + MissingWasmError, + ); + }); + + test('defaults to this checkout, whose vendored wasm is committed', () => { + const paths = resolveServerPaths(); + expect(paths.projectRoot).toBe(path.resolve(import.meta.dir, '..', '..')); + expect(fs.existsSync(paths.wasmPath)).toBe(true); + expect(fs.existsSync(path.join(paths.clientRoot, 'client.html'))).toBe(true); + }); +}); diff --git a/src/server/server-paths.ts b/src/server/server-paths.ts new file mode 100644 index 0000000..75c9b7b --- /dev/null +++ b/src/server/server-paths.ts @@ -0,0 +1,81 @@ +// Where the server finds its files: the package root, the client page and bundle, and the +// libghostty-vt wasm. A pure util over an injected `existsSync`; the one module that knows how +// deep src/server and dist/server sit (ADR 062). +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GHOSTTY_WASM } from './http/static-files'; + +/** The files the server serves, resolved once at startup. */ +export interface ServerPaths { + /** The package root: the checkout in development, the installed package from npm. */ + projectRoot: string; + /** The directory holding client.html and the bundle: `dist/` once built, else `src/client/`. */ + clientRoot: string; + /** The wasm served at `/ghostty-vt-small.wasm`. */ + wasmPath: string; +} + +/** The one filesystem call path resolution makes, injected so tests can lay out either tree. */ +export type ExistsSync = (p: string) => boolean; + +/** Thrown when neither the built nor the vendored wasm exists; the entry prints it and exits 1. */ +export class MissingWasmError extends Error { + constructor() { + super(`Could not find ${GHOSTTY_WASM}.`); + this.name = 'MissingWasmError'; + } +} + +/** + * The package root for a module in `src/server/` or in `dist/server/` (the bundled entry): + * both sit two levels below it. + * + * @param moduleDir - The directory of the running server module. + */ +export function projectRootOf(moduleDir: string): string { + return path.resolve(moduleDir, '..', '..'); +} + +/** + * Locates {@link GHOSTTY_WASM}, preferring the copy the build puts in `dist/` (npm install) + * over the vendored source copy (local dev). + * + * @throws {MissingWasmError} if neither exists. + */ +export function findWasm(projectRoot: string, existsSync: ExistsSync): string { + const candidates = [ + path.join(projectRoot, 'dist', GHOSTTY_WASM), + path.join(projectRoot, 'src', 'client', 'vendor', 'ghostty-vt', GHOSTTY_WASM), + ]; + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) throw new MissingWasmError(); + return found; +} + +/** + * The client directory: `dist/` when the build has put client.html there, else `src/client/`, + * which serves the unbundled page in a fresh checkout. + */ +export function findClientRoot(projectRoot: string, existsSync: ExistsSync): string { + const builtDistPath = path.join(projectRoot, 'dist'); + return existsSync(path.join(builtDistPath, 'client.html')) + ? builtDistPath + : path.join(projectRoot, 'src', 'client'); +} + +/** + * Resolves every path the server needs. The production defaults are this module's own + * directory and the real filesystem. + * + * @throws {MissingWasmError} if the wasm cannot be found. + */ +export function resolveServerPaths( + moduleDir: string = path.dirname(fileURLToPath(import.meta.url)), + existsSync: ExistsSync = fs.existsSync, +): ServerPaths { + const projectRoot = projectRootOf(moduleDir); + const wasmPath = findWasm(projectRoot, existsSync); + const clientRoot = findClientRoot(projectRoot, existsSync); + return { projectRoot, clientRoot, wasmPath }; +} From edf154bdf1b072696bad9cbb9cb9b82a8e1d32a4 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 15:59:58 -0400 Subject: [PATCH 187/295] refactor(client): ClientEnv adapter and ClientContext types in app/ app/browser-env.ts names every global the client root touches, with browserEnv() returning the page's own (the engine loader and the Terminal constructor as factories), so the root can later run on fakes or a happy-dom window. app/client-context.ts holds ClientContext, Feature and the installer shape the feature modules will take (ADR 060). index.ts already loads the config, the engine and the terminal through the env. --- src/client/app/browser-env.test.ts | 84 ++++++++++++++++++++++++++++++ src/client/app/browser-env.ts | 60 +++++++++++++++++++++ src/client/app/client-context.ts | 32 ++++++++++++ src/client/index.ts | 11 ++-- 4 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/client/app/browser-env.test.ts create mode 100644 src/client/app/browser-env.ts create mode 100644 src/client/app/client-context.ts diff --git a/src/client/app/browser-env.test.ts b/src/client/app/browser-env.test.ts new file mode 100644 index 0000000..3996eea --- /dev/null +++ b/src/client/app/browser-env.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDomWindow, type DomWindow } from '../test-support/dom-window'; +import { Ghostty, Terminal } from '../vendor/ghostty-web'; +import { browserEnv } from './browser-env'; + +const WASM = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../vendor/ghostty-vt/ghostty-vt-small.wasm', +); + +let dom: DomWindow; +beforeEach(() => { + dom = createDomWindow(); +}); +afterEach(() => dom.close()); + +describe('browserEnv', () => { + test("returns the window's own globals", () => { + const { window } = dom; + const env = browserEnv(window); + expect(env.window).toBe(window); + expect(env.document).toBe(window.document); + expect(env.navigator).toBe(window.navigator); + expect(env.WebSocket).toBe(window.WebSocket); + expect(env.ResizeObserver).toBe(window.ResizeObserver); + expect(env.performance).toBe(window.performance); + expect(env.window.location.pathname).toBe('/s/test'); + }); + + test('Notification is the constructor where there is one, else undefined', () => { + const { window } = dom; + class FakeNotification {} + Object.defineProperty(window, 'Notification', { value: undefined, configurable: true }); + expect(browserEnv(window).Notification).toBeUndefined(); + Object.defineProperty(window, 'Notification', { value: FakeNotification, configurable: true }); + expect(browserEnv(window).Notification).toBe(FakeNotification as never); + }); + + test('fetch and the timers are called on the window', async () => { + const { window } = dom; + const calls: string[] = []; + function record(name: string, result: unknown) { + return function (this: unknown, ...args: unknown[]) { + calls.push( + `${name} ${this === window} ${args.filter((a) => a !== undefined && typeof a !== 'function')}`, + ); + return result; + }; + } + const response = { ok: true }; + Object.assign(window, { + fetch: record('fetch', Promise.resolve(response)), + setTimeout: record('setTimeout', 7), + clearTimeout: record('clearTimeout', undefined), + requestAnimationFrame: record('requestAnimationFrame', 9), + cancelAnimationFrame: record('cancelAnimationFrame', undefined), + }); + const env = browserEnv(window); + expect(await env.fetch('/api/config')).toBe(response as never); + expect(env.setTimeout(() => {}, 500)).toBe(7); + env.clearTimeout(7); + expect(env.requestAnimationFrame(() => {})).toBe(9); + env.cancelAnimationFrame(9); + expect(calls).toEqual([ + 'fetch true /api/config', + 'setTimeout true 500', + 'clearTimeout true 7', + 'requestAnimationFrame true ', + 'cancelAnimationFrame true 9', + ]); + }); + + test('loadEngine loads libghostty-vt and createTerminal builds the vendored Terminal', async () => { + const env = browserEnv(dom.window); + const ghostty = await env.loadEngine(WASM); + expect(ghostty).toBeInstanceOf(Ghostty); + const term = env.createTerminal({ ghostty, cols: 40, rows: 10 }); + expect(term).toBeInstanceOf(Terminal); + expect([term.cols, term.rows]).toEqual([40, 10]); + term.dispose(); + }); +}); diff --git a/src/client/app/browser-env.ts b/src/client/app/browser-env.ts new file mode 100644 index 0000000..8707e9b --- /dev/null +++ b/src/client/app/browser-env.ts @@ -0,0 +1,60 @@ +// Every browser global the client root touches, behind one interface, with the page's +// own as the production value (adapter, ADR 060, ADR 064). A test passes fakes, or a +// happy-dom window, so the whole root runs without a browser. + +import { + Ghostty, + type ITerminalOptions, + type ListenerTarget, + Terminal, +} from '../vendor/ghostty-web'; + +/** The window as the root uses it: its URL, pixel ratio, close and focus, and its listeners. */ +export interface ClientWindow + extends Pick<Window, 'location' | 'devicePixelRatio' | 'matchMedia' | 'close' | 'focus'>, + ListenerTarget {} + +/** The globals the client root reads, each one the browser's own in production. */ +export interface ClientEnv { + window: ClientWindow; + /** The page's document, including `fonts.ready` (ADR 031). */ + document: Document; + navigator: Navigator; + fetch(input: string, init?: RequestInit): Promise<Response>; + WebSocket: typeof WebSocket; + ResizeObserver: typeof ResizeObserver; + /** Undefined where the page has no Notification API (http, iOS Safari; ADR 056). */ + Notification: typeof Notification | undefined; + performance: Pick<Performance, 'now'>; + setTimeout(handler: () => void, ms: number): number; + clearTimeout(id: number): void; + requestAnimationFrame(callback: FrameRequestCallback): number; + cancelAnimationFrame(id: number): void; + /** Loads libghostty-vt from the URL the server serves it at (ADR 041, ADR 047). */ + loadEngine(url: string): Promise<Ghostty>; + /** Builds the vendored terminal; a test returns a fake. */ + createTerminal(options: ITerminalOptions): Terminal; +} + +/** + * The real globals of `win`, the page's window by default. Functions are wrapped so they + * are called on `win`, as the browser requires of `fetch` and the timers. + */ +export function browserEnv(win: Window & typeof globalThis = window): ClientEnv { + return { + window: win, + document: win.document, + navigator: win.navigator, + fetch: (input, init) => win.fetch(input, init), + WebSocket: win.WebSocket, + ResizeObserver: win.ResizeObserver, + Notification: typeof win.Notification === 'undefined' ? undefined : win.Notification, + performance: win.performance, + setTimeout: (handler, ms) => win.setTimeout(handler, ms), + clearTimeout: (id) => win.clearTimeout(id), + requestAnimationFrame: (callback) => win.requestAnimationFrame(callback), + cancelAnimationFrame: (id) => win.cancelAnimationFrame(id), + loadEngine: (url) => Ghostty.load(url), + createTerminal: (options) => new Terminal(options), + }; +} diff --git a/src/client/app/client-context.ts b/src/client/app/client-context.ts new file mode 100644 index 0000000..c59e5c9 --- /dev/null +++ b/src/client/app/client-context.ts @@ -0,0 +1,32 @@ +// What every client feature may use, and the shape an installed feature returns (types, +// ADR 060). Handles of other parts (the connection, the fitter, the find bar) are not +// here: the root passes them to the installers that need them, as `deps`. + +import type { Disposable, Terminal } from '../vendor/ghostty-web'; +import type { ClientWindow } from './browser-env'; +import type { ClientConfig } from './client-config'; + +export type { Disposable } from '../vendor/ghostty-web'; + +/** The objects the root builds once and every feature installer receives. */ +export interface ClientContext { + term: Terminal; + /** `#terminal`, the element the terminal is opened in. */ + container: HTMLElement; + config: ClientConfig; + /** The config theme in ghostty-web's names (ADR 036). */ + terminalTheme: Record<string, string>; + /** Whether Ghostty's macOS key defaults apply (ADR 054). */ + mac: boolean; + doc: Document; + win: ClientWindow; +} + +/** An installed feature: its `dispose()` removes everything it added and is idempotent. */ +export type Feature = Disposable; + +/** A feature installer, `install<Name>(ctx, deps)`, returning its feature and any API others need. */ +export type FeatureInstaller<Deps, F extends Feature = Feature> = ( + ctx: ClientContext, + deps: Deps, +) => F; diff --git a/src/client/index.ts b/src/client/index.ts index c57f882..7063681 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,3 +1,4 @@ +import { browserEnv } from './app/browser-env'; import { type KeyboardBinding, loadClientConfig } from './app/client-config'; import { detectMac, isTerminalFocused } from './app/platform'; import { toTerminalOptions } from './app/terminal-options'; @@ -18,11 +19,13 @@ import { TabTitle } from './features/tab-title/tab-title'; import { EdgeExtender } from './layout/edge-extender'; import { computeInsets } from './layout/fit-insets'; import { terminalCanvas } from './layout/terminal-canvas'; -import { FitAddon, Ghostty, Terminal, watchDevicePixelRatio } from './vendor/ghostty-web'; +import { FitAddon, watchDevicePixelRatio } from './vendor/ghostty-web'; const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; -const config = await loadClientConfig(fetch); +const env = browserEnv(); + +const config = await loadClientConfig(env.fetch); // Whether the output being parsed is the replay sent on connect (ADR 053) const replay = new ReplayWindow(); @@ -37,13 +40,13 @@ const tabTitle = new TabTitle(sessionId, config.bellTitle, replay, (title) => { // wasm lookup (relative to its own module URL) would depend on where the bundle sits. // Load the wasm from the path the server serves it at instead (ADR 041). It is // Ghostty's official libghostty-vt wasm, the only engine (ADR 042, ADR 047). -const ghostty = await Ghostty.load('/ghostty-vt-small.wasm'); +const ghostty = await env.loadEngine('/ghostty-vt-small.wasm'); // Translate webtty's theme keys to ghostty-web's names and keep `padding`, a webtty // setting and not a terminal colour, out of the theme it receives (ADR 036). const terminalTheme = toTerminalTheme(config.theme ?? {}); -const term = new Terminal(toTerminalOptions(config, terminalTheme, ghostty)); +const term = env.createTerminal(toTerminalOptions(config, terminalTheme, ghostty)); const container = document.getElementById('terminal') as HTMLElement; // The canvas paints theme.background; the container shows only in the padding around From b371c1849b93c529375d6b20f4e436b6c465dcc1 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:00:12 -0400 Subject: [PATCH 188/295] docs(arch): toTerminalOptions takes the engine, and ClientEnv's window carries its listeners --- docs/specs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 8264152..be6d9d6 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -119,7 +119,7 @@ src/ browser-env.ts adapter: ClientEnv, everything start-client touches outside its own objects (see below) client-context.ts types: ClientContext, Feature, Disposable client-config.ts service + type: ClientConfig (from src/config types), loadClientConfig(fetch) - terminal-options.ts util (pure): toTerminalOptions(config, theme) + terminal-options.ts util (pure): toTerminalOptions(config, theme, ghostty) terminal-theme.ts util (pure): toTerminalTheme (was theme.ts) platform.ts util: detectMac(navigator), isTerminalFocused(doc, container) connection/ @@ -259,7 +259,7 @@ Everything between these steps runs synchronously after the awaits of step 1, so | container capture `keydown` | key dispatcher | | document `visibilitychange` | repaint on visible → kitty overlay → title and notifier attended | -`ClientEnv` (`app/browser-env.ts`) carries every global the root touches, each with the browser's own as the production value, so the root runs on fakes: `window` (`location`, `devicePixelRatio`, `matchMedia`, `close`, `focus`), `document` (including `fonts.ready`), `navigator`, `fetch`, `WebSocket`, `ResizeObserver`, `Notification`, `performance.now`, `setTimeout`/`clearTimeout`, `requestAnimationFrame`/`cancelAnimationFrame`, and two factories, `loadEngine(url)` (`Ghostty.load`) and `createTerminal(options)` (`new Terminal`). +`ClientEnv` (`app/browser-env.ts`) carries every global the root touches, each with the browser's own as the production value, so the root runs on fakes: `window` (`location`, `devicePixelRatio`, `matchMedia`, `close`, `focus`, and its `addEventListener`/`removeEventListener` for the window listeners), `document` (including `fonts.ready`), `navigator`, `fetch`, `WebSocket`, `ResizeObserver`, `Notification`, `performance.now`, `setTimeout`/`clearTimeout`, `requestAnimationFrame`/`cancelAnimationFrame`, and two factories, `loadEngine(url)` (`Ghostty.load`) and `createTerminal(options)` (`new Terminal`). Features get what they need from `ClientContext` (`term`, `container`, `config`, `terminalTheme`, `mac`, `doc`, `win`) and from handles the root passes explicitly (the connection, the fitter, the find bar, `clearScreen`). There is no service locator and no event bus. From e434f3d5d96bc0ebd096091af5731a9b35602118 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:02:34 -0400 Subject: [PATCH 189/295] test(server): the missing-wasm startup report is testable and covered pathsOrExit moves from the entry into server-paths.ts as resolvePathsOrExit, with the resolver and the console/process report injected. Tests cover the exact 'Error: Could not find ghostty-vt-small.wasm.' line with exit 1, the rethrow of any other error, success, and the real console.error/process.exit defaults. --- src/server/index.ts | 15 +------ src/server/server-paths.test.ts | 73 ++++++++++++++++++++++++++++++++- src/server/server-paths.ts | 26 ++++++++++++ 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 96c6655..cc22c0b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,20 +3,9 @@ // wiring is in app.ts (ADR 062). import { loadConfig } from '../config'; import { createServer, type WebttyServer } from './app'; -import { MissingWasmError, resolveServerPaths, type ServerPaths } from './server-paths'; +import { resolvePathsOrExit } from './server-paths'; import { SessionStore } from './sessions/session-store'; -/** Resolves the server's files, or prints which one is missing and exits 1. */ -function pathsOrExit(): ServerPaths { - try { - return resolveServerPaths(); - } catch (err) { - if (!(err instanceof MissingWasmError)) throw err; - console.error(`Error: ${err.message}`); - process.exit(1); - } -} - /** Starts webtty on the configured (or `PORT`) host and port. */ function main(): void { const config = loadConfig(); @@ -29,7 +18,7 @@ function main(): void { const displayHost = config.host; const host = config.host === 'localhost' ? '127.0.0.1' : config.host; - const paths = pathsOrExit(); + const paths = resolvePathsOrExit(); // The one registry of sessions. Once the last shell exits the server stops itself. const store = new SessionStore({ onEmpty: () => server.shutdown() }); diff --git a/src/server/server-paths.test.ts b/src/server/server-paths.test.ts index 888d9e2..9ef1b4e 100644 --- a/src/server/server-paths.test.ts +++ b/src/server/server-paths.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; import { @@ -6,7 +6,9 @@ import { findWasm, MissingWasmError, projectRootOf, + resolvePathsOrExit, resolveServerPaths, + type ServerPaths, } from './server-paths'; const ROOT = path.resolve('/pkg'); @@ -102,3 +104,72 @@ describe('resolveServerPaths', () => { expect(fs.existsSync(path.join(paths.clientRoot, 'client.html'))).toBe(true); }); }); + +describe('resolvePathsOrExit', () => { + const PATHS: ServerPaths = { projectRoot: ROOT, clientRoot: ROOT, wasmPath: BUILT_WASM }; + + /** A report that records what was printed and turns exit into a throw, as process.exit never returns. */ + function fakeReport() { + const printed: string[] = []; + const exits: number[] = []; + const report = { + error: (message: string) => printed.push(message), + exit: (code: number): never => { + exits.push(code); + throw new Error(`exit ${code}`); + }, + }; + return { report, printed, exits }; + } + + test('returns the paths when they resolve, printing nothing', () => { + const { report, printed, exits } = fakeReport(); + expect(resolvePathsOrExit(() => PATHS, report)).toBe(PATHS); + expect(printed).toEqual([]); + expect(exits).toEqual([]); + }); + + test('prints the missing wasm and exits 1', () => { + const { report, printed, exits } = fakeReport(); + const missing = () => { + throw new MissingWasmError(); + }; + expect(() => resolvePathsOrExit(missing, report)).toThrow('exit 1'); + expect(printed).toEqual(['Error: Could not find ghostty-vt-small.wasm.']); + expect(exits).toEqual([1]); + }); + + test('rethrows any other error without printing or exiting', () => { + const { report, printed, exits } = fakeReport(); + const boom = new Error('boom'); + const failing = () => { + throw boom; + }; + expect(() => resolvePathsOrExit(failing, report)).toThrow(boom); + expect(printed).toEqual([]); + expect(exits).toEqual([]); + }); + + test('reports through the real console.error and process.exit by default', () => { + const error = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`exit ${code}`); + }); + try { + expect(() => + resolvePathsOrExit(() => { + throw new MissingWasmError(); + }), + ).toThrow('exit 1'); + expect(error).toHaveBeenCalledWith('Error: Could not find ghostty-vt-small.wasm.'); + expect(exit).toHaveBeenCalledWith(1); + } finally { + error.mockRestore(); + exit.mockRestore(); + } + }); + + test('defaults to resolving this checkout', () => { + expect(resolvePathsOrExit().projectRoot).toBe(path.resolve(import.meta.dir, '..', '..')); + }); +}); diff --git a/src/server/server-paths.ts b/src/server/server-paths.ts index 75c9b7b..4a28f8c 100644 --- a/src/server/server-paths.ts +++ b/src/server/server-paths.ts @@ -79,3 +79,29 @@ export function resolveServerPaths( const clientRoot = findClientRoot(projectRoot, existsSync); return { projectRoot, clientRoot, wasmPath }; } + +/** How {@link resolvePathsOrExit} reports a missing wasm; the defaults are the real console and process. */ +export interface StartupReport { + error: (message: string) => void; + exit: (code: number) => never; +} + +/** + * The entry's startup step: resolves the server's files, or prints which one is missing + * (`Error: Could not find ghostty-vt-small.wasm.`) and exits 1. Any other error is rethrown. + */ +export function resolvePathsOrExit( + resolve: () => ServerPaths = resolveServerPaths, + report: StartupReport = { + error: (message) => console.error(message), + exit: (code) => process.exit(code), + }, +): ServerPaths { + try { + return resolve(); + } catch (err) { + if (!(err instanceof MissingWasmError)) throw err; + report.error(`Error: ${err.message}`); + return report.exit(1); + } +} From 86d12ee14896baed48035e4a2466a7acf30b725d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:03:33 -0400 Subject: [PATCH 190/295] refactor(client): isMacPlatform moves to app/platform, so app/ no longer imports features/keyboard --- src/client/app/platform.test.ts | 19 ++++++++++++++++++- src/client/app/platform.ts | 7 +++++-- .../features/keyboard/shortcut-map.test.ts | 19 +------------------ src/client/features/keyboard/shortcut-map.ts | 5 ----- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/client/app/platform.test.ts b/src/client/app/platform.test.ts index 8a808cd..019a1cb 100644 --- a/src/client/app/platform.test.ts +++ b/src/client/app/platform.test.ts @@ -1,6 +1,23 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { createDomWindow, type DomWindow } from '../test-support/dom-window'; -import { detectMac, isTerminalFocused } from './platform'; +import { detectMac, isMacPlatform, isTerminalFocused } from './platform'; + +describe('isMacPlatform', () => { + test.each([ + ['macOS', true], + ['MacIntel', true], + ['iPhone', true], + ['iPad', true], + ['iPod touch', true], + ['Windows', false], + ['Win32', false], + ['Linux x86_64', false], + ['Chrome OS', false], + ['', false], + ])('%p takes the macOS defaults: %p', (platform, mac) => { + expect(isMacPlatform(platform)).toBe(mac); + }); +}); describe('detectMac', () => { test.each([ diff --git a/src/client/app/platform.ts b/src/client/app/platform.ts index 485f79d..fdd207a 100644 --- a/src/client/app/platform.ts +++ b/src/client/app/platform.ts @@ -1,14 +1,17 @@ // What the client asks of the browser's platform and focus state (util; ADR 053, // ADR 054). The navigator and the document come in as arguments. -import { isMacPlatform } from '../features/keyboard/shortcut-map'; - /** The navigator fields the platform check reads; `userAgentData` is Chromium-only. */ export interface PlatformSource { readonly platform: string; readonly userAgentData?: { readonly platform: string }; } +/** Whether the browser's platform takes Ghostty's macOS defaults (iPadOS and iOS too). */ +export function isMacPlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + /** * Whether Ghostty's macOS key defaults apply (ADR 054): the User-Agent Client Hints * platform where the browser has it, else the legacy `navigator.platform`. diff --git a/src/client/features/keyboard/shortcut-map.test.ts b/src/client/features/keyboard/shortcut-map.test.ts index e943f3f..9e0b8a1 100644 --- a/src/client/features/keyboard/shortcut-map.test.ts +++ b/src/client/features/keyboard/shortcut-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { isMacPlatform, type Shortcut, shortcutFor } from './shortcut-map'; +import { type Shortcut, shortcutFor } from './shortcut-map'; type Mod = 'ctrl' | 'shift' | 'alt' | 'meta'; const key = (code: string, mods: Mod[] = []) => ({ @@ -10,23 +10,6 @@ const key = (code: string, mods: Mod[] = []) => ({ metaKey: mods.includes('meta'), }); -describe('isMacPlatform', () => { - test('macOS and iOS take the macOS defaults', () => { - expect(isMacPlatform('macOS')).toBe(true); - expect(isMacPlatform('MacIntel')).toBe(true); - expect(isMacPlatform('iPhone')).toBe(true); - expect(isMacPlatform('iPad')).toBe(true); - }); - - test('Windows, Linux and ChromeOS do not', () => { - expect(isMacPlatform('Windows')).toBe(false); - expect(isMacPlatform('Win32')).toBe(false); - expect(isMacPlatform('Linux x86_64')).toBe(false); - expect(isMacPlatform('Chrome OS')).toBe(false); - expect(isMacPlatform('')).toBe(false); - }); -}); - describe('shortcutFor outside macOS', () => { const at = (code: string, mods: Mod[] = []) => shortcutFor(key(code, mods), false); diff --git a/src/client/features/keyboard/shortcut-map.ts b/src/client/features/keyboard/shortcut-map.ts index 53483ca..6105118 100644 --- a/src/client/features/keyboard/shortcut-map.ts +++ b/src/client/features/keyboard/shortcut-map.ts @@ -22,11 +22,6 @@ export interface Shortcut { type Keys = Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>; -/** Whether the browser's platform takes Ghostty's macOS defaults (iPadOS and iOS too). */ -export function isMacPlatform(platform: string): boolean { - return /mac|iphone|ipad|ipod/i.test(platform); -} - /** * The shortcut a `keydown` is, if any. Modifiers match exactly, as in Ghostty, except for * Ctrl+V, which pastes with Shift or Alt held too, on every platform (ADR 014). Cmd+C and From 359d80fa946dc685e2ef10cdd0167ba54e0d156d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:03:33 -0400 Subject: [PATCH 191/295] refactor(scripts): check-architecture fails app/, connection/ and layout/ importing a feature --- docs/specs/architecture.md | 1 + scripts/architecture/import-rules.test.ts | 18 ++++++++++++++++++ scripts/architecture/import-rules.ts | 11 +++++++++++ scripts/architecture/source-file.ts | 1 + 4 files changed, 31 insertions(+) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index be6d9d6..22b95c6 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -45,6 +45,7 @@ src/client: | `src/client/vendor/ghostty-web/**` imports nothing outside itself. | same | | App code (`src/client/**` outside `vendor/`) imports vendor code only through `vendor/ghostty-web` (the barrel), never a deep path. Tests may deep-import. | same | | `src/client/features/<a>/**` never imports `src/client/features/<b>/**`. Features reach each other only through handles the composition root passes in. | same | +| `src/client/app/**` (except the composition root `app/start-client.ts`), `connection/**` and `layout/**` never import `src/client/features/**`: they sit below the features, so a feature importing them cannot reach another feature through them. | same | | Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`), never the facade class as a value, so no runtime import cycle can form. | same (a value import of a facade, and any runtime cycle), plus review | | A file under a `test-support/` folder is never named `*.test.ts`, and no file imports a `*.test.ts` file. | same | | Test files (`*.test.ts`) may cross the client/server line (contract tests); the import rules above apply to source files only. | same | diff --git a/scripts/architecture/import-rules.test.ts b/scripts/architecture/import-rules.test.ts index 65bd111..28fce2d 100644 --- a/scripts/architecture/import-rules.test.ts +++ b/scripts/architecture/import-rules.test.ts @@ -141,6 +141,24 @@ describe('checkImports', () => { "import { FindBar } from './find-bar';", [], ], + [ + 'app module imports a feature', + 'src/client/app/platform.ts', + "import { FindBar } from '../features/find/find-bar';", + ['below-feature'], + ], + [ + 'connection module imports a feature', + 'src/client/connection/a.ts', + "import type { FindBar } from '../features/find/find-bar';", + ['below-feature'], + ], + [ + 'the composition root imports a feature', + 'src/client/app/start-client.ts', + "import { FindBar } from '../features/find/find-bar';", + [], + ], [ 'split part imports a facade', 'src/client/vendor/ghostty-web/core/a.ts', diff --git a/scripts/architecture/import-rules.ts b/scripts/architecture/import-rules.ts index 37f64ea..40a99c7 100644 --- a/scripts/architecture/import-rules.ts +++ b/scripts/architecture/import-rules.ts @@ -45,6 +45,9 @@ export const VENDOR_FACADES = new Set( const VENDOR_PART_RE = /^src\/client\/vendor\/ghostty-web\/(engine|core|render|input|selection)\//; const FEATURE_RE = /^src\/client\/features\/([^/]+)\//; +// The client layers features build on; only the composition root sits above features. +const BELOW_FEATURES_RE = /^src\/client\/(app|connection|layout)\//; +const COMPOSITION_ROOT = 'src/client/app/start-client.ts'; const BROWSER_SIDE: Layer[] = ['client', 'vendor']; const NODE_SIDE: Layer[] = ['server', 'cli', 'pty', 'config']; @@ -128,6 +131,14 @@ export const IMPORT_RULES: ImportRule[] = [ return own && other && own !== other ? `feature '${own}' imports feature '${other}'` : null; }, }, + { + id: 'below-feature', + appliesToTests: false, + broken: ({ from, target }) => + BELOW_FEATURES_RE.test(from) && from !== COMPOSITION_ROOT && FEATURE_RE.test(target) + ? `imports ${target}; app/, connection/ and layout/ sit below the features` + : null, + }, { id: 'facade-value-import', appliesToTests: false, diff --git a/scripts/architecture/source-file.ts b/scripts/architecture/source-file.ts index 7bc954b..5ebee6b 100644 --- a/scripts/architecture/source-file.ts +++ b/scripts/architecture/source-file.ts @@ -21,6 +21,7 @@ export type RuleId = | 'vendor-outside' | 'vendor-deep' | 'feature-feature' + | 'below-feature' | 'facade-value-import' | 'cycle' | 'kebab-case' From 1cc60d37e49ceb9a90cab6eaa3f625b0b29b7fc3 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:08:29 -0400 Subject: [PATCH 192/295] refactor(client): connection/ close codes, close policy, status line and output pipeline Pure pieces of the socket handling in index.ts, each with table-driven tests: the close codes the tab acts on (4003 pinned to the server's), closeActionFor with today's exact messages and 500 ms / 2 s delays, the "[ webtty ]" status line bytes, and the per-message call order replay.output -> graphics.feed -> replay.parsed -> ack.parsed -> smoothScroll.onOutput (ADR 060). --- src/client/connection/close-codes.test.ts | 16 ++++++ src/client/connection/close-codes.ts | 12 ++++ src/client/connection/close-policy.test.ts | 25 +++++++++ src/client/connection/close-policy.ts | 56 +++++++++++++++++++ src/client/connection/output-pipeline.test.ts | 44 +++++++++++++++ src/client/connection/output-pipeline.ts | 38 +++++++++++++ src/client/connection/status-line.test.ts | 10 ++++ src/client/connection/status-line.ts | 13 +++++ 8 files changed, 214 insertions(+) create mode 100644 src/client/connection/close-codes.test.ts create mode 100644 src/client/connection/close-codes.ts create mode 100644 src/client/connection/close-policy.test.ts create mode 100644 src/client/connection/close-policy.ts create mode 100644 src/client/connection/output-pipeline.test.ts create mode 100644 src/client/connection/output-pipeline.ts create mode 100644 src/client/connection/status-line.test.ts create mode 100644 src/client/connection/status-line.ts diff --git a/src/client/connection/close-codes.test.ts b/src/client/connection/close-codes.test.ts new file mode 100644 index 0000000..c6c76bc --- /dev/null +++ b/src/client/connection/close-codes.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test'; +import { FELL_BEHIND } from '../../server/flow'; +import { CLOSE_CODE } from './close-codes'; + +describe('CLOSE_CODE', () => { + test("fell-behind is the server's code (ADR 048)", () => { + expect(CLOSE_CODE.FELL_BEHIND).toBe(FELL_BEHIND); + }); + + // The server's other codes are module-private on this branch; once its close-codes + // module is exported (step C-F) these are pinned to it too. + test('the codes the tab closes on', () => { + expect(CLOSE_CODE.SERVER_STOPPED).toBe(1001); + expect(CLOSE_CODE.SESSION_GONE).toBe(4001); + }); +}); diff --git a/src/client/connection/close-codes.ts b/src/client/connection/close-codes.ts new file mode 100644 index 0000000..cba453a --- /dev/null +++ b/src/client/connection/close-codes.ts @@ -0,0 +1,12 @@ +// The WebSocket close codes the client acts on (constants, ADR 060). They mirror the +// server's own codes; close-codes.test.ts pins them to the server's values. + +/** The close codes the server ends a PTY socket with, by what each one means to the tab. */ +export const CLOSE_CODE = { + /** RFC 6455 "going away": the server is stopping. */ + SERVER_STOPPED: 1001, + /** The session was removed or its shell exited. */ + SESSION_GONE: 4001, + /** The tab fell too far behind the output and was dropped (ADR 048). */ + FELL_BEHIND: 4003, +} as const; diff --git a/src/client/connection/close-policy.test.ts b/src/client/connection/close-policy.test.ts new file mode 100644 index 0000000..5b268f6 --- /dev/null +++ b/src/client/connection/close-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { CLOSE_WINDOW_MS, closeActionFor, RETRY_MS } from './close-policy'; + +describe('closeActionFor', () => { + test.each([ + [4003, { kind: 'replay', message: 'Tab fell behind; replaying recent output.' }], + [4001, { kind: 'close-window', message: 'Session removed.', delayMs: 500 }], + [1001, { kind: 'close-window', message: 'Server stopped.', delayMs: 500 }], + [1006, { kind: 'retry', message: 'Connection lost. Reconnecting in 2s...', delayMs: 2000 }], + [1000, { kind: 'retry', message: 'Connection lost. Reconnecting in 2s...', delayMs: 2000 }], + [4002, { kind: 'retry', message: 'Connection lost. Reconnecting in 2s...', delayMs: 2000 }], + [1008, { kind: 'retry', message: 'Connection lost. Reconnecting in 2s...', delayMs: 2000 }], + ])('code %d', (code, action) => { + expect(closeActionFor(code)).toEqual(action as ReturnType<typeof closeActionFor>); + }); + + test('the delays', () => { + expect(RETRY_MS).toBe(2000); + expect(CLOSE_WINDOW_MS).toBe(500); + }); + + test('returns the same action object every time, allocating nothing', () => { + expect(closeActionFor(1006)).toBe(closeActionFor(1000)); + }); +}); diff --git a/src/client/connection/close-policy.ts b/src/client/connection/close-policy.ts new file mode 100644 index 0000000..9b68ba4 --- /dev/null +++ b/src/client/connection/close-policy.ts @@ -0,0 +1,56 @@ +// What the tab does when its PTY socket closes, by close code (util, pure, ADR 048, +// ADR 060). PtyConnection carries the action out; keeping the decision here lets each +// code and its exact message be tested without a socket. + +import { CLOSE_CODE } from './close-codes'; + +/** How long a closed tab waits before it reconnects, in ms. */ +export const RETRY_MS = 2000; +/** How long the tab shows its final status line before it closes the window, in ms. */ +export const CLOSE_WINDOW_MS = 500; + +/** + * What to do after a close: `replay` resets the terminal and reconnects at once, + * `close-window` closes the tab after `delayMs`, `retry` reconnects after `delayMs`. + * Each writes `message` as a status line first. + */ +export type CloseAction = + | { readonly kind: 'replay'; readonly message: string } + | { readonly kind: 'close-window'; readonly message: string; readonly delayMs: number } + | { readonly kind: 'retry'; readonly message: string; readonly delayMs: number }; + +// The server dropped this tab for falling too far behind the output. Start again from +// the replay, as a new tab would (ADR 048). +const FELL_BEHIND: CloseAction = { + kind: 'replay', + message: 'Tab fell behind; replaying recent output.', +}; +const SESSION_GONE: CloseAction = { + kind: 'close-window', + message: 'Session removed.', + delayMs: CLOSE_WINDOW_MS, +}; +const SERVER_STOPPED: CloseAction = { + kind: 'close-window', + message: 'Server stopped.', + delayMs: CLOSE_WINDOW_MS, +}; +const LOST: CloseAction = { + kind: 'retry', + message: 'Connection lost. Reconnecting in 2s...', + delayMs: RETRY_MS, +}; + +/** The action for a socket closed with `code`; any code not listed is a lost connection. */ +export function closeActionFor(code: number): CloseAction { + switch (code) { + case CLOSE_CODE.FELL_BEHIND: + return FELL_BEHIND; + case CLOSE_CODE.SESSION_GONE: + return SESSION_GONE; + case CLOSE_CODE.SERVER_STOPPED: + return SERVER_STOPPED; + default: + return LOST; + } +} diff --git a/src/client/connection/output-pipeline.test.ts b/src/client/connection/output-pipeline.test.ts new file mode 100644 index 0000000..c34f904 --- /dev/null +++ b/src/client/connection/output-pipeline.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import { createOutputHandler } from './output-pipeline'; + +describe('createOutputHandler', () => { + test('replay.output → graphics.feed → replay.parsed → ack.parsed → smoothScroll.onOutput', () => { + const calls: string[] = []; + let time = 10; + const handle = createOutputHandler({ + replay: { + output: (chars, now) => calls.push(`replay.output ${chars} ${now}`), + parsed: (now) => calls.push(`replay.parsed ${now}`), + }, + graphics: { + feed: (data) => { + calls.push(`graphics.feed ${data}`); + time = 25; // the parse takes time + }, + }, + smoothScroll: { onOutput: () => calls.push('smoothScroll.onOutput') }, + now: () => time, + }); + handle('hello', { parsed: (chars) => calls.push(`ack.parsed ${chars}`) }); + expect(calls).toEqual([ + 'replay.output 5 10', + 'graphics.feed hello', + 'replay.parsed 25', + 'ack.parsed 5', + 'smoothScroll.onOutput', + ]); + }); + + test('each message is acknowledged on the ack it came with', () => { + const acked: string[] = []; + const handle = createOutputHandler({ + replay: { output: () => {}, parsed: () => {} }, + graphics: { feed: () => {} }, + smoothScroll: { onOutput: () => {} }, + now: () => 0, + }); + handle('ab', { parsed: (chars) => acked.push(`first ${chars}`) }); + handle('abc', { parsed: (chars) => acked.push(`second ${chars}`) }); + expect(acked).toEqual(['first 2', 'second 3']); + }); +}); diff --git a/src/client/connection/output-pipeline.ts b/src/client/connection/output-pipeline.ts new file mode 100644 index 0000000..46f0984 --- /dev/null +++ b/src/client/connection/output-pipeline.ts @@ -0,0 +1,38 @@ +// The fixed per-message call order for PTY output (util, ADR 048, ADR 053, ADR 060). +// It runs for every message, so it is a direct call sequence and allocates nothing. + +/** The parts the pipeline calls, as structural slices so connection/ imports no feature. */ +export interface OutputPipelineParts { + /** ReplayWindow: whether the output being parsed is the replay (ADR 053). */ + replay: { output(chars: number, now: number): void; parsed(now: number): void }; + /** KittyGraphics: takes the APC sequences out and writes the rest to the terminal (ADR 032). */ + graphics: { feed(data: string): void }; + /** SmoothScroll: keeps the scrollback glide in step with new output (ADR 040). */ + smoothScroll: { onOutput(): void }; + /** `performance.now()`. */ + now(): number; +} + +/** The slice of OutputAck the pipeline uses: one per socket, passed in by PtyConnection. */ +export interface OutputAckSink { + parsed(chars: number): void; +} + +/** A handler for one output message on the socket whose ack is `ack`. */ +export type OutputHandler = (data: string, ack: OutputAckSink) => void; + +/** + * Builds the handler every output message goes through. `term.write()` parses + * synchronously, so the output has been parsed once `feed()` returns: acknowledging it + * then lets a slow tab slow the server down (ADR 048). + */ +export function createOutputHandler(parts: OutputPipelineParts): OutputHandler { + const { replay, graphics, smoothScroll, now } = parts; + return (data, ack) => { + replay.output(data.length, now()); + graphics.feed(data); + replay.parsed(now()); + ack.parsed(data.length); + smoothScroll.onOutput(); + }; +} diff --git a/src/client/connection/status-line.test.ts b/src/client/connection/status-line.test.ts new file mode 100644 index 0000000..cb29006 --- /dev/null +++ b/src/client/connection/status-line.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from 'bun:test'; +import { statusLine } from './status-line'; + +describe('statusLine', () => { + test('a dim "[ webtty ]" tag and dim italic text on a line of their own', () => { + expect(statusLine('Server stopped.')).toBe( + '\r\n\x1b[2m[\x1b[0m \x1b[1;33mwebtty\x1b[0m \x1b[2m]\x1b[0m \x1b[2m\x1b[3mServer stopped.\x1b[0m\r\n', + ); + }); +}); diff --git a/src/client/connection/status-line.ts b/src/client/connection/status-line.ts new file mode 100644 index 0000000..6b6845e --- /dev/null +++ b/src/client/connection/status-line.ts @@ -0,0 +1,13 @@ +// The "[ webtty ]" status line the client writes into the terminal when the connection +// changes (util, pure, ADR 060): a dim tag and dim italic text on a line of their own. + +const DIM = '\x1b[2m'; +const YELLOW = '\x1b[1;33m'; +const ITALIC = '\x1b[3m'; +const RESET = '\x1b[0m'; +const TAG = `${DIM}[${RESET} ${YELLOW}webtty${RESET} ${DIM}]${RESET}`; + +/** The terminal bytes that show `text` as a webtty status line, for `term.write()`. */ +export function statusLine(text: string): string { + return `\r\n${TAG} ${DIM}${ITALIC}${text}${RESET}\r\n`; +} From 9a9fde38bc9cfd2267d5b404fda02c2a0d18b0ab Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:08:57 -0400 Subject: [PATCH 193/295] refactor(client): PtyConnection owns the PTY socket, with one send guard and a per-socket ack connection/pty-connection.ts takes the URL (cols, rows, flow=1), connect and reconnect by closeActionFor, and the open guard that index.ts repeated six times. The created hook (clipboard, replay) runs synchronously when a socket is made; on open it runs graphics.reset, sends the size, then resyncFocus. Each socket gets its own OutputAck sending only on that socket, disposed in its onclose before any reconnect, the immediate 4003 one included. WebSocket, location and timers are injected; tests run it on FakeSocket and ManualClock. index.ts wires it with the output pipeline; wire bytes are unchanged and no allocation is added per message. The ratchet entry for index.ts drops to 443. --- scripts/architecture/ratchet-allowlist.ts | 2 +- src/client/connection/pty-connection.test.ts | 223 +++++++++++++++++++ src/client/connection/pty-connection.ts | 153 +++++++++++++ src/client/index.ts | 133 ++++------- 4 files changed, 420 insertions(+), 91 deletions(-) create mode 100644 src/client/connection/pty-connection.test.ts create mode 100644 src/client/connection/pty-connection.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index e86c6c8..4a58b09 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -20,7 +20,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. - 'src/client/index.ts': { lines: 568, step: CLIENT_ROOT }, + 'src/client/index.ts': { lines: 443, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, diff --git a/src/client/connection/pty-connection.test.ts b/src/client/connection/pty-connection.test.ts new file mode 100644 index 0000000..799a49c --- /dev/null +++ b/src/client/connection/pty-connection.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeWebSocket } from '../test-support/fake-socket'; +import { ManualClock } from '../test-support/manual-clock'; +import { CLOSE_WINDOW_MS, RETRY_MS } from './close-policy'; +import { ACK_EVERY, ACK_IDLE_MS, OutputAck } from './output-ack'; +import { PtyConnection, type PtyConnectionHost } from './pty-connection'; +import { statusLine } from './status-line'; + +function setup(protocol = 'http:') { + const clock = new ManualClock(); + const ws = fakeWebSocket(); + const calls: string[] = []; + const size = { cols: 80, rows: 24 }; + const host: PtyConnectionHost = { + size: () => ({ ...size }), + created: () => calls.push(`created ${ws.sockets.length}`), + opening: () => calls.push(`opening, sent ${ws.last().sent.length}`), + opened: () => calls.push(`opened, sent ${ws.last().sent.length}`), + output: (data, ack) => { + calls.push(`output ${data}`); + ack.parsed(data.length); + }, + write: (data) => calls.push(`write ${data}`), + reset: () => calls.push('reset'), + closeWindow: () => calls.push('closeWindow'), + }; + const connection = new PtyConnection('main', host, { + WebSocket: ws.WebSocket, + location: { protocol, host: 'localhost:2550' }, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + createAck: (send) => new OutputAck(send, { set: clock.setTimeout, clear: clock.clearTimeout }), + }); + return { clock, ws, calls, size, connection }; +} + +const resize = (cols: number, rows: number) => JSON.stringify({ type: 'resize', cols, rows }); + +describe('PtyConnection', () => { + test('the URL carries the size and flow=1, over wss: on https', () => { + const { ws, size, connection } = setup(); + size.cols = 120; + size.rows = 40; + connection.connect(); + expect(ws.last().url).toBe('ws://localhost:2550/ws/main/pty?cols=120&rows=40&flow=1'); + expect(setup('https:').connection.url()).toBe( + 'wss://localhost:2550/ws/main/pty?cols=80&rows=24&flow=1', + ); + }); + + test('created runs when the socket is made, before any message', () => { + const { ws, calls, connection } = setup(); + connection.connect(); + expect(calls).toEqual(['created 1']); + ws.last().open(); + ws.last().receive('x'); + expect(calls.at(-1)).toBe('output x'); + }); + + test('on open: opening, then the size, then opened', () => { + const { ws, calls, connection } = setup(); + connection.connect(); + ws.last().open(); + expect(calls).toEqual(['created 1', 'opening, sent 0', 'opened, sent 1']); + expect(ws.last().sent).toEqual([resize(80, 24)]); + }); + + test('send and sendResize are dropped while the socket is not open', () => { + const { ws, connection } = setup(); + connection.send('early'); + expect(connection.isOpen()).toBe(false); + connection.connect(); + connection.send('connecting'); + connection.sendResize(1, 2); + expect(connection.isOpen()).toBe(false); + ws.last().open(); + expect(connection.isOpen()).toBe(true); + connection.send('a'); + connection.sendResize(100, 30); + ws.last().serverClose(1006); + connection.send('closed'); + expect(connection.isOpen()).toBe(false); + expect(ws.last().sent).toEqual([resize(80, 24), 'a', resize(100, 30)]); + }); + + test('a lost connection writes its status and reconnects after 2 s', () => { + const { ws, calls, clock, connection } = setup(); + connection.connect(); + ws.last().open(); + calls.length = 0; + ws.last().fail(); + expect(calls).toEqual([ + `write ${statusLine('WebSocket error.')}`, + `write ${statusLine('Connection lost. Reconnecting in 2s...')}`, + ]); + clock.advance(RETRY_MS - 1); + expect(ws.sockets.length).toBe(1); + clock.advance(1); + expect(ws.sockets.length).toBe(2); + expect(calls.at(-1)).toBe('created 2'); + }); + + test.each([ + [4001, 'Session removed.'], + [1001, 'Server stopped.'], + ])('close %d writes its status and closes the window after 500 ms', (code, message) => { + const { ws, calls, clock, connection } = setup(); + connection.connect(); + ws.last().open(); + calls.length = 0; + ws.last().serverClose(code); + expect(calls).toEqual([`write ${statusLine(message)}`]); + clock.advance(CLOSE_WINDOW_MS); + expect(calls).toEqual([`write ${statusLine(message)}`, 'closeWindow']); + expect(ws.sockets.length).toBe(1); + }); + + test('4003 resets, writes its status and reconnects at once', () => { + const { ws, calls, connection } = setup(); + connection.connect(); + ws.last().open(); + calls.length = 0; + ws.last().serverClose(4003); + expect(calls).toEqual([ + 'reset', + `write ${statusLine('Tab fell behind; replaying recent output.')}`, + 'created 2', + ]); + }); + + test("each socket's ack sends only on that socket", () => { + const { ws, connection } = setup(); + connection.connect(); + const first = ws.last(); + first.open(); + first.receive('x'.repeat(ACK_EVERY)); + first.serverClose(4003); + const second = ws.last(); + second.open(); + second.receive('y'.repeat(ACK_EVERY)); + const ack = (chars: number) => JSON.stringify({ type: 'ack', chars }); + expect(first.sent).toEqual([resize(80, 24), ack(ACK_EVERY)]); + expect(second.sent).toEqual([resize(80, 24), ack(ACK_EVERY)]); + }); + + test('a queued ack is disposed on close and never reaches the next socket', () => { + for (const code of [4003, 1006]) { + const { ws, clock, connection } = setup(); + connection.connect(); + const first = ws.last(); + first.open(); + first.receive('queued'); + expect(clock.pending.timers).toBe(1); + first.serverClose(code); + clock.advance(RETRY_MS); + const second = ws.last(); + expect(second).not.toBe(first); + second.open(); + clock.advance(ACK_IDLE_MS * 2); + expect(second.sent).toEqual([resize(80, 24)]); + expect(first.sent).toEqual([resize(80, 24)]); + } + }); + + test('the default ack is an OutputAck on the real timers', () => { + const ws = fakeWebSocket(); + const noop = () => {}; + const connection = new PtyConnection( + 's', + { + size: () => ({ cols: 1, rows: 1 }), + created: noop, + opening: noop, + opened: noop, + output: (data, ack) => ack.parsed(data.length), + write: noop, + reset: noop, + closeWindow: noop, + }, + { + WebSocket: ws.WebSocket, + location: { protocol: 'http:', host: 'h' }, + setTimeout: () => 0, + clearTimeout: noop, + }, + ); + connection.connect(); + ws.last().open(); + ws.last().receive('z'.repeat(ACK_EVERY)); + expect(ws.last().sent.at(-1)).toBe(JSON.stringify({ type: 'ack', chars: ACK_EVERY })); + connection.dispose(); + }); + + test('dispose closes the socket, cancels a pending reconnect and is idempotent', () => { + const { ws, calls, clock, connection } = setup(); + connection.connect(); + ws.last().open(); + ws.last().receive('pending ack'); + ws.last().serverClose(1006); + expect(clock.pending.timers).toBe(1); + connection.dispose(); + expect(clock.pending.timers).toBe(0); + clock.advance(RETRY_MS); + expect(ws.sockets.length).toBe(1); + + const open = setup(); + open.connection.connect(); + const socket = open.ws.last(); + socket.open(); + socket.receive('queued'); + open.calls.length = 0; + open.connection.dispose(); + open.connection.dispose(); + expect(socket.closedBy).toEqual({ code: undefined, reason: undefined }); + expect(socket.onclose).toBeNull(); + expect(open.clock.pending.timers).toBe(0); + expect(open.calls).toEqual([]); + expect(open.connection.isOpen()).toBe(false); + open.connection.connect(); + expect(open.ws.sockets.length).toBe(1); + expect(calls.length).toBeGreaterThan(0); + }); +}); diff --git a/src/client/connection/pty-connection.ts b/src/client/connection/pty-connection.ts new file mode 100644 index 0000000..d0f6b8d --- /dev/null +++ b/src/client/connection/pty-connection.ts @@ -0,0 +1,153 @@ +// The tab's WebSocket to its session's PTY (service, ADR 048, ADR 060): the URL, +// connect and reconnect by the close policy, and one open guard for everything the tab +// sends. The WebSocket constructor and the timers are injected, so tests run it on fakes. + +import { closeActionFor } from './close-policy'; +import { OutputAck } from './output-ack'; +import type { OutputHandler } from './output-pipeline'; +import { statusLine } from './status-line'; + +/** The terminal's grid size, sent in the URL and on open. */ +export interface GridSize { + cols: number; + rows: number; +} + +/** What the connection needs from the tab around it. */ +export interface PtyConnectionHost { + /** The terminal's current grid size. */ + size(): GridSize; + /** + * Runs synchronously when a socket is created, before any of its messages: the output + * starts with the session's replay (ADR 052, ADR 053, ADR 056). + */ + created(): void; + /** Runs on open, before the size is sent: kitty graphics state starts over (ADR 032). */ + opening(): void; + /** + * Runs on open, after the size is sent: focus changes while the socket was down were + * dropped, so the app's mode 1004 gets the current state (ADR 053). + */ + opened(): void; + /** Handles one output message with the ack of the socket it came on. */ + output: OutputHandler; + /** Writes bytes to the terminal: the status lines. */ + write(data: string): void; + /** Resets the terminal, before the replay that follows a fell-behind close (ADR 048). */ + reset(): void; + /** Closes the tab, after the session or the server is gone. */ + closeWindow(): void; +} + +/** The browser objects the connection uses, each the page's own in production. */ +export interface PtyConnectionDeps { + WebSocket: typeof WebSocket; + location: Pick<Location, 'protocol' | 'host'>; + setTimeout(handler: () => void, ms: number): number; + clearTimeout(id: number): void; + /** Makes the ack for one socket, bound to that socket's guarded send (ADR 048). */ + createAck?(send: (message: string) => void): OutputAck; +} + +/** + * One tab's connection to `/ws/<session>/pty`. A new socket replaces the old one on + * every reconnect; {@link send} and {@link sendResize} go to the current socket only + * while it is open, and are dropped otherwise, as they always were. + */ +export class PtyConnection { + private socket: WebSocket | null = null; + private ack: OutputAck | null = null; + private timer: number | null = null; + private disposed = false; + private readonly createAck: (send: (message: string) => void) => OutputAck; + + constructor( + private readonly sessionId: string, + private readonly host: PtyConnectionHost, + private readonly deps: PtyConnectionDeps, + ) { + this.createAck = deps.createAck ?? ((send) => new OutputAck(send)); + } + + /** The socket URL for the current size. flow=1: this client acknowledges output (ADR 048). */ + url(): string { + const { protocol, host } = this.deps.location; + const { cols, rows } = this.host.size(); + const scheme = protocol === 'https:' ? 'wss:' : 'ws:'; + return `${scheme}//${host}/ws/${this.sessionId}/pty?cols=${cols}&rows=${rows}&flow=1`; + } + + /** Whether the current socket is open, so a send now would reach the PTY. */ + isOpen(): boolean { + return this.socket !== null && this.socket.readyState === this.deps.WebSocket.OPEN; + } + + /** Sends input to the PTY; dropped while the socket is not open. */ + send(data: string): void { + if (this.isOpen()) this.socket?.send(data); + } + + /** Tells the server the terminal's new size; dropped while the socket is not open. */ + sendResize(cols: number, rows: number): void { + this.send(JSON.stringify({ type: 'resize', cols, rows })); + } + + /** Opens a new socket, which becomes the current one. */ + connect(): void { + if (this.disposed) return; + const socket = new this.deps.WebSocket(this.url()); + this.socket = socket; + this.host.created(); + // This socket's own ack, sending only on it (ADR 048) + const ack = this.createAck((message) => { + if (socket.readyState === this.deps.WebSocket.OPEN) socket.send(message); + }); + this.ack = ack; + const { host } = this; + + socket.onopen = () => { + host.opening(); + const { cols, rows } = host.size(); + this.sendResize(cols, rows); + host.opened(); + }; + socket.onmessage = (event: MessageEvent<string>) => host.output(event.data, ack); + socket.onclose = (event: CloseEvent) => { + // Before any reconnect, so an ack queued for this socket never reaches the next + ack.dispose(); + this.closed(event.code); + }; + socket.onerror = () => host.write(statusLine('WebSocket error.')); + } + + /** Stops reconnecting and closes the current socket without acting on its close. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.timer !== null) this.deps.clearTimeout(this.timer); + this.timer = null; + this.ack?.dispose(); + const socket = this.socket; + if (socket) { + socket.onopen = socket.onmessage = socket.onclose = socket.onerror = null; + socket.close(); + } + this.socket = null; + } + + private closed(code: number): void { + const action = closeActionFor(code); + if (action.kind === 'replay') { + this.host.reset(); + this.host.write(statusLine(action.message)); + this.connect(); + return; + } + this.host.write(statusLine(action.message)); + const next = action.kind === 'retry' ? () => this.connect() : () => this.host.closeWindow(); + this.timer = this.deps.setTimeout(() => { + this.timer = null; + next(); + }, action.delayMs); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index 7063681..82dd101 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,7 +3,8 @@ import { type KeyboardBinding, loadClientConfig } from './app/client-config'; import { detectMac, isTerminalFocused } from './app/platform'; import { toTerminalOptions } from './app/terminal-options'; import { toTerminalTheme } from './app/terminal-theme'; -import { OutputAck } from './connection/output-ack'; +import { createOutputHandler } from './connection/output-pipeline'; +import { PtyConnection } from './connection/pty-connection'; import { ReplayWindow } from './connection/replay-window'; import { ClipboardWriter } from './features/clipboard/clipboard-writer'; import { Composer } from './features/compose/composer'; @@ -151,15 +152,13 @@ document.addEventListener('visibilitychange', () => { // display of different scale, so fit again on a change. watchDevicePixelRatio(window, scheduleFit); -const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; -let ws: WebSocket; +// Created with the connection below; read only when a reply is sent. +let connection: PtyConnection; // Kitty graphics: the wasm32 libghostty-vt build leaves kitty graphics out and // discards the protocol's APC sequences, so PTY output goes through this instead of // term.write(). See ADR 032 and ADR 041. -const graphics = new KittyGraphics(term, container, (data: string) => { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); -}); +const graphics = new KittyGraphics(term, container, (data: string) => connection.send(data)); // A program's OSC 52 clipboard write, decoded by Ghostty, written to the browser // clipboard once the user has used this tab since connecting (ADR 052). @@ -210,76 +209,36 @@ document.addEventListener('visibilitychange', () => { }); window.addEventListener('keydown', () => notifier.attended(), { capture: true }); -function connect(): void { - // flow=1: this client acknowledges output as it parses it, so the server paces it - // (ADR 048). - const wsUrl = `${protocol}//${window.location.host}/ws/${sessionId}/pty?cols=${term.cols}&rows=${term.rows}&flow=1`; - const socket = new WebSocket(wsUrl); - ws = socket; - // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) - clipboardWriter.connected(); - // ... and ignore its bells and notifications until the replay's last piece (ADR 053, - // ADR 056) - replay.connected(); - const ack = new OutputAck((message) => { - if (socket.readyState === WebSocket.OPEN) socket.send(message); - }); - - const DIM = '\x1b[2m', - YELLOW = '\x1b[1;33m', - ITALIC = '\x1b[3m', - RESET = '\x1b[0m'; - const tag = `${DIM}[${RESET} ${YELLOW}webtty${RESET} ${DIM}]${RESET}`; - const msg = (text: string): string => `\r\n${tag} ${DIM}${ITALIC}${text}${RESET}\r\n`; - - ws.onopen = () => { - graphics.reset(); - ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); +// The PTY socket (ADR 048, ADR 060). PtyConnection owns the URL, the reconnects and the +// close codes; this says what runs around them. +connection = new PtyConnection( + sessionId, + { + size: () => ({ cols: term.cols, rows: term.rows }), + created: () => { + // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) + clipboardWriter.connected(); + // ... and ignore its bells and notifications until the replay's last piece (ADR 053, + // ADR 056) + replay.connected(); + }, + opening: () => graphics.reset(), // Focus changes while the socket was down were dropped: send the state the app's // mode 1004 now misses (ADR 053). After a reset the mode is off until the replay. - term.resyncFocus(); - }; - - ws.onmessage = (event: MessageEvent<string>) => { - // term.write() parses synchronously, so the output has been parsed once feed() - // returns: acknowledging it now lets a slow tab slow the server down. - replay.output(event.data.length, performance.now()); - graphics.feed(event.data); - replay.parsed(performance.now()); - ack.parsed(event.data.length); - smoothScroll.onOutput(); - }; - - ws.onclose = (event: CloseEvent) => { - ack.dispose(); - if (event.code === 4003) { - // The server dropped this tab for falling too far behind the output. Start - // again from the replay, as a new tab would (ADR 048). - term.reset(); - term.write(msg('Tab fell behind; replaying recent output.')); - connect(); - return; - } - if (event.code === 4001) { - term.write(msg('Session removed.')); - setTimeout(() => window.close(), 500); - return; - } - if (event.code === 1001) { - term.write(msg('Server stopped.')); - setTimeout(() => window.close(), 500); - return; - } - term.write(msg('Connection lost. Reconnecting in 2s...')); - setTimeout(connect, 2000); - }; - - ws.onerror = () => { - term.write(msg('WebSocket error.')); - }; -} - -connect(); + opened: () => term.resyncFocus(), + output: createOutputHandler({ + replay, + graphics, + smoothScroll, + now: () => env.performance.now(), + }), + write: (data) => term.write(data), + reset: () => term.reset(), + closeWindow: () => env.window.close(), + }, + { ...env, location: env.window.location }, +); +connection.connect(); // Wheel scrolling (ADR 017, ADR 040, ADR 045). WheelStream tells a mouse wheel, a // trackpad and a TrackPoint apart and picks how each event moves: @@ -323,10 +282,10 @@ container.addEventListener( if (!binding) return; e.preventDefault(); e.stopPropagation(); - if (binding.chars && ws.readyState === WebSocket.OPEN) { + if (binding.chars && connection.isOpen()) { // A keystroke scrolls to the bottom, as the terminal's own keys do (ADR 055) term.scrollToBottom(); - ws.send(binding.chars); + connection.send(binding.chars); } }, { capture: true }, @@ -360,27 +319,21 @@ window.addEventListener( ); // Forward terminal keystrokes and input to the PTY over WebSocket. -term.onData((data: string) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data); - } -}); +term.onData((data: string) => connection.send(data)); // Notify the server when the terminal is resized so the PTY dimensions stay in sync. -term.onResize(({ cols, rows }: { cols: number; rows: number }) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - } -}); +term.onResize(({ cols, rows }: { cols: number; rows: number }) => + connection.sendResize(cols, rows), +); // ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike // xterm.js), relying entirely on the browser 'paste' event. On macOS that // event only fires for Cmd+V (the OS paste gesture) - Ctrl+V produces no // event at all. See ADR 014. function sendCtrlV(): void { - if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (!connection.isOpen()) return; term.scrollToBottom(); - ws.send('\x16'); + connection.send('\x16'); } // Text goes through the terminal, which encodes it as Ghostty does: bracketed @@ -388,11 +341,11 @@ function sendCtrlV(): void { // as CR (they may act as Enter). Ctrl+V is not a text fallback (remote TUIs can // interpret it as an image-paste shortcut). See ADR 037 and ADR 049. function sendPaste(text: string): void { - if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (!connection.isOpen()) return; const ctrlV = nonTextPaste(text); if (ctrlV) { term.scrollToBottom(); - ws.send(ctrlV); + connection.send(ctrlV); } else term.paste(text); } From acecb33b36ddabbc519ff9f4307058b2578148c3 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:08:58 -0400 Subject: [PATCH 194/295] test(client): OutputAck on its default window timers --- src/client/connection/output-ack.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/client/connection/output-ack.test.ts b/src/client/connection/output-ack.test.ts index 8793b3d..f4ef0f9 100644 --- a/src/client/connection/output-ack.test.ts +++ b/src/client/connection/output-ack.test.ts @@ -70,4 +70,14 @@ describe('OutputAck', () => { expect(timers.length).toBe(0); expect(sent).toEqual([]); }); + + test('defaults to the window timers', async () => { + const sent: string[] = []; + const ack = new OutputAck((m) => sent.push(m)); + ack.parsed(1); + ack.dispose(); + ack.parsed(2); + await new Promise((resolve) => setTimeout(resolve, ACK_IDLE_MS + 20)); + expect(sent).toEqual([JSON.stringify({ type: 'ack', chars: 3 })]); + }); }); From f5dd7f41e9910278d7d48e06dd44c627fed961af Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:11:46 -0400 Subject: [PATCH 195/295] test(server): an in-process LiveServer built by createServer for the route tests test-support/live-server.ts starts the whole server with createServer on port 0, wired as src/server/index.ts wires it (the store's onEmpty shuts the server down), with /bin/sh as the shell off Windows. RouteHarness now builds a fresh LiveServer per test instead of wrapping createRequestHandler in its own http.Server, so the golden tests run through the real 500 fallback and shutdown; the stop golden test waits for onStop. useTmpHome points HOME at a temporary directory and puts it back. --- src/server/routes-golden.test.ts | 4 +- src/test-support/live-server.ts | 91 +++++++++++++++++++++++++ src/test-support/route-harness.ts | 104 +++++++++++++---------------- src/test-support/server-harness.ts | 22 ++++++ 4 files changed, 162 insertions(+), 59 deletions(-) create mode 100644 src/test-support/live-server.ts diff --git a/src/server/routes-golden.test.ts b/src/server/routes-golden.test.ts index 8e585bd..d1e0491 100644 --- a/src/server/routes-golden.test.ts +++ b/src/server/routes-golden.test.ts @@ -36,12 +36,14 @@ describe('routes golden — server', () => { } }); - test('POST /api/server/stop answers "stopping" and then calls onStop once', async () => { + test('POST /api/server/stop answers "stopping" and then shuts the server down once', async () => { expect(await call('POST', '/api/server/stop')).toEqual({ status: 200, headers: { ...plain(8), 'content-type': 'text/plain' }, body: 'stopping', }); + expect(h.live.server.httpServer.listening).toBe(false); + await h.live.stopped; expect(h.stops).toBe(1); }); diff --git a/src/test-support/live-server.ts b/src/test-support/live-server.ts new file mode 100644 index 0000000..9a20623 --- /dev/null +++ b/src/test-support/live-server.ts @@ -0,0 +1,91 @@ +// The whole webtty server in the test process: createServer on an OS-chosen loopback port, +// with the store's onEmpty wired to shutdown as src/server/index.ts wires it, so route, +// websocket and session code count in coverage. A test-support object (ADR 062, ADR 064). +import type { AddressInfo } from 'node:net'; +import { type Config, loadConfig as loadUserConfig } from '../config'; +import { createServer, type WebttyServer } from '../server/app'; +import type { ServerPaths } from '../server/server-paths'; +import type { SpawnPty } from '../server/sessions/session'; +import { SessionStore } from '../server/sessions/session-store'; + +/** What a test can swap; everything else is the production wiring. */ +export interface LiveServerOptions { + /** Where the client page and the wasm are. */ + paths: ServerPaths; + /** The config loader; {@link testShellConfig} when omitted. */ + loadConfig?: () => Config; + /** The PTY spawner; the real one from src/pty when omitted. */ + spawnPty?: SpawnPty; +} + +/** A listening server and the handles a test needs to drive and inspect it. */ +export interface LiveServer { + server: WebttyServer; + store: SessionStore; + /** `http://127.0.0.1:<port>`. */ + base: string; + /** `ws://127.0.0.1:<port>`. */ + wsBase: string; + /** How many times the server called its `onStop` hook. */ + stops: () => number; + /** Resolves once `onStop` has been called. */ + stopped: Promise<void>; + /** Shuts the server down (killing any shell) unless it is already stopping. */ + close: () => void; +} + +/** + * The config under the test's HOME with a predictable shell: `/bin/sh` off Windows (the + * user's `$SHELL` may print anything at startup), and clink kept out of cmd.exe on Windows, + * where it breaks PTY socket writes. + */ +export function testShellConfig(): Config { + const config = loadUserConfig(); + return process.platform === 'win32' + ? { ...config, env: { ...config.env, CLINK_NOAUTORUN: '1' } } + : { ...config, shell: '/bin/sh' }; +} + +/** Builds the server with {@link createServer} and listens on port 0 of 127.0.0.1. */ +export async function startLiveServer(options: LiveServerOptions): Promise<LiveServer> { + let stops = 0; + let markStopped!: () => void; + const stopped = new Promise<void>((resolve) => { + markStopped = resolve; + }); + + // Shutdown stops the listener at once; a shell it killed exits later and empties the store + // again, and the process-exiting entry never sees that, so stop only while still listening. + const shutdownIfListening = () => { + if (server.httpServer.listening) server.shutdown(); + }; + const store = new SessionStore({ onEmpty: shutdownIfListening }); + const server = createServer({ + loadConfig: options.loadConfig ?? testShellConfig, + store, + paths: options.paths, + spawnPty: options.spawnPty, + onStop: () => { + stops++; + markStopped(); + }, + }); + await new Promise<void>((resolve) => server.httpServer.listen(0, '127.0.0.1', resolve)); + const { port } = server.httpServer.address() as AddressInfo; + + return { + server, + store, + base: `http://127.0.0.1:${port}`, + wsBase: `ws://127.0.0.1:${port}`, + stops: () => stops, + stopped, + close: () => { + shutdownIfListening(); + // Drops keep-alive connections. It does not wait for onStop: Bun's node:http holds + // close() open on a server that has upgraded a WebSocket, so onStop then comes only + // after SHUTDOWN_GRACE_MS, and the listener is closed already. + server.httpServer.closeAllConnections(); + }, + }; +} diff --git a/src/test-support/route-harness.ts b/src/test-support/route-harness.ts index 14c884e..bd68ee1 100644 --- a/src/test-support/route-harness.ts +++ b/src/test-support/route-harness.ts @@ -1,16 +1,14 @@ -// An in-process HTTP server for the route golden tests: the request handler on a free loopback -// port with a temporary HOME and dist, a fresh SessionStore per test, and helpers that read a +// The whole server in-process for the route golden tests: createServer on port 0 with a +// temporary HOME and dist, a fresh server and SessionStore per test, and helpers that read a // reply as status, headers and body. A test-support object, never collected as a test (ADR 062, ADR 064). import fs from 'node:fs'; import http from 'node:http'; -import type { AddressInfo } from 'node:net'; import path from 'node:path'; -import { loadConfig } from '../config'; -import { createRequestHandler } from '../server/routes/route-table'; import type { Session } from '../server/sessions/session'; -import { SessionStore } from '../server/sessions/session-store'; +import type { SessionStore } from '../server/sessions/session-store'; import { FakePty } from './fake-pty'; -import { cleanupTmpHome, getFreePort, makeTmpHome } from './server-harness'; +import { type LiveServer, startLiveServer } from './live-server'; +import { type TmpHome, useTmpHome } from './server-harness'; /** A reply reduced to what the golden tests pin. */ export interface Reply { @@ -59,65 +57,55 @@ export const WASM_BYTES = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]); export class RouteHarness { tmpHome = ''; wasmPath = ''; - /** The sessions the handler sees; replaced by reset() so tests start empty. */ - store = new SessionStore(); - /** How many times the handler called onStop. */ - stops = 0; - private server: http.Server | null = null; - private baseUrl = ''; - private origHome: string | undefined; - - /** Makes the temporary HOME, dist and wasm, and listens on a free port, wrapped as src/server/index.ts wraps the handler. */ + private home: TmpHome | null = null; + private distDir = ''; + private current: LiveServer | null = null; + + /** The running server; reset() replaces it so each test starts with no sessions. */ + get live(): LiveServer { + if (!this.current) throw new Error('RouteHarness: call reset() before using the server'); + return this.current; + } + + /** The sessions the routes see. */ + get store(): SessionStore { + return this.live.store; + } + + /** How many times the server called onStop. */ + get stops(): number { + return this.live.stops(); + } + + /** Makes the temporary HOME, dist and wasm the servers serve. */ async start(): Promise<void> { - this.origHome = process.env.HOME; - this.tmpHome = makeTmpHome('routes-golden'); - process.env.HOME = this.tmpHome; - const distDir = path.join(this.tmpHome, 'dist'); - fs.mkdirSync(distDir); - fs.writeFileSync(path.join(distDir, 'client.html'), CLIENT_HTML); - fs.writeFileSync(path.join(distDir, 'client-browser.js'), 'console.log(1);'); - fs.writeFileSync(path.join(distDir, 'client.css'), 'body{}'); + this.home = useTmpHome('routes-golden'); + this.tmpHome = this.home.dir; + this.distDir = path.join(this.tmpHome, 'dist'); + fs.mkdirSync(this.distDir); + fs.writeFileSync(path.join(this.distDir, 'client.html'), CLIENT_HTML); + fs.writeFileSync(path.join(this.distDir, 'client-browser.js'), 'console.log(1);'); + fs.writeFileSync(path.join(this.distDir, 'client.css'), 'body{}'); this.wasmPath = path.join(this.tmpHome, 'ghostty-vt-small.wasm'); fs.writeFileSync(this.wasmPath, WASM_BYTES); fs.writeFileSync(path.join(this.tmpHome, 'secret.txt'), 'secret'); - - const server = http.createServer((req, res) => { - // Built per request so a store replaced by reset() is the one the routes see. - const handleRequest = createRequestHandler({ - store: this.store, - loadConfig, - wasmPath: this.wasmPath, - clientDistPath: distDir, - onStop: () => this.stops++, - }); - handleRequest(req, res).catch(() => { - if (!res.headersSent) { - res.writeHead(500); - res.end('Internal Server Error'); - } - }); - }); - const port = await getFreePort(); - await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve)); - this.baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - this.server = server; } - /** Gives the next test an empty store, no stop calls and no config file. */ - reset(): void { - this.store = new SessionStore(); - this.stops = 0; + /** Gives the next test a new server: an empty store, no stop calls and no config file. */ + async reset(): Promise<void> { + this.current?.close(); fs.rmSync(path.join(this.tmpHome, '.config'), { recursive: true, force: true }); + this.current = await startLiveServer({ + paths: { projectRoot: this.tmpHome, clientRoot: this.distDir, wasmPath: this.wasmPath }, + spawnPty: () => new FakePty(), + }); } /** Closes the server, restores HOME and removes the temporary directory. */ async stop(): Promise<void> { - const server = this.server; - if (server) await new Promise<void>((resolve) => server.close(() => resolve())); - this.server = null; - if (this.origHome === undefined) delete process.env.HOME; - else process.env.HOME = this.origHome; - cleanupTmpHome(this.tmpHome); + this.current?.close(); + this.current = null; + this.home?.restore(); } /** Writes `config` as the config file in the temporary HOME; the handler reads it per request. */ @@ -129,7 +117,7 @@ export class RouteHarness { /** Sends a request without following redirects; the reply's headers leave out Date. */ call = async (method: string, url: string, init: RequestInit = {}): Promise<Reply> => { - const res = await fetch(`${this.baseUrl}${url}`, { method, redirect: 'manual', ...init }); + const res = await fetch(`${this.live.base}${url}`, { method, redirect: 'manual', ...init }); const headers: Record<string, string> = {}; for (const [k, v] of res.headers) { if (k !== 'date' && k !== 'connection' && k !== 'keep-alive') headers[k] = v; @@ -158,14 +146,14 @@ export class RouteHarness { body, duplex: 'half', }; - const response = fetch(`${this.baseUrl}${url}`, init as RequestInit); + const response = fetch(`${this.live.base}${url}`, init as RequestInit); return { controller, response }; }; /** GETs `rawPath` with node:http, so a path like `/dist/../x` reaches the server unnormalised. */ rawGet = (rawPath: string): Promise<Reply> => new Promise((resolve, reject) => { - const req = http.request(`${this.baseUrl}/`, { path: rawPath }, (res) => { + const req = http.request(`${this.live.base}/`, { path: rawPath }, (res) => { let body = ''; res.on('data', (c: Buffer) => { body += c.toString(); diff --git a/src/test-support/server-harness.ts b/src/test-support/server-harness.ts index 944bd3d..54b06db 100644 --- a/src/test-support/server-harness.ts +++ b/src/test-support/server-harness.ts @@ -68,3 +68,25 @@ export function makeTmpHome(prefix: string): string { export function cleanupTmpHome(tmpHome: string): void { fs.rmSync(tmpHome, { recursive: true, force: true }); } + +/** A temporary HOME set on this process, and how to put the old one back. */ +export interface TmpHome { + dir: string; + /** Restores HOME and removes the directory. */ + restore: () => void; +} + +/** Points this process's HOME at a fresh temp directory, so an in-process server reads no real config. */ +export function useTmpHome(prefix: string): TmpHome { + const original = process.env.HOME; + const dir = makeTmpHome(prefix); + process.env.HOME = dir; + return { + dir, + restore: () => { + if (original === undefined) delete process.env.HOME; + else process.env.HOME = original; + cleanupTmpHome(dir); + }, + }; +} From 8ad953326b8f09398c0763f09c1932b813609bd1 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:11:51 -0400 Subject: [PATCH 196/295] test(server): run route and websocket tests in-process routes.test.ts and websocket.test.ts start the server with startLiveServer (createServer on port 0, real shells) instead of spawning src/server/index.ts, so routes, both ws channels and sessions count in coverage; "server shuts down when last session exits" now checks the listener and the store. The real-shell websocket tests skip on Windows under Bun, where node-pty cannot run in the test process. entry.test.ts keeps three subprocess smokes for what only the entry does: it listens on PORT, binds a 'localhost' host as 127.0.0.1 while printing localhost, removes PORT from the environment /execute commands inherit (moved from routes.test.ts), and exits 0 on stop. src/server stays at 100% lines and functions; bun test 16.5 s before, 16.2 s after. --- src/server/entry.test.ts | 102 +++++++++++++++++++++++++++++++++++ src/server/routes.test.ts | 77 ++++++-------------------- src/server/websocket.test.ts | 74 ++++++++----------------- 3 files changed, 141 insertions(+), 112 deletions(-) create mode 100644 src/server/entry.test.ts diff --git a/src/server/entry.test.ts b/src/server/entry.test.ts new file mode 100644 index 0000000..ad11551 --- /dev/null +++ b/src/server/entry.test.ts @@ -0,0 +1,102 @@ +// Subprocess smoke tests for the server entry, src/server/index.ts: the parts that only a real +// process shows, namely the PORT variable, its removal from the shell's environment, the +// localhost bind and the exit on stop. Everything else runs in-process (ADR 062, ADR 064). +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { type ChildProcess, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocket } from 'ws'; +import { isWindows, runningUnderBun } from '../runtime'; +import { + cleanupTmpHome, + getFreePort, + makeTmpHome, + waitForServer, +} from '../test-support/server-harness'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// On Windows, Bun's ConPTY/net.Socket integration doesn't support the pipe handles node-pty +// requires, so the server runs under Node from the build there. +const useNode = isWindows() && runningUnderBun(); +const SERVER_EXEC = useNode ? 'node' : process.execPath; +const SERVER_ENTRY = useNode + ? path.resolve(__dirname, '../../dist/server/index.js') + : path.resolve(__dirname, 'index.ts'); + +describe('server entry', () => { + let proc: ChildProcess; + let port: number; + let tmpHome: string; + let stdout = ''; + let exitCode: Promise<number | null>; + + beforeAll(async () => { + tmpHome = makeTmpHome('entry-test'); + // The config asks for 'localhost', which the entry binds as 127.0.0.1. + const configDir = path.join(tmpHome, '.config', 'webtty'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({ host: 'localhost' })); + port = await getFreePort(); + proc = spawn(SERVER_EXEC, [SERVER_ENTRY], { + env: { + ...process.env, + PORT: String(port), + HOME: tmpHome, + ...(process.platform !== 'win32' && { SHELL: '/bin/sh' }), + ...(process.platform === 'win32' && { CLINK_NOAUTORUN: '1' }), + }, + stdio: ['ignore', 'pipe', 'ignore'], + }); + proc.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + exitCode = new Promise((resolve) => proc.on('exit', (code) => resolve(code))); + await waitForServer(`http://127.0.0.1:${port}`); + }); + + afterAll(() => { + proc.kill(); + cleanupTmpHome(tmpHome); + }); + + test('listens on the PORT variable, bound to 127.0.0.1 and shown as localhost', async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/server/status`); + expect(res.status).toBe(200); + expect(stdout).toContain(`listening on http://localhost:${port}`); + }); + + test('PORT is removed from the environment commands inherit', async () => { + const base = `http://127.0.0.1:${port}`; + await fetch(`${base}/api/sessions`, { method: 'POST', body: '{"id":"port-leak"}' }); + // /execute needs a running shell; the PTY socket spawns it. + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/port-leak/pty?cols=80&rows=24`); + await new Promise((resolve, reject) => { + ws.on('open', resolve); + ws.on('error', reject); + }); + const res = await fetch(`${base}/s/port-leak/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cmd: process.execPath, + args: ['-e', 'console.log(process.env.PORT ?? "")'], + }), + }); + ws.close(); + const lines = (await res.text()) + .trim() + .split('\n') + .map((l) => JSON.parse(l) as Record<string, unknown>); + const stdoutLine = lines.find((l) => l.stream === 'stdout'); + expect(String(stdoutLine?.data).trim()).toBe(''); + expect(lines.at(-1)).toEqual({ exit: 0 }); + }); + + test('POST /api/server/stop ends the process with exit code 0', async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/server/stop`, { method: 'POST' }); + expect(await res.text()).toBe('stopping'); + expect(await exitCode).toBe(0); + }); +}); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 79a8374..2f6c510 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -1,46 +1,33 @@ +// Behaviour tests for the HTTP routes against the whole server built in-process by +// createServer, with real shells for the execute routes; the exact replies are pinned by the +// routes-*golden tests (ADR 062, ADR 064). The tests share one server and run in order. import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { type ChildProcess, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DEFAULT_THEME } from '../config'; -import { - cleanupTmpHome, - getFreePort, - makeTmpHome, - waitForServer, - waitForServerDown, -} from '../test-support/server-harness'; +import { type LiveServer, startLiveServer } from '../test-support/live-server'; +import { type TmpHome, useTmpHome } from '../test-support/server-harness'; import { getVersion } from '../version'; +import { resolveServerPaths } from './server-paths'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const SERVER_ENTRY = path.resolve(__dirname, 'index.ts'); describe('server — routes', () => { - let proc: ChildProcess; + let live: LiveServer; let baseUrl: string; - let tmpHome: string; + let home: TmpHome; beforeAll(async () => { - tmpHome = makeTmpHome('routes-test'); - const port = await getFreePort(); - baseUrl = `http://127.0.0.1:${port}`; - proc = spawn(process.execPath, [SERVER_ENTRY], { - env: { - ...process.env, - PORT: String(port), - HOME: tmpHome, - ...(process.platform !== 'win32' && { SHELL: '/bin/sh' }), - }, - stdio: 'ignore', - }); - await waitForServer(baseUrl); + home = useTmpHome('routes-test'); + live = await startLiveServer({ paths: resolveServerPaths() }); + baseUrl = live.base; }); afterAll(() => { - proc.kill(); - cleanupTmpHome(tmpHome); + live.close(); + home.restore(); }); test('GET /unknown returns 404', async () => { @@ -457,45 +444,13 @@ describe('server — routes', () => { expect(typeof exitLine?.error).toBe('string'); }); - test('POST /s/:id/execute does not leak server PORT into child env', async () => { - await fetch(`${baseUrl}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: 'execute-port-leak' }), - }); - const wsUrl = baseUrl.replace(/^http/, 'ws'); - const ws = new WebSocket(`${wsUrl}/ws/execute-port-leak/pty?cols=80&rows=24`); - await new Promise<void>((resolve, reject) => { - ws.onopen = () => resolve(); - ws.onerror = () => reject(new Error('WS error')); - setTimeout(() => reject(new Error('WS timeout')), 5000); - }); - const res = await fetch(`${baseUrl}/s/execute-port-leak/execute`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - cmd: process.execPath, - args: ['-e', 'console.log(process.env.PORT ?? "")'], - }), - }); - ws.close(); - expect(res.status).toBe(200); - const text = await res.text(); - const lines = text - .trim() - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l) as Record<string, unknown>); - const stdoutLine = lines.find((l) => l.stream === 'stdout'); - expect(stdoutLine).toBeDefined(); - expect(String(stdoutLine?.data).trim()).toBe(''); - }); - test('POST /api/server/stop returns 200 and stops server', async () => { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); expect(res.status).toBe(200); expect(await res.text()).toBe('stopping'); - await waitForServerDown(baseUrl); + live.server.httpServer.closeAllConnections(); + await live.stopped; + expect(live.stops()).toBe(1); await expect(fetch(`${baseUrl}/api/sessions`)).rejects.toThrow(); }); }); diff --git a/src/server/websocket.test.ts b/src/server/websocket.test.ts index da6108a..0d2b55f 100644 --- a/src/server/websocket.test.ts +++ b/src/server/websocket.test.ts @@ -1,31 +1,23 @@ +// Behaviour tests for the two WebSocket channels, /ws/:id/pty and /ws/:id/events, against +// the whole server built in-process by createServer with real shells: banner and replay, +// fan-out, flow control, resize, shell exit and the stop once the last session ends +// (ADR 062, ADR 064). The tests share one server and run in order. import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { type ChildProcess, spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { WebSocket } from 'ws'; import { isWindows, runningUnderBun } from '../runtime'; -import { - cleanupTmpHome, - getFreePort, - makeTmpHome, - waitForServer, -} from '../test-support/server-harness'; +import { type LiveServer, startLiveServer } from '../test-support/live-server'; +import { type TmpHome, useTmpHome } from '../test-support/server-harness'; +import { resolveServerPaths } from './server-paths'; const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); const stripAnsi = (s: string) => s.replace(ANSI_RE, ''); // cmd.exe requires CR+LF to execute commands; sh/bash accept LF alone. const NL = process.platform === 'win32' ? '\r\n' : '\n'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// On Windows, Bun's ConPTY/net.Socket integration doesn't support the pipe -// handles node-pty requires. Mirror the same workaround as http.ts: run the -// server under Node when on Windows+Bun so node-pty works correctly. -const useNode = isWindows() && runningUnderBun(); -const SERVER_EXEC = useNode ? 'node' : process.execPath; -const SERVER_ENTRY = useNode - ? path.resolve(__dirname, '../../dist/server/index.js') - : path.resolve(__dirname, 'index.ts'); +// On Windows, Bun's ConPTY/net.Socket integration doesn't support the pipe handles node-pty +// requires, so these real-shell tests cannot run in a Bun test process there. +const noPtyInProcess = isWindows() && runningUnderBun(); function connectWs(wsUrl: string): Promise<{ ws: WebSocket; messages: string[] }> { return new Promise((resolve, reject) => { @@ -85,37 +77,22 @@ function closeWs(ws: WebSocket): Promise<void> { }); } -describe('websocket', () => { - let proc: ChildProcess; +describe.skipIf(noPtyInProcess)('websocket', () => { + let live: LiveServer; let baseUrl: string; let wsBase: string; - let port: number; - let tmpHome: string; + let home: TmpHome; beforeAll(async () => { - tmpHome = makeTmpHome('ws-test'); - port = await getFreePort(); - baseUrl = `http://127.0.0.1:${port}`; - wsBase = `ws://127.0.0.1:${port}`; - proc = spawn(SERVER_EXEC, [SERVER_ENTRY], { - env: { - ...process.env, - PORT: String(port), - HOME: tmpHome, - // Let the server resolve the shell via its own platform detection (config.ts). - // Forcing /bin/sh here breaks Windows where that path does not exist. - ...(process.platform !== 'win32' && { SHELL: '/bin/sh' }), - // On Windows, clink (if installed) auto-injects into cmd.exe and breaks PTY socket writes. - ...(process.platform === 'win32' && { CLINK_NOAUTORUN: '1' }), - }, - stdio: 'ignore', - }); - await waitForServer(baseUrl); + home = useTmpHome('ws-test'); + live = await startLiveServer({ paths: resolveServerPaths() }); + baseUrl = live.base; + wsBase = live.wsBase; }); afterAll(() => { - proc.kill(); - cleanupTmpHome(tmpHome); + live.close(); + home.restore(); }); test('rejects connection for non-existent session with code 4001', async () => { @@ -497,15 +474,10 @@ describe('websocket', () => { ws.send(`exit${NL}`); await new Promise<void>((resolve) => ws.on('close', () => resolve())); + // Shutdown closes the listener at once; onStop follows after the shutdown grace. const deadline = Date.now() + 3000; - while (Date.now() < deadline) { - try { - await fetch(`${baseUrl}/api/sessions`); - await Bun.sleep(100); - } catch { - return; - } - } - throw new Error('Server did not shut down after last session exited'); + while (live.server.httpServer.listening && Date.now() < deadline) await Bun.sleep(10); + expect(live.server.httpServer.listening).toBe(false); + expect(live.store.size).toBe(0); }); }); From e4ec4bff64960835a9cadb3cac40f974d8082636 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:15:55 -0400 Subject: [PATCH 197/295] refactor(pty): name the PTY files after what they hold types.ts becomes pty-process.ts, bun.ts bun-backend.ts and node.ts node-backend.ts (git mv, imports updated, no logic change; ADR 062). --- src/pty/{bun.ts => bun-backend.ts} | 2 +- src/pty/index.ts | 6 ++++-- src/pty/{node.ts => node-backend.ts} | 2 +- src/pty/{types.ts => pty-process.ts} | 0 4 files changed, 6 insertions(+), 4 deletions(-) rename src/pty/{bun.ts => bun-backend.ts} (97%) rename src/pty/{node.ts => node-backend.ts} (97%) rename src/pty/{types.ts => pty-process.ts} (100%) diff --git a/src/pty/bun.ts b/src/pty/bun-backend.ts similarity index 97% rename from src/pty/bun.ts rename to src/pty/bun-backend.ts index 8a722e3..26a33a9 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun-backend.ts @@ -1,4 +1,4 @@ -import type { PtyProcess } from './types'; +import type { PtyProcess } from './pty-process'; /** * Spawns a PTY-backed shell using Bun's native `Bun.spawn` terminal API. diff --git a/src/pty/index.ts b/src/pty/index.ts index 637994e..e152dfe 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,6 +1,6 @@ import { bunTerminalAvailable } from '../runtime'; -export type { PtyProcess } from './types'; +export type { PtyProcess } from './pty-process'; // Bun.Terminal does not implement PTY on Windows (Bun.spawn({ terminal }) // is a no-op there), so fall back to node-pty on that platform. @@ -8,7 +8,9 @@ export type { PtyProcess } from './types'; const useBunTerminal = bunTerminalAvailable(); console.log(`pty: ${useBunTerminal ? 'Bun.Terminal' : 'node-pty'}`); -const { spawn: _spawn } = await (useBunTerminal ? import('./bun') : import('./node')); +const { spawn: _spawn } = await (useBunTerminal + ? import('./bun-backend') + : import('./node-backend')); export const spawn = _spawn; diff --git a/src/pty/node.ts b/src/pty/node-backend.ts similarity index 97% rename from src/pty/node.ts rename to src/pty/node-backend.ts index 80c0edc..b5b1027 100644 --- a/src/pty/node.ts +++ b/src/pty/node-backend.ts @@ -1,5 +1,5 @@ import nodePty from '@lydell/node-pty'; -import type { PtyProcess } from './types'; +import type { PtyProcess } from './pty-process'; /** * Spawns a PTY-backed shell using the `@lydell/node-pty` native addon. diff --git a/src/pty/types.ts b/src/pty/pty-process.ts similarity index 100% rename from src/pty/types.ts rename to src/pty/pty-process.ts From 61b972729a364f5166a8a5bee046568430a002d2 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:17:48 -0400 Subject: [PATCH 198/295] test(client): fakes for the window, ResizeObserver and a feature's ClientContext FakeWindow records its listeners as `window` and hands out recording media queries, so a devicePixelRatio change can be played; fakeResizeObserver records what is observed and reports resizes; fakeContext builds a ClientContext on a FakeTerminal and an injected happy-dom document with settable fonts.ready and visibilityState. FakeTerminal gains loadAddon, syncCellPixelSize and a renderer as open() gives one. The SPEC's test-support list names the new fakes. --- docs/specs/architecture.md | 3 +- src/client/test-support.test.ts | 83 +++++++++++++++++ src/client/test-support/fake-context.ts | 88 +++++++++++++++++++ .../test-support/fake-resize-observer.ts | 49 +++++++++++ src/client/test-support/fake-terminal.ts | 29 ++++++ src/client/test-support/fake-window.ts | 51 +++++++++++ 6 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/client/test-support/fake-context.ts create mode 100644 src/client/test-support/fake-resize-observer.ts create mode 100644 src/client/test-support/fake-window.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 22b95c6..804bcca 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -154,7 +154,8 @@ src/ kitty-error.ts, kitty-terminal.ts, overlay-canvas.ts (+ dispose), kitty-graphics.ts (facade), test-support/graphics-harness.ts kitty-feature.ts - test-support/ fake-terminal.ts, fake-socket.ts, manual-clock.ts, recording-target.ts, dom-window.ts + test-support/ fake-terminal.ts, fake-socket.ts, fake-window.ts, fake-resize-observer.ts, fake-context.ts, + manual-clock.ts, recording-target.ts, dom-window.ts test-support.test.ts the fakes' self-tests (a test-support/ folder holds no *.test.ts) vendor/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md diff --git a/src/client/test-support.test.ts b/src/client/test-support.test.ts index 247cbd1..b12ded4 100644 --- a/src/client/test-support.test.ts +++ b/src/client/test-support.test.ts @@ -3,8 +3,11 @@ import { describe, expect, test } from 'bun:test'; import { createDomWindow } from './test-support/dom-window'; +import { FAKE_CONFIG, fakeContext } from './test-support/fake-context'; +import { fakeResizeObserver } from './test-support/fake-resize-observer'; import { fakeWebSocket } from './test-support/fake-socket'; import { FakeTerminal } from './test-support/fake-terminal'; +import { FakeWindow } from './test-support/fake-window'; import { ManualClock } from './test-support/manual-clock'; import { RecordingTarget } from './test-support/recording-target'; @@ -212,6 +215,86 @@ describe('FakeTerminal', () => { ]); expect(term.asTerminal() as object).toBe(term); }); + + test('loads addons, syncs cell pixels and gets a renderer as open() gives one', () => { + const term = new FakeTerminal(); + let activatedWith: unknown; + term.loadAddon({ activate: (t) => (activatedWith = t) }); + term.syncCellPixelSize(); + expect(activatedWith).toBe(term); + expect(term.renderer).toBeUndefined(); + const renderer = term.attachRenderer(); + expect(term.wasmTerm).toBeDefined(); + renderer.render({}, false, 2); + renderer.remeasureFont(); + expect(term.calls).toEqual([ + 'loadAddon', + 'syncCellPixelSize', + 'render:dirty@2', + 'remeasureFont', + ]); + }); +}); + +describe('FakeWindow', () => { + test('records listeners as `window`, media queries, and close and focus calls', () => { + const win = new FakeWindow('https://example.test:1234/s/abc'); + expect(win.location.host).toBe('example.test:1234'); + expect(win.location.pathname).toBe('/s/abc'); + win.addEventListener('resize', () => {}); + expect(win.added[0].target).toBe('window'); + const query = win.matchMedia('(resolution: 1dppx)'); + expect(query.matches).toBe(true); + expect(query.name).toBe('media (resolution: 1dppx)'); + expect(win.queries).toEqual([query]); + win.close(); + win.focus(); + expect(win.calls).toEqual(['close', 'focus']); + expect(win.asWindow() as object).toBe(win); + }); +}); + +describe('fakeResizeObserver', () => { + test('records observers and targets, reports resizes until disconnected', () => { + const { ResizeObserver, observers } = fakeResizeObserver(); + let resizes = 0; + const observer = new ResizeObserver(() => resizes++); + const a = {} as Element; + const b = {} as Element; + observer.observe(a, { box: 'border-box' }); + observer.observe(b); + observer.unobserve(b); + observer.unobserve(b); + expect(observers[0].observed).toEqual([{ target: a, options: { box: 'border-box' } }]); + observers[0].resize(); + observer.disconnect(); + observers[0].resize(); + expect(resizes).toBe(1); + expect(observers[0].disconnected).toBe(true); + }); +}); + +describe('fakeContext', () => { + test('a context over a happy-dom #terminal with settable fonts and visibility', async () => { + const fake = fakeContext({ padding: 4 }); + expect(fake.ctx.config.padding).toBe(4); + expect(fake.ctx.config.fontSize).toBe(FAKE_CONFIG.fontSize); + expect(fake.ctx.doc.getElementById('terminal')).toBe(fake.container); + expect(fake.ctx.term as object).toBe(fake.term); + expect(fake.ctx.win as object).toBe(fake.win); + let changes = 0; + fake.ctx.doc.addEventListener('visibilitychange', () => changes++); + fake.setVisibility('hidden'); + expect(fake.ctx.doc.visibilityState).toBe('hidden'); + expect(changes).toBe(1); + let loaded = false; + void fake.ctx.doc.fonts.ready.then(() => { + loaded = true; + }); + await fake.fontsLoaded(); + expect(loaded).toBe(true); + await fake.dom.close(); + }); }); describe('createDomWindow', () => { diff --git a/src/client/test-support/fake-context.ts b/src/client/test-support/fake-context.ts new file mode 100644 index 0000000..00cf92f --- /dev/null +++ b/src/client/test-support/fake-context.ts @@ -0,0 +1,88 @@ +// A ClientContext on fakes for feature tests (test fake, ADR 064): a FakeTerminal, a +// recording FakeWindow and an injected happy-dom document holding `#terminal`. The +// document's `fonts.ready` and `visibilityState`, which happy-dom lacks or fixes, are set +// by the test. + +import type { ClientConfig } from '../app/client-config'; +import type { ClientContext } from '../app/client-context'; +import { createDomWindow, type DomWindow } from './dom-window'; +import { FakeTerminal } from './fake-terminal'; +import { FakeWindow } from './fake-window'; + +/** The context and the fakes behind it, for the test to drive. */ +export interface FakeContext { + ctx: ClientContext; + term: FakeTerminal; + win: FakeWindow; + dom: DomWindow; + container: HTMLElement; + /** Resolves `document.fonts.ready`, as the browser does once the web fonts load. */ + fontsLoaded(): Promise<void>; + /** Sets `document.visibilityState` and dispatches `visibilitychange`. */ + setVisibility(state: DocumentVisibilityState): void; +} + +/** The config a feature sees when the test does not say otherwise. */ +export const FAKE_CONFIG: ClientConfig = { + cols: 80, + rows: 24, + fontSize: 14, + fontFamily: 'monospace', + cursorStyle: 'block', + cursorStyleBlink: false, + scrollbackLines: 1000, + theme: {}, + copyOnSelect: false, + rightClickBehavior: 'contextMenu', + clipboardWrite: true, + bellTitle: true, + desktopNotifications: true, + mouseScrollSpeed: 1, + padding: 0, + keyboardBindings: [], +}; + +/** Builds a context on fakes; close `dom` in the test's teardown. */ +export function fakeContext(config: Partial<ClientConfig> = {}): FakeContext { + const dom = createDomWindow(); + const { document } = dom; + const container = document.createElement('div'); + container.id = 'terminal'; + document.body.append(container); + let loaded: () => void = () => {}; + const ready = new Promise<void>((resolve) => { + loaded = resolve; + }); + Object.defineProperty(document, 'fonts', { value: { ready }, configurable: true }); + let visibility: DocumentVisibilityState = 'visible'; + Object.defineProperty(document, 'visibilityState', { + get: () => visibility, + configurable: true, + }); + const term = new FakeTerminal(); + const win = new FakeWindow(); + const ctx: ClientContext = { + term: term.asTerminal(), + container, + config: { ...FAKE_CONFIG, ...config }, + terminalTheme: {}, + mac: false, + doc: document, + win: win.asWindow(), + }; + return { + ctx, + term, + win, + dom, + container, + fontsLoaded: async () => { + loaded(); + await ready; + }, + setVisibility: (state) => { + visibility = state; + document.dispatchEvent(new dom.window.Event('visibilitychange')); + }, + }; +} diff --git a/src/client/test-support/fake-resize-observer.ts b/src/client/test-support/fake-resize-observer.ts new file mode 100644 index 0000000..cd476db --- /dev/null +++ b/src/client/test-support/fake-resize-observer.ts @@ -0,0 +1,49 @@ +// A ResizeObserver stand-in (test fake, ADR 064): it records what is observed and lets the +// test report a resize. fakeResizeObserver() gives a constructor to inject and its observers. + +/** One observer: its callback, what it watches, and whether it was disconnected. */ +export class FakeResizeObserver { + readonly observed: { target: Element; options?: ResizeObserverOptions }[] = []; + disconnected = false; + + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element, options?: ResizeObserverOptions): void { + this.observed.push({ target, options }); + } + + unobserve(target: Element): void { + const index = this.observed.findIndex((entry) => entry.target === target); + if (index >= 0) this.observed.splice(index, 1); + } + + disconnect(): void { + this.disconnected = true; + this.observed.length = 0; + } + + /** Reports a resize of everything observed, as the browser does after layout. */ + resize(): void { + if (this.disconnected) return; + this.callback([], this as unknown as ResizeObserver); + } +} + +/** A `ResizeObserver` constructor to inject, and the observers it made, in order. */ +export interface FakeResizeObserverClass { + ResizeObserver: typeof ResizeObserver; + observers: FakeResizeObserver[]; +} + +/** Makes a fresh `ResizeObserver` stand-in whose observers the test can reach. */ +export function fakeResizeObserver(): FakeResizeObserverClass { + const observers: FakeResizeObserver[] = []; + class RecordedObserver extends FakeResizeObserver { + constructor(callback: ResizeObserverCallback) { + super(callback); + observers.push(this); + } + } + // The fake covers the members the client uses. + return { ResizeObserver: RecordedObserver as unknown as typeof ResizeObserver, observers }; +} diff --git a/src/client/test-support/fake-terminal.ts b/src/client/test-support/fake-terminal.ts index b3d8bfc..c3bebf2 100644 --- a/src/client/test-support/fake-terminal.ts +++ b/src/client/test-support/fake-terminal.ts @@ -28,12 +28,21 @@ export interface TerminalEvents { /** The name of a terminal event. */ export type TerminalEventName = keyof TerminalEvents; +/** The slice of the renderer the layout uses; its calls go to the terminal's `calls`. */ +export interface FakeRenderer { + render(buffer: object, forceAll: boolean, viewportY: number): void; + remeasureFont(): void; +} + /** A fake `Terminal`: fire its events, read what was written, called and subscribed. */ export class FakeTerminal { cols = 80; rows = 24; viewportY = 0; options: Record<string, unknown> = {}; + /** Undefined until {@link attachRenderer}, as before `open()`. */ + renderer: FakeRenderer | undefined; + wasmTerm: object | undefined; /** Everything passed to `write`, in order. */ readonly written: (string | Uint8Array)[] = []; /** Every command called, as `name` or `name:arg`, in order. */ @@ -108,6 +117,26 @@ export class FakeTerminal { this.calls.push('reset'); } + syncCellPixelSize(): void { + this.calls.push('syncCellPixelSize'); + } + + loadAddon(addon: { activate(terminal: unknown): void }): void { + this.calls.push('loadAddon'); + addon.activate(this); + } + + /** Gives the terminal a renderer and an engine, as `open()` does; returns the renderer. */ + attachRenderer(): FakeRenderer { + this.wasmTerm = {}; + this.renderer = { + render: (_buffer, forceAll, viewportY) => + this.calls.push(`render:${forceAll ? 'all' : 'dirty'}@${viewportY}`), + remeasureFont: () => this.calls.push('remeasureFont'), + }; + return this.renderer; + } + scrollToBottom(): void { this.calls.push('scrollToBottom'); this.viewportY = 0; diff --git a/src/client/test-support/fake-window.ts b/src/client/test-support/fake-window.ts new file mode 100644 index 0000000..74839d4 --- /dev/null +++ b/src/client/test-support/fake-window.ts @@ -0,0 +1,51 @@ +// The window as the client root uses it, as a fake (test fake, ADR 064): a RecordingTarget +// named `window` for the listener-order tests, with a settable devicePixelRatio and a +// matchMedia whose queries are recording targets too, so a DPR change can be played. + +import type { ClientWindow } from '../app/browser-env'; +import { RecordingTarget } from './recording-target'; + +/** A media query list the test can fire `change` on; it records its own listeners. */ +export class FakeMediaQuery extends RecordingTarget { + readonly matches = true; + + constructor(readonly media: string) { + super(`media ${media}`); + } +} + +/** A fake `ClientWindow`: records its listeners, the queries asked for, and close/focus calls. */ +export class FakeWindow extends RecordingTarget { + devicePixelRatio = 1; + readonly location: Location; + /** Every media query asked for, in order. */ + readonly queries: FakeMediaQuery[] = []; + /** `close` and `focus` calls, in order. */ + readonly calls: string[] = []; + + constructor(url = 'http://localhost/s/test') { + super('window'); + // URL has the fields the client reads from location (protocol, host, pathname). + this.location = new URL(url) as unknown as Location; + } + + readonly matchMedia = (media: string): FakeMediaQuery => { + const query = new FakeMediaQuery(media); + this.queries.push(query); + return query; + }; + + close(): void { + this.calls.push('close'); + } + + focus(): void { + this.calls.push('focus'); + } + + /** The fake typed as the window the client takes. */ + asWindow(): ClientWindow { + // The fake covers the members the client uses, not the whole MediaQueryList. + return this as unknown as ClientWindow; + } +} From 4f3fa6068647d443afd6c5bfb5110cd432226249 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:18:21 -0400 Subject: [PATCH 199/295] refactor(client): layout/: Fitter, layout feature and repaint on visible The fit and its triggers leave index.ts (SPEC step 2, ADR 060): - layout/fitter.ts: Fitter, fit() and an rAF-coalesced schedule(), with onFitted hooks run in order after the padding is placed (the edge strips, then smooth scroll); - layout/layout-feature.ts: installLayout loads the FitAddon, schedules the first fit and refits on ResizeObserver, window resize, fonts.ready and DPR changes, returning { fit, schedule }; applyPaddingColor still runs before term.open(); - layout/repaint-on-visible.ts: the document visibilitychange full repaint (ADR 031), installed right after the layout, still the first of the document's visibilitychange listeners. Font zoom calls layout.fit() synchronously. Listener targets, types, phases and order are unchanged; every listener goes through listen() into a store that dispose() releases, which nothing calls at runtime. index.ts builds the ClientContext the installers take; its allowlist entry drops to 369 lines. --- scripts/architecture/ratchet-allowlist.ts | 2 +- src/client/index.ts | 118 +++----------- src/client/layout/fitter.test.ts | 162 +++++++++++++++++++ src/client/layout/fitter.ts | 114 +++++++++++++ src/client/layout/layout-feature.test.ts | 149 +++++++++++++++++ src/client/layout/layout-feature.ts | 104 ++++++++++++ src/client/layout/repaint-on-visible.test.ts | 36 +++++ src/client/layout/repaint-on-visible.ts | 21 +++ 8 files changed, 609 insertions(+), 97 deletions(-) create mode 100644 src/client/layout/fitter.test.ts create mode 100644 src/client/layout/fitter.ts create mode 100644 src/client/layout/layout-feature.test.ts create mode 100644 src/client/layout/layout-feature.ts create mode 100644 src/client/layout/repaint-on-visible.test.ts create mode 100644 src/client/layout/repaint-on-visible.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 4a58b09..bf1b4ab 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -20,7 +20,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. - 'src/client/index.ts': { lines: 443, step: CLIENT_ROOT }, + 'src/client/index.ts': { lines: 369, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, diff --git a/src/client/index.ts b/src/client/index.ts index 82dd101..fa099c0 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,6 @@ import { browserEnv } from './app/browser-env'; import { type KeyboardBinding, loadClientConfig } from './app/client-config'; +import type { ClientContext } from './app/client-context'; import { detectMac, isTerminalFocused } from './app/platform'; import { toTerminalOptions } from './app/terminal-options'; import { toTerminalTheme } from './app/terminal-theme'; @@ -18,9 +19,8 @@ import { SmoothScroll } from './features/scroll/smooth-scroll'; import { WheelStream } from './features/scroll/wheel-stream'; import { TabTitle } from './features/tab-title/tab-title'; import { EdgeExtender } from './layout/edge-extender'; -import { computeInsets } from './layout/fit-insets'; -import { terminalCanvas } from './layout/terminal-canvas'; -import { FitAddon, watchDevicePixelRatio } from './vendor/ghostty-web'; +import { applyPaddingColor, installLayout } from './layout/layout-feature'; +import { installRepaintOnVisible } from './layout/repaint-on-visible'; const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; @@ -50,15 +50,19 @@ const terminalTheme = toTerminalTheme(config.theme ?? {}); const term = env.createTerminal(toTerminalOptions(config, terminalTheme, ghostty)); const container = document.getElementById('terminal') as HTMLElement; -// The canvas paints theme.background; the container shows only in the padding around -// it, so theme.padding (default: background) colours the space set by config.padding. -// The sub-cell leftover at the right and bottom is not coloured: EdgeExtender stretches -// the edge cells' own background into it (see edge.ts). -const paddingColor = config.theme?.padding ?? config.theme?.background; -if (paddingColor) { - container.style.background = paddingColor; -} +applyPaddingColor(container, config); await term.open(container); + +const ctx: ClientContext = { + term, + container, + config, + terminalTheme, + mac: detectMac(env.navigator), + doc: env.document, + win: env.window, +}; + // The strips copy the edges of the part of the canvas in view (ADR 043). const edges = new EdgeExtender(container, config.padding, () => term.renderer?.visibleArea); // IME / dictation composition shows at the cursor in the theme's colours, not as a @@ -68,89 +72,11 @@ new Composer(term, container, terminalTheme); const wheelStream = new WheelStream(); const smoothScroll = new SmoothScroll(term, wheelStream); -// FitAddon sizes the grid from the full container less its CSS padding, so the -// padding is set to config.padding on every side first (#terminal is border-box, so -// clientWidth/Height do not change). What is left over, plus config.padding on each -// side, is then distributed as padding: top and left are exactly config.padding, and -// the leftover goes to the right and bottom. proposeDimensions rather than -// FitAddon.fit(), which ignores calls for 50 ms after each resize. See ADR 033. -const fitAddon = new FitAddon(); -term.loadAddon(fitAddon); -function fit(): void { - container.style.padding = `${config.padding}px`; - const grid = fitAddon.proposeDimensions(); - if (!grid) return; - if (grid.cols !== term.cols || grid.rows !== term.rows) { - term.resize(grid.cols, grid.rows); - } - // Every font zoom, font load and DPR change reaches fit(). A grid change has - // already sent the new cell pixels with resize; this sends them when only the - // pixels changed, for Ghostty's size reports (ADR 042). - term.syncCellPixelSize(); - const canvas = terminalCanvas(container); - if (!canvas) return; - const insets = computeInsets( - Math.max(0, container.clientWidth - canvas.offsetWidth), - Math.max(0, container.clientHeight - canvas.offsetHeight), - config.padding, - ); - container.style.paddingLeft = `${insets.left}px`; - container.style.paddingRight = `${insets.right}px`; - container.style.paddingTop = `${insets.top}px`; - container.style.paddingBottom = `${insets.bottom}px`; - edges.update(); - smoothScroll.update(); -} - -// Both ResizeObserver and window 'resize' can fire for the same physical event. -// Schedule through rAF so multiple signals coalesce into one fit per frame. -let pendingFitFrame: number | null = null; -function scheduleFit(): void { - if (pendingFitFrame !== null) return; - pendingFitFrame = window.requestAnimationFrame(() => { - pendingFitFrame = null; - fit(); - }); -} - -scheduleFit(); -new ResizeObserver(() => scheduleFit()).observe(container, { box: 'border-box' }); -// ResizeObserver misses monitor hot-plug and DPI changes because those resize -// the viewport without changing the container's layout box. window resize fires -// reliably for both, so use both observers together. -window.addEventListener('resize', scheduleFit); - -// Chromium can still be loading config.fontFamily when ghostty-web's -// CanvasRenderer measures font metrics synchronously on open, so mouse-to-cell -// hit testing gets captured against the fallback font's glyph width instead of -// the real one — clicks land on the wrong cell until something happens to -// resize the terminal. Firefox blocks first layout on web font load and -// doesn't hit this race. Remeasure once the real font is confirmed ready; -// ghostty-web's render loop then notices the canvas-vs-metrics mismatch on -// its next frame and self-corrects with a forced full repaint. See ADR 031. -// The new cell size also changes how many cols and rows fit and where the padding -// and edge strips go, so run the fit again once the metrics have changed. -document.fonts.ready.then(() => { - term.renderer?.remeasureFont(); - scheduleFit(); -}); - -// Chromium can evict a hidden tab's canvas backing store to reclaim GPU -// memory; Firefox does not. ghostty-web's render loop only repaints -// WASM-dirty rows, so a cleared canvas can stay blank/partial after switching -// back to the tab until content happens to change. Force a full repaint on -// visibility restore. See ADR 031. -document.addEventListener('visibilitychange', () => { - if (document.visibilityState !== 'visible') return; - if (term.renderer && term.wasmTerm) { - term.renderer.render(term.wasmTerm, true, term.viewportY); - } -}); - -// The renderer follows devicePixelRatio changes itself (whole ratio, ADR 039). The -// edge strips also draw at the ratio, and the window may not resize on a move to a -// display of different scale, so fit again on a change. -watchDevicePixelRatio(window, scheduleFit); +// The fit, its first run, and the resize, font and pixel ratio signals that refit (ADR 033) +const layout = installLayout(ctx, { ...env, edges, smoothScroll }); +// The document listener is the first of the SPEC's visibilitychange listeners; the +// layout's own are on other targets, so their relative order does not matter. +installRepaintOnVisible(ctx); // Created with the connection below; read only when a reply is sent. let connection: PtyConnection; @@ -313,7 +239,7 @@ window.addEventListener( else if (zoomOut) currentFontSize = Math.max(6, currentFontSize - 1); else currentFontSize = Math.min(32, Math.max(6, config.fontSize)); term.options.fontSize = currentFontSize; - fit(); + layout.fit(); }, { capture: true }, ); @@ -370,7 +296,7 @@ term.onPasteRequest(() => { // configured-binding listener above, so skip a combo the user has already bound // (including an intentionally empty binding meant to consume the key and suppress the // default). Both run before the input handler, which listens on the container too. -const mac = detectMac(navigator); +const { mac } = ctx; container.addEventListener( 'keydown', (e: KeyboardEvent) => { diff --git a/src/client/layout/fitter.test.ts b/src/client/layout/fitter.test.ts new file mode 100644 index 0000000..d83f532 --- /dev/null +++ b/src/client/layout/fitter.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../test-support/dom-window'; +import { ManualClock } from '../test-support/manual-clock'; +import { Fitter, type FitterHost, type GridProposal } from './fitter'; + +let dom: DomWindow; +afterEach(() => dom.close()); + +/** Gives `el` a layout box: happy-dom does no layout, so the test injects the sizes. */ +function measure(el: HTMLElement, sizes: Record<string, number>): void { + for (const [name, value] of Object.entries(sizes)) { + Object.defineProperty(el, name, { value, configurable: true }); + } +} + +/** + * An 809x407 container holding an 800x400 terminal canvas, a host that logs its calls and + * a fitter with `padding` over them; `proposal` is what the grid source answers. + */ +function setup(padding = 0) { + dom = createDomWindow(); + const container = dom.document.createElement('div'); + dom.document.body.append(container); + const canvas = dom.document.createElement('canvas'); + measure(canvas, { offsetWidth: 800, offsetHeight: 400 }); + container.append(canvas); + measure(container, { clientWidth: 809, clientHeight: 407 }); + const calls: string[] = []; + const host = { + cols: 80, + rows: 24, + resize(cols: number, rows: number) { + calls.push(`resize:${cols}x${rows}`); + this.cols = cols; + this.rows = rows; + }, + syncCellPixelSize: () => calls.push('syncCellPixelSize'), + }; + let proposal: GridProposal | undefined = { cols: 80, rows: 24 }; + const clock = new ManualClock(); + const fitter = new Fitter( + host satisfies FitterHost, + container, + padding, + { proposeDimensions: () => proposal }, + { + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }, + ); + return { + container, + canvas, + calls, + clock, + fitter, + propose: (next: GridProposal | undefined) => { + proposal = next; + }, + }; +} + +function padding(el: HTMLElement): string[] { + const { paddingTop, paddingRight, paddingBottom, paddingLeft } = el.style; + return [paddingTop, paddingRight, paddingBottom, paddingLeft]; +} + +describe('Fitter', () => { + test('fit() resizes to the proposed grid and syncs the cell pixels, synchronously', () => { + const { fitter, calls, propose } = setup(); + propose({ cols: 100, rows: 30 }); + fitter.fit(); + expect(calls).toEqual(['resize:100x30', 'syncCellPixelSize']); + }); + + test('an unchanged grid is not resized, but the cell pixels are still synced', () => { + const { fitter, calls } = setup(); + fitter.fit(); + expect(calls).toEqual(['syncCellPixelSize']); + }); + + test('no proposal (the font is not measured yet) only resets the padding', () => { + const { fitter, calls, container, propose } = setup(6); + propose(undefined); + let hooks = 0; + fitter.onFitted(() => hooks++); + fitter.fit(); + expect(container.style.padding).toBe('6px'); + expect(calls).toEqual([]); + expect(hooks).toBe(0); + }); + + test('places the padding: top and left exact, the leftover to the right and bottom', () => { + const { fitter, container } = setup(0); + fitter.fit(); + expect(padding(container)).toEqual(['0px', '9px', '7px', '0px']); + }); + + test('the leftover includes the configured padding on both sides', () => { + const { fitter, container } = setup(2); + fitter.fit(); + expect(padding(container)).toEqual(['2px', '7px', '5px', '2px']); + }); + + test('without a terminal canvas the padding stays uniform and no hook runs', () => { + const { fitter, container, canvas } = setup(3); + canvas.dataset.edge = 'true'; + let hooks = 0; + fitter.onFitted(() => hooks++); + fitter.fit(); + expect(padding(container)).toEqual(['3px', '3px', '3px', '3px']); + expect(hooks).toBe(0); + }); + + test('hooks run after the padding is placed, in the order added', () => { + const { fitter, container } = setup(); + const log: string[] = []; + fitter.onFitted(() => log.push(`edges ${container.style.paddingRight}`)); + fitter.onFitted(() => log.push('smoothScroll')); + fitter.fit(); + expect(log).toEqual(['edges 9px', 'smoothScroll']); + }); + + test('a removed hook no longer runs; removing it twice is harmless', () => { + const { fitter } = setup(); + let runs = 0; + const hook = fitter.onFitted(() => runs++); + hook.dispose(); + hook.dispose(); + fitter.fit(); + expect(runs).toBe(0); + }); + + test('schedule() coalesces the signals of one frame into one fit', () => { + const { fitter, calls, clock } = setup(); + fitter.schedule(); + fitter.schedule(); + fitter.schedule(); + expect(calls).toEqual([]); + expect(clock.pending.frames).toBe(1); + clock.frame(); + expect(calls).toEqual(['syncCellPixelSize']); + fitter.schedule(); + clock.frame(); + expect(calls).toEqual(['syncCellPixelSize', 'syncCellPixelSize']); + }); + + test('dispose cancels a scheduled fit and stops scheduling; twice is a no-op', () => { + const { fitter, calls, clock } = setup(); + let hooks = 0; + fitter.onFitted(() => hooks++); + fitter.schedule(); + fitter.dispose(); + expect(clock.pending.frames).toBe(0); + fitter.schedule(); + expect(clock.pending.frames).toBe(0); + fitter.dispose(); + fitter.fit(); + expect(calls).toEqual(['syncCellPixelSize']); + expect(hooks).toBe(0); + }); +}); diff --git a/src/client/layout/fitter.ts b/src/client/layout/fitter.ts new file mode 100644 index 0000000..4bca33e --- /dev/null +++ b/src/client/layout/fitter.ts @@ -0,0 +1,114 @@ +// Sizes the terminal grid to `#terminal` and places the padding around the canvas +// (service, ADR 033, ADR 042, ADR 060). Every resize signal goes through schedule(), +// which coalesces them into one fit per frame; font zoom calls fit() directly. + +import { type Disposable, toDisposable } from '../vendor/ghostty-web'; +import { computeInsets } from './fit-insets'; +import { terminalCanvas } from './terminal-canvas'; + +/** The grid the fit addon proposes for the container, or undefined before the font is measured. */ +export interface GridProposal { + cols: number; + rows: number; +} + +/** What the fitter reads from and resizes: the vendored `Terminal` in production. */ +export interface FitterHost { + readonly cols: number; + readonly rows: number; + resize(cols: number, rows: number): void; + /** Sends the cell size in device pixels for Ghostty's size reports (ADR 042). */ + syncCellPixelSize(): void; +} + +/** The grid size source, the vendored `FitAddon` in production. */ +export interface GridSource { + proposeDimensions(): GridProposal | undefined; +} + +/** The frame scheduler the fitter coalesces on, the window's in production. */ +export interface FitterDeps { + requestAnimationFrame(callback: FrameRequestCallback): number; + cancelAnimationFrame(id: number): void; +} + +/** Fits the grid and the padding, and tells its hooks, in the order added, after each fit. */ +export class Fitter implements Disposable { + private pendingFrame: number | null = null; + private readonly hooks: (() => void)[] = []; + private disposed = false; + + constructor( + private readonly term: FitterHost, + private readonly container: HTMLElement, + private readonly padding: number, + private readonly grid: GridSource, + private readonly deps: FitterDeps, + ) {} + + /** + * Runs `hook` after every fit that found the canvas, after the padding is placed: the + * edge strips, then smooth scroll, which both read the new layout. + */ + onFitted(hook: () => void): Disposable { + this.hooks.push(hook); + return toDisposable(() => { + const index = this.hooks.indexOf(hook); + if (index >= 0) this.hooks.splice(index, 1); + }); + } + + /** + * Fits now. FitAddon sizes the grid from the full container less its CSS padding, so + * the padding is set to `padding` on every side first (#terminal is border-box, so + * clientWidth/Height do not change). What is left over, plus `padding` on each side, + * is then distributed: top and left are exactly `padding`, the leftover goes to the + * right and bottom. proposeDimensions rather than FitAddon.fit(), which ignores calls + * for 50 ms after each resize (ADR 033). + */ + fit(): void { + this.container.style.padding = `${this.padding}px`; + const grid = this.grid.proposeDimensions(); + if (!grid) return; + if (grid.cols !== this.term.cols || grid.rows !== this.term.rows) { + this.term.resize(grid.cols, grid.rows); + } + // Every font zoom, font load and DPR change reaches fit(). A grid change has already + // sent the new cell pixels with resize; this sends them when only the pixels + // changed, for Ghostty's size reports (ADR 042). + this.term.syncCellPixelSize(); + const canvas = terminalCanvas(this.container); + if (!canvas) return; + const insets = computeInsets( + Math.max(0, this.container.clientWidth - canvas.offsetWidth), + Math.max(0, this.container.clientHeight - canvas.offsetHeight), + this.padding, + ); + this.container.style.paddingLeft = `${insets.left}px`; + this.container.style.paddingRight = `${insets.right}px`; + this.container.style.paddingTop = `${insets.top}px`; + this.container.style.paddingBottom = `${insets.bottom}px`; + for (const hook of this.hooks) hook(); + } + + /** + * Fits on the next frame. ResizeObserver and window `resize` can fire for the same + * physical change, so the signals of one frame coalesce into one fit. + */ + schedule(): void { + if (this.disposed || this.pendingFrame !== null) return; + this.pendingFrame = this.deps.requestAnimationFrame(() => { + this.pendingFrame = null; + this.fit(); + }); + } + + /** Cancels a scheduled fit and drops the hooks. Idempotent. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.pendingFrame !== null) this.deps.cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = null; + this.hooks.length = 0; + } +} diff --git a/src/client/layout/layout-feature.test.ts b/src/client/layout/layout-feature.test.ts new file mode 100644 index 0000000..f3d4d52 --- /dev/null +++ b/src/client/layout/layout-feature.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../test-support/fake-context'; +import { fakeResizeObserver } from '../test-support/fake-resize-observer'; +import { ManualClock } from '../test-support/manual-clock'; +import type { GridProposal } from './fitter'; +import { applyPaddingColor, installLayout, type LayoutDeps } from './layout-feature'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** The layout installed on fakes; `log` records the fit listeners' updates in order. */ +function setup(deps: Partial<LayoutDeps> = {}) { + fake = fakeContext(); + const canvas = fake.dom.document.createElement('canvas'); + fake.container.append(canvas); + const log: string[] = []; + const clock = new ManualClock(); + const observers = fakeResizeObserver(); + let proposal: GridProposal | undefined = { cols: 80, rows: 24 }; + const layout = installLayout(fake.ctx, { + edges: { update: () => log.push('edges') }, + smoothScroll: { update: () => log.push('smoothScroll') }, + ResizeObserver: observers.ResizeObserver, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + grid: { proposeDimensions: () => proposal }, + ...deps, + }); + return { + layout, + log, + clock, + observers: observers.observers, + propose: (next: GridProposal | undefined) => { + proposal = next; + }, + }; +} + +describe('installLayout', () => { + test('the first fit is scheduled at install and runs on the next frame', () => { + const { log, clock } = setup(); + expect(log).toEqual([]); + expect(clock.pending.frames).toBe(1); + clock.frame(); + expect(log).toEqual(['edges', 'smoothScroll']); + expect(fake.term.calls).toEqual(['syncCellPixelSize']); + }); + + test('fit() is synchronous (font zoom) and resizes the terminal', () => { + const { layout, log, propose } = setup(); + propose({ cols: 90, rows: 30 }); + layout.fit(); + expect(fake.term.calls).toEqual(['resize:90x30', 'syncCellPixelSize']); + expect(log).toEqual(['edges', 'smoothScroll']); + }); + + test('observes the container border box and refits on a resize, coalesced per frame', () => { + const { log, clock, observers } = setup(); + clock.frame(); + log.length = 0; + expect(observers).toHaveLength(1); + expect(observers[0].observed).toEqual([ + { target: fake.container, options: { box: 'border-box' } }, + ]); + observers[0].resize(); + fake.win.dispatch({ type: 'resize' }); + expect(clock.pending.frames).toBe(1); + clock.frame(); + expect(log).toEqual(['edges', 'smoothScroll']); + }); + + test('adds one window listener, `resize` in the bubble phase', () => { + setup(); + expect(fake.win.added.map(({ target, type, capture }) => ({ target, type, capture }))).toEqual([ + { target: 'window', type: 'resize', capture: false }, + ]); + }); + + test('once the web fonts load, remeasures the font and refits', async () => { + const { clock } = setup(); + clock.frame(); + const renderer = fake.term.attachRenderer(); + expect(renderer).toBeDefined(); + fake.term.calls.length = 0; + await fake.fontsLoaded(); + expect(fake.term.calls).toEqual(['remeasureFont']); + expect(clock.pending.frames).toBe(1); + }); + + test('a font load before open() only refits', async () => { + const { clock } = setup(); + clock.frame(); + await fake.fontsLoaded(); + expect(fake.term.calls).toEqual(['syncCellPixelSize']); + expect(clock.pending.frames).toBe(1); + }); + + test('a device pixel ratio change refits and re-arms for the new ratio', () => { + const { clock } = setup(); + clock.frame(); + expect(fake.win.queries.map((q) => q.media)).toEqual(['(resolution: 1dppx)']); + fake.win.devicePixelRatio = 2; + fake.win.queries[0].dispatch({ type: 'change' }); + expect(clock.pending.frames).toBe(1); + expect(fake.win.queries.map((q) => q.media)).toEqual([ + '(resolution: 1dppx)', + '(resolution: 2dppx)', + ]); + }); + + test('without deps.grid, loads the vendored FitAddon into the terminal', () => { + setup({ grid: undefined }); + expect(fake.term.calls).toEqual(['loadAddon']); + }); + + test('dispose removes everything it added and stops refitting; twice is a no-op', async () => { + const { layout, log, clock, observers } = setup(); + layout.dispose(); + expect(clock.pending.frames).toBe(0); + expect(fake.win.active).toEqual([]); + expect(fake.win.removed.map((r) => r.type)).toEqual(['resize']); + expect(observers[0].disconnected).toBe(true); + expect(fake.win.queries[0].active).toEqual([]); + fake.win.dispatch({ type: 'resize' }); + fake.term.attachRenderer(); + await fake.fontsLoaded(); + clock.frame(); + expect(log).toEqual([]); + expect(fake.term.calls).toEqual([]); + layout.dispose(); + }); +}); + +describe('applyPaddingColor', () => { + test('colours the container with theme.padding, else theme.background, else nothing', () => { + fake = fakeContext(); + const { container } = fake; + applyPaddingColor(container, { ...fake.ctx.config, theme: {} }); + expect(container.style.background).toBe(''); + applyPaddingColor(container, { ...fake.ctx.config, theme: { background: '#102030' } }); + expect(container.style.background).toBe('#102030'); + applyPaddingColor(container, { + ...fake.ctx.config, + theme: { background: '#102030', padding: '#405060' }, + }); + expect(container.style.background).toBe('#405060'); + }); +}); diff --git a/src/client/layout/layout-feature.ts b/src/client/layout/layout-feature.ts new file mode 100644 index 0000000..c27bc0a --- /dev/null +++ b/src/client/layout/layout-feature.ts @@ -0,0 +1,104 @@ +// Keeps the grid fitted to `#terminal` (feature, ADR 031, ADR 033, ADR 039, ADR 060): the +// fit addon and the Fitter, the first fit, and the signals that refit: ResizeObserver, +// window `resize`, the web font loading and device pixel ratio changes. + +import type { ClientConfig } from '../app/client-config'; +import type { ClientContext, Feature } from '../app/client-context'; +import { + DisposableStore, + FitAddon, + listen, + toDisposable, + watchDevicePixelRatio, +} from '../vendor/ghostty-web'; +import { Fitter, type GridSource } from './fitter'; + +/** Anything that re-reads the layout after a fit. */ +export interface FitListener { + update(): void; +} + +/** What the layout feature needs besides the context: the fit's listeners and the browser's. */ +export interface LayoutDeps { + /** The edge strips, placed after every fit (ADR 035). */ + edges: FitListener; + /** Smooth scroll, which re-reads the row height after every fit (ADR 040). */ + smoothScroll: FitListener; + ResizeObserver: typeof ResizeObserver; + requestAnimationFrame(callback: FrameRequestCallback): number; + cancelAnimationFrame(id: number): void; + /** The grid size source; the vendored FitAddon, loaded into the terminal, by default. */ + grid?: GridSource; +} + +/** The layout API others use: font zoom fits at once, everything else schedules. */ +export interface LayoutFeature extends Feature { + /** Fits now, synchronously (font zoom, ADR 033). */ + fit(): void; + /** Fits on the next frame, coalesced with any other signal of the same frame. */ + schedule(): void; +} + +/** + * Colours the space around the canvas. The canvas paints theme.background; the container + * shows only in the padding around it, so theme.padding (default: background) colours the + * space set by config.padding. The sub-cell leftover at the right and bottom is not + * coloured: EdgeExtender stretches the edge cells' own background into it (ADR 035, ADR 036). + * Called before the terminal opens, so the first paint already has it. + */ +export function applyPaddingColor(container: HTMLElement, config: ClientConfig): void { + const paddingColor = config.theme?.padding ?? config.theme?.background; + if (paddingColor) { + container.style.background = paddingColor; + } +} + +/** Loads the fit addon, fits on the next frame and refits on every layout signal. */ +export function installLayout(ctx: ClientContext, deps: LayoutDeps): LayoutFeature { + const { term, container, config, doc, win } = ctx; + const store = new DisposableStore(); + let grid = deps.grid; + if (!grid) { + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + grid = fitAddon; + } + const fitter = store.add(new Fitter(term, container, config.padding, grid, deps)); + store.add(fitter.onFitted(() => deps.edges.update())); + store.add(fitter.onFitted(() => deps.smoothScroll.update())); + const schedule = (): void => fitter.schedule(); + + schedule(); + const observer = new deps.ResizeObserver(() => schedule()); + observer.observe(container, { box: 'border-box' }); + store.add(toDisposable(() => observer.disconnect())); + // ResizeObserver misses monitor hot-plug and DPI changes because those resize the + // viewport without changing the container's layout box. window resize fires reliably + // for both, so use both observers together. + store.add(listen(win, 'resize', schedule)); + + // Chromium can still be loading config.fontFamily when the renderer measures font + // metrics synchronously on open, so mouse-to-cell hit testing is captured against the + // fallback font's glyph width: clicks land on the wrong cell until something resizes + // the terminal. Firefox blocks first layout on web font load and does not hit this + // race. Remeasure once the real font is ready; the render loop then notices the + // canvas-vs-metrics mismatch on its next frame and repaints in full (ADR 031). The new + // cell size also changes how many cols and rows fit and where the padding and edge + // strips go, so fit again once the metrics have changed. + doc.fonts.ready.then(() => { + if (store.isDisposed) return; + term.renderer?.remeasureFont(); + schedule(); + }); + + // The renderer follows devicePixelRatio changes itself (whole ratio, ADR 039). The edge + // strips also draw at the ratio, and the window may not resize on a move to a display + // of different scale, so fit again on a change. + store.add(toDisposable(watchDevicePixelRatio(win, schedule))); + + return { + fit: () => fitter.fit(), + schedule, + dispose: () => store.dispose(), + }; +} diff --git a/src/client/layout/repaint-on-visible.test.ts b/src/client/layout/repaint-on-visible.test.ts new file mode 100644 index 0000000..06cd2a9 --- /dev/null +++ b/src/client/layout/repaint-on-visible.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../test-support/fake-context'; +import { installRepaintOnVisible } from './repaint-on-visible'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +describe('installRepaintOnVisible', () => { + test('a tab made visible again repaints every row at the current viewport', () => { + fake = fakeContext(); + fake.term.attachRenderer(); + fake.term.viewportY = 3; + installRepaintOnVisible(fake.ctx); + fake.setVisibility('hidden'); + expect(fake.term.calls).toEqual([]); + fake.setVisibility('visible'); + expect(fake.term.calls).toEqual(['render:all@3']); + }); + + test('before the terminal is open there is nothing to repaint', () => { + fake = fakeContext(); + installRepaintOnVisible(fake.ctx); + fake.setVisibility('visible'); + expect(fake.term.calls).toEqual([]); + }); + + test('dispose removes the listener; twice is a no-op', () => { + fake = fakeContext(); + fake.term.attachRenderer(); + const feature = installRepaintOnVisible(fake.ctx); + feature.dispose(); + feature.dispose(); + fake.setVisibility('visible'); + expect(fake.term.calls).toEqual([]); + }); +}); diff --git a/src/client/layout/repaint-on-visible.ts b/src/client/layout/repaint-on-visible.ts new file mode 100644 index 0000000..f9b25f3 --- /dev/null +++ b/src/client/layout/repaint-on-visible.ts @@ -0,0 +1,21 @@ +// Repaints the whole canvas when the tab becomes visible again (feature, ADR 031, ADR 060). +// Chromium can evict a hidden tab's canvas backing store to reclaim GPU memory; Firefox +// does not. The render loop only repaints rows the engine marks dirty, so a cleared +// canvas could stay blank until the content changed. + +import type { ClientContext, Feature } from '../app/client-context'; +import { listen } from '../vendor/ghostty-web'; + +/** The installed repaint; nothing to call, only to dispose. */ +export type RepaintOnVisibleFeature = Feature; + +/** Forces a full repaint on every document `visibilitychange` to visible. */ +export function installRepaintOnVisible(ctx: ClientContext): RepaintOnVisibleFeature { + const { term, doc } = ctx; + return listen(doc, 'visibilitychange', () => { + if (doc.visibilityState !== 'visible') return; + if (term.renderer && term.wasmTerm) { + term.renderer.render(term.wasmTerm, true, term.viewportY); + } + }); +} From cc460f26a7146d37f21cf7dbc9c1ffdef7836a48 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:18:36 -0400 Subject: [PATCH 200/295] refactor(pty): spawnPty options object and testable backends src/pty exposes spawnPty(options: PtySpawnOptions) in place of the seven positional arguments and the reordering spawnForSession wrapper, which is removed. The backend choice is the pure selectBackend(env) and the "pty: ..." startup line is unchanged. The node-pty backend takes the addon, the platform and the base environment by injection (live values per spawn by default), so the cmd.exe /d mapping, pause/resume and the data/exit mapping are tested with a fake. Session spawns with the options object; SpawnPty now lives with PtyProcess in pty-process.ts (ADR 062). --- src/pty/bun-backend.ts | 21 ++--- src/pty/index.test.ts | 74 ++++++++++------- src/pty/index.ts | 58 ++++++-------- src/pty/node-backend.test.ts | 120 ++++++++++++++++++++++++++++ src/pty/node-backend.ts | 47 ++++++----- src/pty/pty-process.ts | 24 ++++++ src/server/app.test.ts | 2 +- src/server/app.ts | 7 +- src/server/sessions/session.test.ts | 6 +- src/server/sessions/session.ts | 29 +++---- src/server/ws/pty-socket.test.ts | 2 +- src/server/ws/ws-upgrade.test.ts | 6 +- src/server/ws/ws-upgrade.ts | 4 +- src/test-support/live-server.ts | 2 +- 14 files changed, 272 insertions(+), 130 deletions(-) create mode 100644 src/pty/node-backend.test.ts diff --git a/src/pty/bun-backend.ts b/src/pty/bun-backend.ts index 26a33a9..4c44c83 100644 --- a/src/pty/bun-backend.ts +++ b/src/pty/bun-backend.ts @@ -1,25 +1,14 @@ -import type { PtyProcess } from './pty-process'; +// The Bun.Terminal PTY backend: a SpawnPty built on Bun's native `Bun.spawn({ terminal })`. +// Used under Bun on every platform but Windows (ADR 062); it cannot pause (ADR 048). +import type { PtyProcess, PtySpawnOptions } from './pty-process'; /** * Spawns a PTY-backed shell using Bun's native `Bun.spawn` terminal API. * - * @param shell - Shell executable path (e.g., `/bin/bash`). - * @param cols - Terminal width in columns. - * @param rows - Terminal height in rows. - * @param term - `$TERM` environment variable (e.g., `xterm-256color`). - * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). - * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ -export function spawn( - shell: string, - cols: number, - rows: number, - term: string, - colorTerm: string, - cwd: string, - env: Record<string, string>, -): PtyProcess { +export function spawnBunPty(options: PtySpawnOptions): PtyProcess { + const { shell, cols, rows, term, colorTerm, cwd, env } = options; let onDataCb: ((data: string) => void) | undefined; let onExitCb: ((e: { exitCode: number }) => void) | undefined; // One streaming decoder per PTY: a read can end mid-character, and decoding each diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 0cf8358..8d6e420 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { isWindows, runningUnderBun } from '../runtime'; -import { spawnForSession } from './index'; +import { bunTerminalAvailable, isWindows, type RuntimeEnv, runningUnderBun } from '../runtime'; +import { type PtySpawnOptions, selectBackend, spawnPty } from './index'; function waitForData(received: string[], content: string, timeout = 3000): Promise<void> { return new Promise((resolve, reject) => { @@ -28,17 +28,47 @@ const TEST_SHELL = process.platform === 'win32' ? (process.env.COMSPEC ?? 'cmd.e // and under Node on Windows. const isBunOnWindows = isWindows() && runningUnderBun(); -describe('spawnForSession', () => { +/** Spawn options for the test shell at 80x24 in the current directory. */ +function options(): PtySpawnOptions { + return { + shell: TEST_SHELL, + cols: 80, + rows: 24, + term: 'xterm-256color', + colorTerm: 'truecolor', + cwd: process.cwd(), + env: {}, + }; +} + +/** A runtime snapshot with only the fields selectBackend reads set. */ +const runtime = (platform: string, bunVersion: string | undefined): RuntimeEnv => ({ + platform, + execPath: '/usr/bin/x', + bunVersion, + bunGlobal: undefined, +}); + +describe('selectBackend', () => { + test.each([ + ['Bun on macOS', 'Bun.Terminal', runtime('darwin', '1.3.0')], + ['Bun on Linux', 'Bun.Terminal', runtime('linux', '1.3.0')], + ['Bun on Windows', 'node-pty', runtime('win32', '1.3.0')], + ['Node on macOS', 'node-pty', runtime('darwin', undefined)], + ['Node on Linux', 'node-pty', runtime('linux', undefined)], + ['Node on Windows', 'node-pty', runtime('win32', undefined)], + ] as const)('%s uses %s', (_name, expected, env) => { + expect(selectBackend(env)).toBe(expected); + }); + + test('defaults to the live process', () => { + expect(selectBackend()).toBe(bunTerminalAvailable() ? 'Bun.Terminal' : 'node-pty'); + }); +}); + +describe('spawnPty', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession( - 80, - 24, - TEST_SHELL, - 'xterm-256color', - 'truecolor', - process.cwd(), - {}, - ); + const pty = spawnPty(options()); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -51,15 +81,7 @@ describe('spawnForSession', () => { }); test.skipIf(isBunOnWindows)('spawned process can receive data', async () => { - const pty = spawnForSession( - 80, - 24, - TEST_SHELL, - 'xterm-256color', - 'truecolor', - process.cwd(), - {}, - ); + const pty = spawnPty(options()); const received: string[] = []; pty.onData((data) => received.push(data)); @@ -84,15 +106,7 @@ describe('spawnForSession', () => { // prefix straddle block (and read) boundaries. const file = path.join(os.tmpdir(), `webtty-pty-mb-${process.pid}.txt`); fs.writeFileSync(file, `x${'─'.repeat(100_000)}\n`); - const pty = spawnForSession( - 80, - 24, - TEST_SHELL, - 'xterm-256color', - 'truecolor', - process.cwd(), - {}, - ); + const pty = spawnPty(options()); const received: string[] = []; pty.onData((d) => received.push(d)); pty.write(`cat '${file}'; echo DONE-$((40+2))\n`); diff --git a/src/pty/index.ts b/src/pty/index.ts index e152dfe..a183482 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -1,38 +1,32 @@ -import { bunTerminalAvailable } from '../runtime'; +// The PTY layer's facade: picks a backend once at load, logs the choice, and exposes +// spawnPty(options) to the server (ADR 062). The backend modules are imported lazily, so +// the node-pty addon is never loaded where Bun.Terminal is used. +import { bunTerminalAvailable, processRuntime, type RuntimeEnv } from '../runtime'; +import type { PtyProcess, PtySpawnOptions } from './pty-process'; -export type { PtyProcess } from './pty-process'; +export type { PtyProcess, PtySpawnOptions, SpawnPty } from './pty-process'; -// Bun.Terminal does not implement PTY on Windows (Bun.spawn({ terminal }) -// is a no-op there), so fall back to node-pty on that platform. -// On all other platforms, prefer Bun.Terminal when running under Bun. -const useBunTerminal = bunTerminalAvailable(); -console.log(`pty: ${useBunTerminal ? 'Bun.Terminal' : 'node-pty'}`); - -const { spawn: _spawn } = await (useBunTerminal - ? import('./bun-backend') - : import('./node-backend')); - -export const spawn = _spawn; +/** The two PTY backends, named as the startup log line prints them. */ +export type PtyBackendName = 'Bun.Terminal' | 'node-pty'; /** - * Convenience wrapper: spawns a PTY using session-oriented parameters from config. - * - * @param cols - Terminal width in columns. - * @param rows - Terminal height in rows. - * @param shell - Shell executable path (e.g., `/bin/bash`). - * @param term - `$TERM` environment variable (e.g., `xterm-256color`). - * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). - * @param cwd - Working directory for the shell. - * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. + * Chooses the PTY backend for a runtime. Bun.Terminal does not implement PTY on Windows + * (Bun.spawn({ terminal }) is a no-op there), so fall back to node-pty on that platform. + * On all other platforms, prefer Bun.Terminal when running under Bun. */ -export function spawnForSession( - cols: number, - rows: number, - shell: string, - term: string, - colorTerm: string, - cwd: string, - env: Record<string, string>, -) { - return _spawn(shell, cols, rows, term, colorTerm, cwd, env); +export function selectBackend(env: RuntimeEnv = processRuntime()): PtyBackendName { + return bunTerminalAvailable(env) ? 'Bun.Terminal' : 'node-pty'; +} + +const backendName = selectBackend(); +console.log(`pty: ${backendName}`); + +const backend = + backendName === 'Bun.Terminal' + ? (await import('./bun-backend')).spawnBunPty + : (await import('./node-backend')).spawnNodePty; + +/** Starts a shell in a PTY with the backend chosen at load; the server's SpawnPty in production. */ +export function spawnPty(options: PtySpawnOptions): PtyProcess { + return backend(options); } diff --git a/src/pty/node-backend.test.ts b/src/pty/node-backend.test.ts new file mode 100644 index 0000000..041f2a1 --- /dev/null +++ b/src/pty/node-backend.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'bun:test'; +import nodePty from '@lydell/node-pty'; +import { + liveNodeBackendDeps, + type NodeBackendDeps, + type NodePtyModule, + spawnNodePty, +} from './node-backend'; +import type { PtySpawnOptions } from './pty-process'; + +/** Options for a spawn; `shell` varies per test. */ +const options = (shell = '/bin/zsh'): PtySpawnOptions => ({ + shell, + cols: 100, + rows: 30, + term: 'xterm-256color', + colorTerm: 'truecolor', + cwd: '/work', + env: { A: 'config', TERM: 'overridden' }, +}); + +/** A fake node-pty that records every call in `log` and lets a test emit data and exit. */ +function fakeNodePty() { + const log: string[] = []; + const spawns: { file: string; args: string[]; opts: Record<string, unknown> }[] = []; + let dataCb: ((data: string) => void) | undefined; + let exitCb: ((e: { exitCode: number; signal?: number }) => void) | undefined; + const module: NodePtyModule = { + spawn(file, args, opts) { + spawns.push({ file, args, opts: opts as Record<string, unknown> }); + return { + pid: 777, + onData: (cb) => { + dataCb = cb; + }, + onExit: (cb) => { + exitCb = cb; + }, + write: (data) => log.push(`write ${JSON.stringify(data)}`), + resize: (cols, rows) => log.push(`resize ${cols}x${rows}`), + pause: () => log.push('pause'), + resume: () => log.push('resume'), + kill: () => log.push('kill'), + }; + }, + }; + return { + module, + log, + spawns, + emitData: (data: string) => dataCb?.(data), + emitExit: (exitCode: number) => exitCb?.({ exitCode, signal: 0 }), + }; +} + +/** Deps around a fake node-pty on `platform`, with a fixed base environment. */ +function deps(platform: string, fake = fakeNodePty()): NodeBackendDeps & { fake: typeof fake } { + return { nodePty: fake.module, platform, baseEnv: { HOME: '/home/u', A: 'base' }, fake }; +} + +describe('spawnNodePty arguments', () => { + test.each([ + ['win32', 'C:\\Windows\\System32\\cmd.exe', ['/d']], + ['win32', 'CMD.EXE', ['/d']], + ['win32', 'C:\\Program Files\\PowerShell\\pwsh.exe', []], + ['linux', '/usr/bin/cmd.exe', []], + ['darwin', '/bin/zsh', []], + ])('on %s, %s is started with %p', (platform, shell, args) => { + const d = deps(platform); + spawnNodePty(options(shell), d); + expect(d.fake.spawns[0]?.file).toBe(shell); + expect(d.fake.spawns[0]?.args).toEqual(args); + }); + + test('passes the size, cwd and TERM name, and layers config env then TERM/COLORTERM over the base env', () => { + const d = deps('linux'); + spawnNodePty(options(), d); + expect(d.fake.spawns[0]?.opts).toEqual({ + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: '/work', + env: { HOME: '/home/u', A: 'config', TERM: 'xterm-256color', COLORTERM: 'truecolor' }, + }); + }); +}); + +describe('spawnNodePty handle', () => { + test('maps every PtyProcess method onto the node-pty process', () => { + const d = deps('linux'); + const pty = spawnNodePty(options(), d); + expect(pty.pid).toBe(777); + pty.write('ls\n'); + pty.resize(120, 40); + pty.pause?.(); + pty.resume?.(); + pty.kill(); + expect(d.fake.log).toEqual(['write "ls\\n"', 'resize 120x40', 'pause', 'resume', 'kill']); + }); + + test('forwards output and exit to the registered callbacks', () => { + const d = deps('linux'); + const pty = spawnNodePty(options(), d); + const seen: string[] = []; + pty.onData((data) => seen.push(`data ${data}`)); + pty.onExit((e) => seen.push(`exit ${e.exitCode}`)); + d.fake.emitData('hello'); + d.fake.emitExit(3); + expect(seen).toEqual(['data hello', 'exit 3']); + }); +}); + +describe('liveNodeBackendDeps', () => { + test('reads the real addon, platform and environment', () => { + const live = liveNodeBackendDeps(); + expect(live.nodePty).toBe(nodePty); + expect(live.platform).toBe(process.platform); + expect(live.baseEnv).toBe(process.env); + }); +}); diff --git a/src/pty/node-backend.ts b/src/pty/node-backend.ts index b5b1027..34ea055 100644 --- a/src/pty/node-backend.ts +++ b/src/pty/node-backend.ts @@ -1,37 +1,48 @@ +// The node-pty PTY backend: a SpawnPty built on the `@lydell/node-pty` native addon, used +// under Node and on Windows. The addon and the platform are injected so the argument and +// event mapping is tested without a real PTY (ADR 062). import nodePty from '@lydell/node-pty'; -import type { PtyProcess } from './pty-process'; +import type { PtyProcess, PtySpawnOptions } from './pty-process'; + +/** The part of the node-pty module this backend calls; a test passes a fake. */ +export type NodePtyModule = Pick<typeof nodePty, 'spawn'>; + +/** What {@link spawnNodePty} reads from outside its options, defaulting to the live process. */ +export interface NodeBackendDeps { + /** The node-pty module. */ + nodePty: NodePtyModule; + /** `process.platform`; decides the cmd.exe AutoRun flag. */ + platform: string; + /** The server's own environment, which the config's `env` is layered over. */ + baseEnv: Record<string, string | undefined>; +} + +/** Reads {@link NodeBackendDeps} from the live process, per spawn as before the split. */ +export function liveNodeBackendDeps(): NodeBackendDeps { + return { nodePty, platform: process.platform, baseEnv: process.env }; +} /** * Spawns a PTY-backed shell using the `@lydell/node-pty` native addon. * - * @param shell - Shell executable path (e.g., `/bin/bash`). - * @param cols - Terminal width in columns. - * @param rows - Terminal height in rows. - * @param term - `$TERM` environment variable (e.g., `xterm-256color`). - * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). - * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ -export function spawn( - shell: string, - cols: number, - rows: number, - term: string, - colorTerm: string, - cwd: string, - env: Record<string, string>, +export function spawnNodePty( + options: PtySpawnOptions, + deps: NodeBackendDeps = liveNodeBackendDeps(), ): PtyProcess { + const { shell, cols, rows, term, colorTerm, cwd, env } = options; // On Windows, cmd.exe may have an AutoRun registry key that launches shell // enhancers (e.g. clink). These take over the ConPTY pipe and cause // ERR_SOCKET_CLOSED on the first write. Pass /d to disable AutoRun. - const shellArgs = process.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; + const shellArgs = deps.platform === 'win32' && /cmd\.exe$/i.test(shell) ? ['/d'] : []; - const ptyProc = nodePty.spawn(shell, shellArgs, { + const ptyProc = deps.nodePty.spawn(shell, shellArgs, { name: term, cols, rows, cwd, - env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, + env: { ...deps.baseEnv, ...env, TERM: term, COLORTERM: colorTerm }, }); return { diff --git a/src/pty/pty-process.ts b/src/pty/pty-process.ts index 2aef41e..9c78d17 100644 --- a/src/pty/pty-process.ts +++ b/src/pty/pty-process.ts @@ -1,3 +1,6 @@ +// The PTY layer's types: the handle to a running shell and the options that start one. +// Implemented by the Bun and node-pty backends and consumed by the server's sessions (ADR 062). + /** Minimal abstraction over a running PTY process. Implemented by both the Bun and node-pty backends. */ export interface PtyProcess { /** OS process ID of the shell spawned inside the PTY. */ @@ -20,3 +23,24 @@ export interface PtyProcess { /** Terminate the child process. */ kill(): void; } + +/** Everything needed to start a shell in a PTY; one object instead of seven positional arguments. */ +export interface PtySpawnOptions { + /** Shell executable path (e.g. `/bin/bash`). */ + shell: string; + /** Terminal width in columns. */ + cols: number; + /** Terminal height in rows. */ + rows: number; + /** `$TERM` for the shell (e.g. `xterm-256color`). */ + term: string; + /** `$COLORTERM` for the shell (e.g. `truecolor`). */ + colorTerm: string; + /** Working directory for the shell. */ + cwd: string; + /** Extra environment from the config, layered over the server's own environment. */ + env: Record<string, string>; +} + +/** Starts a shell in a PTY; each backend is one of these, and so is a test's fake. */ +export type SpawnPty = (options: PtySpawnOptions) => PtyProcess; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 2b3203b..4c69b57 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -46,7 +46,7 @@ async function start(loadConfig: () => Config = () => DEFAULT_CONFIG) { loadConfig, store, paths, - spawnPty: (cols, rows) => { + spawnPty: ({ cols, rows }) => { log.push(`spawn ${cols}x${rows}`); return pty; }, diff --git a/src/server/app.ts b/src/server/app.ts index 6cc4006..cd43e87 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -4,11 +4,10 @@ import http from 'node:http'; import type { WebSocketServer } from 'ws'; import type { Config } from '../config'; -import { spawnForSession } from '../pty'; +import { type SpawnPty, spawnPty } from '../pty'; import { packageRunner } from '../runtime'; import { createRequestHandler } from './routes/route-table'; import type { ServerPaths } from './server-paths'; -import type { SpawnPty } from './sessions/session'; import type { SessionStore } from './sessions/session-store'; import { WS_CLOSE } from './ws/close-codes'; import { createWebSocketServer } from './ws/ws-upgrade'; @@ -22,7 +21,7 @@ export interface ServerDeps { loadConfig: () => Config; /** The one registry of sessions; its `onEmpty` should call {@link WebttyServer.shutdown}. */ store: SessionStore; - /** Starts a session's shell; `spawnForSession` from src/pty when omitted. */ + /** Starts a session's shell; `spawnPty` from src/pty when omitted. */ spawnPty?: SpawnPty; /** Where the client files and the wasm are. */ paths: ServerPaths; @@ -71,7 +70,7 @@ export function createServer(deps: ServerDeps): WebttyServer { // 3. The WebSocket server on the HTTP server's upgrade event. const wss = createWebSocketServer(httpServer, store, { loadConfig, - spawnPty: deps.spawnPty ?? spawnForSession, + spawnPty: deps.spawnPty ?? spawnPty, runner: packageRunner(), }); diff --git a/src/server/sessions/session.test.ts b/src/server/sessions/session.test.ts index e236791..83e8c63 100644 --- a/src/server/sessions/session.test.ts +++ b/src/server/sessions/session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, test } from 'bun:test'; import { homedir } from 'node:os'; import { DEFAULT_CONFIG } from '../../config'; +import type { PtySpawnOptions } from '../../pty'; import { FakePty } from '../../test-support/fake-pty'; import { FakeSocket } from '../../test-support/fake-socket'; import { WS_CLOSE } from '../ws/close-codes'; @@ -14,7 +15,8 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); function fixture(scrollback = DEFAULT_CONFIG.scrollback) { const log: string[] = []; const pty = new FakePty(log); - const spawnPty = mock((cols: number, rows: number, ...rest: unknown[]) => { + const spawnPty = mock((options: PtySpawnOptions) => { + const { cols, rows, ...rest } = options; log.push(`spawn ${cols}x${rows} ${JSON.stringify(rest)}`); return pty; }); @@ -67,7 +69,7 @@ describe('Session.ensurePty', () => { session.attach(ws, false); session.ensurePty(ws, 100, 30, deps, host); expect(log).toEqual([ - `spawn 100x30 ${JSON.stringify(['/bin/sh', DEFAULT_CONFIG.term, DEFAULT_CONFIG.colorTerm, '/work', { A: '1' }])}`, + `spawn 100x30 ${JSON.stringify({ shell: '/bin/sh', term: DEFAULT_CONFIG.term, colorTerm: DEFAULT_CONFIG.colorTerm, cwd: '/work', env: { A: '1' } })}`, `ws.send ${JSON.stringify(sessionBanner('bunx').slice(0, 20))}`, 'pty.write "\\n"', ]); diff --git a/src/server/sessions/session.ts b/src/server/sessions/session.ts index b7d5577..4abbc44 100644 --- a/src/server/sessions/session.ts +++ b/src/server/sessions/session.ts @@ -4,27 +4,16 @@ // the registry of sessions is SessionStore in session-store.ts. import { homedir } from 'node:os'; import type { Config } from '../../config'; -import type { PtyProcess } from '../../pty'; +import type { PtyProcess, SpawnPty } from '../../pty'; import { type FlowSocket, OutputPacer } from '../output-pacer'; import { WS_CLOSE } from '../ws/close-codes'; import { sessionBanner } from './session-banner'; -/** Starts a session's shell: `spawnForSession` from src/pty in production, a fake in tests. */ -export type SpawnPty = ( - cols: number, - rows: number, - shell: string, - term: string, - colorTerm: string, - cwd: string, - env: Record<string, string>, -) => PtyProcess; - /** What {@link Session.ensurePty} needs to start a shell, injected so tests need no real PTY. */ export interface PtySpawnDeps { /** Reads the config at spawn time, so an edited config file applies to the next shell (ADR 009). */ loadConfig: () => Config; - /** Spawns the shell. */ + /** Spawns the shell: `spawnPty` from src/pty in production, a fake in tests. */ spawnPty: SpawnPty; /** The package runner (`bunx` or `npx`) the banner tells the user to run. */ runner: string; @@ -103,15 +92,15 @@ export class Session { } const config = deps.loadConfig(); const limit = config.scrollback; - const pty = deps.spawnPty( + const pty = deps.spawnPty({ + shell: config.shell, cols, rows, - config.shell, - config.term, - config.colorTerm, - this.baseDir, - config.env, - ); + term: config.term, + colorTerm: config.colorTerm, + cwd: this.baseDir, + env: config.env, + }); this.pty = pty; // The per-chunk hot path: append to the ring and hand the chunk to the pacer, nothing more. pty.onData((data: string) => { diff --git a/src/server/ws/pty-socket.test.ts b/src/server/ws/pty-socket.test.ts index b9e9af1..7362143 100644 --- a/src/server/ws/pty-socket.test.ts +++ b/src/server/ws/pty-socket.test.ts @@ -16,7 +16,7 @@ function fixture() { const session = store.create('s', '/work'); const deps: PtySpawnDeps = { loadConfig: () => DEFAULT_CONFIG, - spawnPty: (cols, rows) => { + spawnPty: ({ cols, rows }) => { log.push(`spawn ${cols}x${rows}`); return pty; }, diff --git a/src/server/ws/ws-upgrade.test.ts b/src/server/ws/ws-upgrade.test.ts index f7873aa..f59d9fe 100644 --- a/src/server/ws/ws-upgrade.test.ts +++ b/src/server/ws/ws-upgrade.test.ts @@ -3,7 +3,7 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { WebSocket, type WebSocketServer } from 'ws'; import { DEFAULT_CONFIG, loadConfig } from '../../config'; -import { spawnForSession } from '../../pty'; +import { spawnPty } from '../../pty'; import { FakePty } from '../../test-support/fake-pty'; import { sessionBanner } from '../sessions/session-banner'; import { SessionStore } from '../sessions/session-store'; @@ -30,7 +30,7 @@ async function start() { server = http.createServer(); wss = createWebSocketServer(server, store, { loadConfig: () => DEFAULT_CONFIG, - spawnPty: (cols, rows) => { + spawnPty: ({ cols, rows }) => { log.push(`spawn ${cols}x${rows}`); return pty; }, @@ -102,7 +102,7 @@ describe('defaultPtySpawnDeps', () => { test('reads the config file, spawns through src/pty and names the package runner', () => { const deps = defaultPtySpawnDeps(); expect(deps.loadConfig).toBe(loadConfig); - expect(deps.spawnPty).toBe(spawnForSession); + expect(deps.spawnPty).toBe(spawnPty); expect(['bunx', 'npx']).toContain(deps.runner); }); }); diff --git a/src/server/ws/ws-upgrade.ts b/src/server/ws/ws-upgrade.ts index 8f93a09..e3e41a7 100644 --- a/src/server/ws/ws-upgrade.ts +++ b/src/server/ws/ws-upgrade.ts @@ -5,7 +5,7 @@ import type http from 'node:http'; import type { WebSocket as WS } from 'ws'; import { WebSocketServer } from 'ws'; import { loadConfig } from '../../config'; -import { spawnForSession } from '../../pty'; +import { spawnPty } from '../../pty'; import { packageRunner } from '../../runtime'; import type { PtySpawnDeps } from '../sessions/session'; import type { SessionStore } from '../sessions/session-store'; @@ -18,7 +18,7 @@ const EVENTS_PATH = /^\/ws\/([^/]+)\/events$/; /** The production spawn dependencies: the config file, the platform's PTY backend and runner. */ export function defaultPtySpawnDeps(): PtySpawnDeps { - return { loadConfig, spawnPty: spawnForSession, runner: packageRunner() }; + return { loadConfig, spawnPty, runner: packageRunner() }; } function requestUrl(req: http.IncomingMessage): URL { diff --git a/src/test-support/live-server.ts b/src/test-support/live-server.ts index 9a20623..ce1cbb0 100644 --- a/src/test-support/live-server.ts +++ b/src/test-support/live-server.ts @@ -3,9 +3,9 @@ // websocket and session code count in coverage. A test-support object (ADR 062, ADR 064). import type { AddressInfo } from 'node:net'; import { type Config, loadConfig as loadUserConfig } from '../config'; +import type { SpawnPty } from '../pty'; import { createServer, type WebttyServer } from '../server/app'; import type { ServerPaths } from '../server/server-paths'; -import type { SpawnPty } from '../server/sessions/session'; import { SessionStore } from '../server/sessions/session-store'; /** What a test can swap; everything else is the production wiring. */ From 17c17515db87789a46c3c2983b0e1e1b1b16984f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:25:11 -0400 Subject: [PATCH 201/295] test(client): recordListeners logs the listeners of a real DOM element or document Feature tests that query the container or the document (focus, visibility) can now read their listener order and teardown from a RecordingTarget while the listeners still run on the happy-dom node. --- src/client/test-support.test.ts | 28 ++++++++++++++++++++- src/client/test-support/recording-target.ts | 20 +++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/client/test-support.test.ts b/src/client/test-support.test.ts index b12ded4..25b8342 100644 --- a/src/client/test-support.test.ts +++ b/src/client/test-support.test.ts @@ -9,7 +9,7 @@ import { fakeWebSocket } from './test-support/fake-socket'; import { FakeTerminal } from './test-support/fake-terminal'; import { FakeWindow } from './test-support/fake-window'; import { ManualClock } from './test-support/manual-clock'; -import { RecordingTarget } from './test-support/recording-target'; +import { RecordingTarget, recordListeners } from './test-support/recording-target'; describe('ManualClock', () => { test('runs timeouts in due order when time advances, with now() at each due time', () => { @@ -117,6 +117,32 @@ describe('RecordingTarget', () => { }); }); +describe('recordListeners', () => { + test('logs the adds and removes on a real DOM target, which still runs its listeners', async () => { + const dom = createDomWindow(); + const element = dom.document.createElement('div'); + const recording = recordListeners(element, 'container'); + const log: string[] = []; + const listener = () => log.push('focusin'); + element.addEventListener('focusin', listener, { capture: true }); + element.dispatchEvent(new dom.window.Event('focusin')); + expect(recording.active.map((r) => [r.target, r.type, r.capture])).toEqual([ + ['container', 'focusin', true], + ]); + element.removeEventListener('focusin', listener, true); + element.dispatchEvent(new dom.window.Event('focusin')); + expect(log).toEqual(['focusin']); + expect(recording.removed).toHaveLength(1); + expect(recording.active).toEqual([]); + // EventTarget takes a null listener and ignores it + const target: EventTarget = element; + target.addEventListener('focusin', null); + target.removeEventListener('focusin', null); + expect(recording.added).toHaveLength(1); + await dom.close(); + }); +}); + describe('fakeWebSocket', () => { test('records the sockets made and what the client sends while open', () => { const fake = fakeWebSocket(); diff --git a/src/client/test-support/recording-target.ts b/src/client/test-support/recording-target.ts index b3abdeb..b0a2bdd 100644 --- a/src/client/test-support/recording-target.ts +++ b/src/client/test-support/recording-target.ts @@ -84,3 +84,23 @@ export class RecordingTarget implements ListenerTarget { } } } + +/** + * Records the listeners later added to and removed from a real DOM `target` (a happy-dom + * element or document) while still adding them to it, so a feature test can read the + * listener order and teardown of targets its code also queries, and dispatch real events. + */ +export function recordListeners(target: EventTarget, name: string): RecordingTarget { + const recording = new RecordingTarget(name); + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type, listener, options) => { + if (listener) recording.addEventListener(type, listener, options); + add(type, listener, options); + }; + target.removeEventListener = (type, listener, options) => { + if (listener) recording.removeEventListener(type, listener, options); + remove(type, listener, options); + }; + return recording; +} From 47bb25cde7b3ff91076286f00c2db4ac5179a5d7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:25:12 -0400 Subject: [PATCH 202/295] refactor(client): tab title, notifications and clipboard feature installers The three window-listener features leave index.ts (SPEC steps 4, 5, 6, ADR 060): - features/clipboard/clipboard-feature.ts: installClipboard subscribes the ClipboardWriter to onClipboardWrite and arms it on window capture keydown, mousedown, paste and drop; it returns connected() for the PtyConnection; - features/tab-title/tab-title-feature.ts: installTabTitle subscribes onTitleChange and onBell, and attends on container focusin and window capture keydown; the TabTitle object is still created in step 1; - features/notifications/notifications-feature.ts: installNotifications builds the DesktopNotifier and adds window capture keydown and mousedown (user input), container focusin, document visibilitychange (title and notifier attended) and window capture keydown (attended), in that order. The installers are called in today's order, so the window capture keydown/mousedown, container focusin and document visibilitychange rows match the SPEC table. The globals they read come from ClientEnv (navigator.clipboard, Notification, navigator.userActivation, performance.now) and ctx.win.focus(). The keydown and mousedown user-input listeners are registered separately instead of in a loop with an instanceof KeyboardEvent check, in the same order. Every listener and subscription goes into a store that dispose() releases. tab-title.ts and clipboard-writer.ts get header comments. index.ts's allowlist entry drops to 339 lines. --- scripts/architecture/ratchet-allowlist.ts | 2 +- .../clipboard/clipboard-feature.test.ts | 67 ++++++++ .../features/clipboard/clipboard-feature.ts | 38 +++++ .../features/clipboard/clipboard-writer.ts | 3 + .../notifications-feature.test.ts | 160 ++++++++++++++++++ .../notifications/notifications-feature.ts | 83 +++++++++ .../tab-title/tab-title-feature.test.ts | 66 ++++++++ .../features/tab-title/tab-title-feature.ts | 29 ++++ src/client/features/tab-title/tab-title.ts | 3 + src/client/index.ts | 64 ++----- 10 files changed, 467 insertions(+), 48 deletions(-) create mode 100644 src/client/features/clipboard/clipboard-feature.test.ts create mode 100644 src/client/features/clipboard/clipboard-feature.ts create mode 100644 src/client/features/notifications/notifications-feature.test.ts create mode 100644 src/client/features/notifications/notifications-feature.ts create mode 100644 src/client/features/tab-title/tab-title-feature.test.ts create mode 100644 src/client/features/tab-title/tab-title-feature.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index bf1b4ab..b2c992d 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -20,7 +20,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. - 'src/client/index.ts': { lines: 369, step: CLIENT_ROOT }, + 'src/client/index.ts': { lines: 339, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, diff --git a/src/client/features/clipboard/clipboard-feature.test.ts b/src/client/features/clipboard/clipboard-feature.test.ts new file mode 100644 index 0000000..0a116d3 --- /dev/null +++ b/src/client/features/clipboard/clipboard-feature.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { installClipboard } from './clipboard-feature'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** The feature installed on fakes, with what reached the clipboard. */ +function setup() { + fake = fakeContext(); + const written: string[] = []; + const feature = installClipboard(fake.ctx, { + clipboard: { + writeText: async (text) => { + written.push(text); + }, + }, + }); + /** Fires an OSC 52 write and lets the clipboard promise settle. */ + const osc52 = async (text: string) => { + fake.term.fire('onClipboardWrite', text); + await Promise.resolve(); + }; + return { feature, written, osc52 }; +} + +describe('installClipboard', () => { + test('arms on window capture keydown, mousedown, paste and drop, and subscribes once', () => { + setup(); + expect(fake.win.added.map(({ target, type, capture }) => ({ target, type, capture }))).toEqual( + ['keydown', 'mousedown', 'paste', 'drop'].map((type) => ({ + target: 'window', + type, + capture: true, + })), + ); + expect(fake.term.subscriptions).toEqual(['onClipboardWrite']); + }); + + test.each(['keydown', 'mousedown', 'paste', 'drop'])( + 'writes only after input (%s) since the last connect (ADR 052)', + async (type) => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + const { feature, written, osc52 } = setup(); + await osc52('before input'); + fake.win.dispatch({ type }); + await osc52('after input'); + feature.connected(); + await osc52('replayed'); + fake.win.dispatch({ type }); + await osc52('again'); + expect(written).toEqual(['after input', 'again']); + warn.mockRestore(); + }, + ); + + test('dispose removes everything it added; twice is a no-op', async () => { + const { feature, written, osc52 } = setup(); + fake.win.dispatch({ type: 'keydown' }); + feature.dispose(); + feature.dispose(); + expect(fake.win.active).toEqual([]); + expect(fake.win.removed.map((r) => r.type)).toEqual(['drop', 'paste', 'mousedown', 'keydown']); + await osc52('after dispose'); + expect(written).toEqual([]); + }); +}); diff --git a/src/client/features/clipboard/clipboard-feature.ts b/src/client/features/clipboard/clipboard-feature.ts new file mode 100644 index 0000000..38baab1 --- /dev/null +++ b/src/client/features/clipboard/clipboard-feature.ts @@ -0,0 +1,38 @@ +// Wires a program's OSC 52 clipboard writes to the browser clipboard (feature, ADR 052, +// ADR 060): the terminal's `onClipboardWrite` goes to a ClipboardWriter, which the user's +// input in this tab arms and each new connection disarms until the replay has passed. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import { ClipboardWriter, type TextClipboard } from './clipboard-writer'; + +/** What the clipboard feature needs besides the context. */ +export interface ClipboardDeps { + /** `navigator.clipboard`; undefined where the page has none (http). */ + clipboard: TextClipboard | undefined; +} + +/** The clipboard API the connection uses. */ +export interface ClipboardFeature extends Feature { + /** A socket was created: the output that follows starts with the replay (ADR 052). */ + connected(): void; +} + +/** Subscribes the writer to the terminal and arms it on window capture `keydown`, `mousedown`, `paste` and `drop`. */ +export function installClipboard(ctx: ClientContext, deps: ClipboardDeps): ClipboardFeature { + const { term, win } = ctx; + const store = new DisposableStore(); + const writer = new ClipboardWriter(deps.clipboard); + store.add( + term.onClipboardWrite((text) => { + void writer.write(text); + }), + ); + for (const type of ['keydown', 'mousedown', 'paste', 'drop'] as const) { + store.add(listen(win, type, () => writer.userInput(), { capture: true })); + } + return { + connected: () => writer.connected(), + dispose: () => store.dispose(), + }; +} diff --git a/src/client/features/clipboard/clipboard-writer.ts b/src/client/features/clipboard/clipboard-writer.ts index 692d989..14a5721 100644 --- a/src/client/features/clipboard/clipboard-writer.ts +++ b/src/client/features/clipboard/clipboard-writer.ts @@ -1,3 +1,6 @@ +// Decides whether a program's OSC 52 clipboard write reaches the browser clipboard +// (object, ADR 052); the clipboard comes in through TextClipboard. + /** The part of `navigator.clipboard` a program's clipboard write uses. */ export interface TextClipboard { writeText(text: string): Promise<void>; diff --git a/src/client/features/notifications/notifications-feature.test.ts b/src/client/features/notifications/notifications-feature.test.ts new file mode 100644 index 0000000..7a5472b --- /dev/null +++ b/src/client/features/notifications/notifications-feature.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { type RecordingTarget, recordListeners } from '../../test-support/recording-target'; +import type { NotificationApi } from './desktop-notifier'; +import { installNotifications, type NotificationsDeps } from './notifications-feature'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** A recording NotificationApi whose permission the test sets. */ +function fakeApi(permission: NotificationPermission = 'granted') { + const log: string[] = []; + const clicks: (() => void)[] = []; + const api: NotificationApi = { + permission: () => permission, + requestPermission: async () => { + log.push('request'); + return permission; + }, + show: (title, options, onClick) => { + log.push(`show:${title}:${options.body}`); + clicks.push(onClick); + return { close: () => log.push(`close:${title}`) }; + }, + }; + return { api, log, clicks }; +} + +/** The feature installed on fakes; `log` records the API and the tab title in order. */ +function setup(deps: Partial<NotificationsDeps> = {}, permission?: NotificationPermission) { + fake = fakeContext(); + const container = recordListeners(fake.container, 'container'); + const doc = recordListeners(fake.dom.document, 'document'); + const { api, log, clicks } = fakeApi(permission); + const replay = { active: false }; + let now = 10_000; + const feature = installNotifications(fake.ctx, { + sessionId: 'main', + replay, + title: { + name: 'vim', + bell: () => log.push('bell'), + attended: () => log.push('title attended'), + }, + notifications: api, + userActivation: { isActive: true }, + now: () => now, + ...deps, + }); + const notify = (title = 'build', body = 'done') => { + now += 10_000; + fake.term.fire('onDesktopNotification', { title, body }); + }; + return { feature, container, doc, replay, log, clicks, notify }; +} + +/** Puts focus inside the terminal, as a click on its textarea does. */ +function focusTerminal(): void { + const textarea = fake.dom.document.createElement('textarea'); + fake.container.append(textarea); + textarea.focus(); +} + +/** A window `keydown` for `name`, as the browser dispatches it. */ +const key = (name: string) => new fake.dom.window.KeyboardEvent('keydown', { key: name }); + +const rows = (target: RecordingTarget) => + target.added.map(({ target, type, capture }) => ({ target, type, capture })); + +describe('installNotifications', () => { + test('adds its listeners in the SPEC order and subscribes once', () => { + const { container, doc } = setup(); + expect(rows(fake.win)).toEqual([ + { target: 'window', type: 'keydown', capture: true }, + { target: 'window', type: 'mousedown', capture: true }, + { target: 'window', type: 'keydown', capture: true }, + ]); + expect(rows(container)).toEqual([{ target: 'container', type: 'focusin', capture: false }]); + expect(rows(doc)).toEqual([{ target: 'document', type: 'visibilitychange', capture: false }]); + expect(fake.term.subscriptions).toEqual(['onDesktopNotification']); + }); + + test('shows a notification while the terminal is not focused; a click focuses it', () => { + const { log, clicks, notify } = setup(); + notify('', 'done'); + expect(log).toEqual(['show:vim:done']); + clicks[0](); + expect(fake.win.calls).toEqual(['focus']); + expect(fake.term.calls).toEqual(['focus']); + expect(log).toEqual(['show:vim:done', 'close:vim']); + }); + + test('nothing while the terminal is focused, or for the replay', () => { + const { log, replay, notify } = setup(); + replay.active = true; + notify(); + replay.active = false; + focusTerminal(); + notify(); + expect(log).toEqual([]); + }); + + test('without permission the tab title is marked instead', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + const { log, notify } = setup({}, 'denied'); + notify(); + expect(log).toEqual(['bell']); + warn.mockRestore(); + }); + + test('asks permission on the next activating key or click, not on Esc without activation', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + const first = setup({ userActivation: undefined }, 'default'); + first.notify(); + fake.win.dispatch(key('Escape')); + expect(first.log).toEqual(['bell']); + fake.win.dispatch(key('a')); + expect(first.log).toEqual(['bell', 'request']); + fake.dom.close(); + const second = setup({ userActivation: { isActive: true } }, 'default'); + second.notify(); + fake.win.dispatch({ type: 'mousedown' }); + expect(second.log).toEqual(['bell', 'request']); + warn.mockRestore(); + }); + + test('closes what it showed on focusin, a window key, or the tab shown while focused', () => { + const { log, notify } = setup(); + notify('a'); + fake.container.dispatchEvent(new fake.dom.window.FocusEvent('focusin')); + notify('b'); + fake.win.dispatch(key('a')); + expect(log).toEqual(['show:a:done', 'close:a', 'show:b:done', 'close:b']); + log.length = 0; + // Shown again with focus elsewhere in the page: not attended + notify('c'); + fake.setVisibility('visible'); + expect(log).toEqual(['show:c:done']); + fake.container.dispatchEvent(new fake.dom.window.FocusEvent('focusin')); + log.length = 0; + // The tab hidden with focus in the terminal, then shown again + focusTerminal(); + fake.setVisibility('hidden'); + notify('d'); + fake.setVisibility('visible'); + expect(log).toEqual(['show:d:done', 'title attended', 'close:d']); + }); + + test('dispose removes everything it added; twice is a no-op', () => { + const { feature, container, doc, log, notify } = setup(); + feature.dispose(); + feature.dispose(); + expect(fake.win.active).toEqual([]); + expect(fake.win.removed).toHaveLength(3); + expect(container.active).toEqual([]); + expect(doc.active).toEqual([]); + notify(); + expect(log).toEqual([]); + }); +}); diff --git a/src/client/features/notifications/notifications-feature.ts b/src/client/features/notifications/notifications-feature.ts new file mode 100644 index 0000000..a8a68a9 --- /dev/null +++ b/src/client/features/notifications/notifications-feature.ts @@ -0,0 +1,83 @@ +// Wires a program's desktop notifications (OSC 9, OSC 777) to the DesktopNotifier +// (feature, ADR 053, ADR 056, ADR 060): shown while the terminal is not focused, the bell +// mark without permission, permission asked on the next key or click that activates the +// page (not Esc), and closed once the terminal is attended. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { isTerminalFocused } from '../../app/platform'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import { activates } from './browser-notifications'; +import { DesktopNotifier, type NotificationApi } from './desktop-notifier'; + +/** The tab title as the notifier uses it: its name, its bell mark, and clearing the mark. */ +export interface TitleMark { + readonly name: string; + bell(): void; + attended(): void; +} + +/** What the notifications feature needs besides the context. */ +export interface NotificationsDeps { + sessionId: string; + /** Whether the output is the replay, whose notifications are not new (ADR 053). */ + replay: { readonly active: boolean }; + /** The tab title: the notification's fallback title and mark, attended with the notifier. */ + title: TitleMark; + /** The browser's Notification API; undefined where the page has none (http, iOS Safari). */ + notifications: NotificationApi | undefined; + /** `navigator.userActivation`, where the browser has it (ADR 056). */ + userActivation?: { readonly isActive: boolean }; + /** The clock the rate limits read, `performance.now()` in the page. */ + now(): number; +} + +/** The installed notifications; nothing to call, only to dispose. */ +export type NotificationsFeature = Feature; + +/** + * Subscribes to `onDesktopNotification`; window capture `keydown` and `mousedown` are the + * gesture to ask permission with; container `focusin`, document `visibilitychange` (title + * too) and window capture `keydown` attend, in that order. + */ +export function installNotifications( + ctx: ClientContext, + deps: NotificationsDeps, +): NotificationsFeature { + const { term, container, doc, win } = ctx; + const { title, userActivation } = deps; + const store = new DisposableStore(); + const focused = (): boolean => isTerminalFocused(doc, container); + const notifier = new DesktopNotifier(deps.notifications, deps.sessionId, deps.replay, { + focused, + name: () => title.name, + mark: () => title.bell(), + focus: () => { + win.focus(); + term.focus(); + }, + }); + store.add(term.onDesktopNotification((n) => notifier.notify(n, deps.now()))); + store.add( + listen( + win, + 'keydown', + (e) => notifier.userInput(activates(e as KeyboardEvent, userActivation)), + { capture: true }, + ), + ); + store.add( + listen(win, 'mousedown', () => notifier.userInput(activates({}, userActivation)), { + capture: true, + }), + ); + store.add(listen(container, 'focusin', () => notifier.attended())); + store.add( + listen(doc, 'visibilitychange', () => { + if (!focused()) return; + title.attended(); + notifier.attended(); + }), + ); + store.add(listen(win, 'keydown', () => notifier.attended(), { capture: true })); + return { dispose: () => store.dispose() }; +} diff --git a/src/client/features/tab-title/tab-title-feature.test.ts b/src/client/features/tab-title/tab-title-feature.test.ts new file mode 100644 index 0000000..d4c5071 --- /dev/null +++ b/src/client/features/tab-title/tab-title-feature.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { type RecordingTarget, recordListeners } from '../../test-support/recording-target'; +import { TabTitle } from './tab-title'; +import { installTabTitle } from './tab-title-feature'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** The feature installed on fakes, with the titles it set and the container's listener log. */ +function setup() { + fake = fakeContext(); + const container: RecordingTarget = recordListeners(fake.container, 'container'); + const replay = { active: false }; + const titles: string[] = []; + const tabTitle = new TabTitle('main', true, replay, (title) => titles.push(title)); + const feature = installTabTitle(fake.ctx, { tabTitle }); + const last = () => titles[titles.length - 1]; + return { feature, container, replay, last }; +} + +const focusin = () => fake.container.dispatchEvent(new fake.dom.window.FocusEvent('focusin')); + +describe('installTabTitle', () => { + test('adds container focusin, then window capture keydown, and subscribes title then bell', () => { + const { container } = setup(); + const rows = (target: RecordingTarget) => + target.added.map(({ target, type, capture }) => ({ target, type, capture })); + expect(rows(container)).toEqual([{ target: 'container', type: 'focusin', capture: false }]); + expect(rows(fake.win)).toEqual([{ target: 'window', type: 'keydown', capture: true }]); + expect(fake.term.subscriptions).toEqual(['onTitleChange', 'onBell']); + }); + + test("shows the program's title and marks a bell until focus or a key", () => { + const { last } = setup(); + fake.term.fire('onTitleChange', 'vim'); + expect(last()).toBe('vim | webtty'); + fake.term.fire('onBell'); + expect(last()).toBe('🔔 vim | webtty'); + focusin(); + expect(last()).toBe('vim | webtty'); + fake.term.fire('onBell'); + fake.win.dispatch({ type: 'keydown' }); + expect(last()).toBe('vim | webtty'); + }); + + test('a bell in the replay is ignored (ADR 053)', () => { + const { replay, last } = setup(); + replay.active = true; + fake.term.fire('onBell'); + expect(last()).toBe('main | webtty'); + }); + + test('dispose removes everything it added; twice is a no-op', () => { + const { feature, container, last } = setup(); + feature.dispose(); + feature.dispose(); + expect(container.active).toEqual([]); + expect(fake.win.active).toEqual([]); + expect(container.removed).toHaveLength(1); + expect(fake.win.removed).toHaveLength(1); + fake.term.fire('onTitleChange', 'vim'); + fake.term.fire('onBell'); + expect(last()).toBe('main | webtty'); + }); +}); diff --git a/src/client/features/tab-title/tab-title-feature.ts b/src/client/features/tab-title/tab-title-feature.ts new file mode 100644 index 0000000..b5c10e4 --- /dev/null +++ b/src/client/features/tab-title/tab-title-feature.ts @@ -0,0 +1,29 @@ +// Wires the tab title to the terminal (feature, ADR 053, ADR 060): the program's OSC 0/2 +// title and its bells go to the TabTitle, and focus in the terminal or a key clears the +// bell mark. Ghostty clears it on focus; a window focus reaches the container as a +// `focusin` too, while Chrome switching back to the tab only shows it. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import type { TabTitle } from './tab-title'; + +/** What the tab title feature needs besides the context. */ +export interface TabTitleDeps { + /** Created by the root before the wasm loads, so the tab is named at once. */ + tabTitle: TabTitle; +} + +/** The installed tab title; nothing to call, only to dispose. */ +export type TabTitleFeature = Feature; + +/** Subscribes to `onTitleChange` and `onBell`; container `focusin` and window capture `keydown` attend. */ +export function installTabTitle(ctx: ClientContext, deps: TabTitleDeps): TabTitleFeature { + const { term, container, win } = ctx; + const { tabTitle } = deps; + const store = new DisposableStore(); + store.add(term.onTitleChange((title) => tabTitle.title(title))); + store.add(term.onBell(() => tabTitle.bell())); + store.add(listen(container, 'focusin', () => tabTitle.attended())); + store.add(listen(win, 'keydown', () => tabTitle.attended(), { capture: true })); + return { dispose: () => store.dispose() }; +} diff --git a/src/client/features/tab-title/tab-title.ts b/src/client/features/tab-title/tab-title.ts index 5c877ae..516f67f 100644 --- a/src/client/features/tab-title/tab-title.ts +++ b/src/client/features/tab-title/tab-title.ts @@ -1,3 +1,6 @@ +// The tab title and its bell mark (object, ADR 053): the program's title or the session +// id, as `<title> | webtty`, with a 🔔 after a bell until the terminal is attended. + const BELL_MARK = '🔔 '; /** diff --git a/src/client/index.ts b/src/client/index.ts index fa099c0..f8d6f3b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,23 +1,24 @@ import { browserEnv } from './app/browser-env'; import { type KeyboardBinding, loadClientConfig } from './app/client-config'; import type { ClientContext } from './app/client-context'; -import { detectMac, isTerminalFocused } from './app/platform'; +import { detectMac } from './app/platform'; import { toTerminalOptions } from './app/terminal-options'; import { toTerminalTheme } from './app/terminal-theme'; import { createOutputHandler } from './connection/output-pipeline'; import { PtyConnection } from './connection/pty-connection'; import { ReplayWindow } from './connection/replay-window'; -import { ClipboardWriter } from './features/clipboard/clipboard-writer'; +import { installClipboard } from './features/clipboard/clipboard-feature'; import { Composer } from './features/compose/composer'; import { FindBar } from './features/find/find-bar'; import { shortcutFor } from './features/keyboard/shortcut-map'; import { KittyGraphics } from './features/kitty/graphics/kitty-graphics'; -import { activates, browserNotifications } from './features/notifications/browser-notifications'; -import { DesktopNotifier } from './features/notifications/desktop-notifier'; +import { browserNotifications } from './features/notifications/browser-notifications'; +import { installNotifications } from './features/notifications/notifications-feature'; import { nonTextPaste } from './features/paste/non-text-paste'; import { SmoothScroll } from './features/scroll/smooth-scroll'; import { WheelStream } from './features/scroll/wheel-stream'; import { TabTitle } from './features/tab-title/tab-title'; +import { installTabTitle } from './features/tab-title/tab-title-feature'; import { EdgeExtender } from './layout/edge-extender'; import { applyPaddingColor, installLayout } from './layout/layout-feature'; import { installRepaintOnVisible } from './layout/repaint-on-visible'; @@ -88,52 +89,21 @@ const graphics = new KittyGraphics(term, container, (data: string) => connection // A program's OSC 52 clipboard write, decoded by Ghostty, written to the browser // clipboard once the user has used this tab since connecting (ADR 052). -const clipboardWriter = new ClipboardWriter(navigator.clipboard); -term.onClipboardWrite((text) => { - void clipboardWriter.write(text); -}); -for (const type of ['keydown', 'mousedown', 'paste', 'drop'] as const) { - window.addEventListener(type, () => clipboardWriter.userInput(), { capture: true }); -} +const clipboard = installClipboard(ctx, { clipboard: env.navigator.clipboard }); -const terminalFocused = (): boolean => isTerminalFocused(document, container); - -// The title and the bell mark (ADR 053). Ghostty clears the mark when the terminal -// gains focus (a window focus reaches the container as a focusin too; Chrome switching -// back to the tab only shows it) or gets a key. -term.onTitleChange((title) => tabTitle.title(title)); -term.onBell(() => tabTitle.bell()); -container.addEventListener('focusin', () => tabTitle.attended()); -window.addEventListener('keydown', () => tabTitle.attended(), { capture: true }); +// The title and the bell mark (ADR 053). +installTabTitle(ctx, { tabTitle }); // Desktop notifications (OSC 9, OSC 777), shown while the terminal is not focused, as -// Ghostty on macOS does; the bell mark without permission. Permission is asked on the -// next key or click after the first one that activates the page (not Esc), a gesture -// browsers accept (ADR 056). -const notifier = new DesktopNotifier(browserNotifications(), sessionId, replay, { - focused: terminalFocused, - name: () => tabTitle.name, - mark: () => tabTitle.bell(), - focus: () => { - window.focus(); - term.focus(); - }, -}); -term.onDesktopNotification((n) => notifier.notify(n, performance.now())); -for (const type of ['keydown', 'mousedown'] as const) { - window.addEventListener( - type, - (e) => notifier.userInput(activates(e instanceof KeyboardEvent ? e : {})), - { capture: true }, - ); -} -container.addEventListener('focusin', () => notifier.attended()); -document.addEventListener('visibilitychange', () => { - if (!terminalFocused()) return; - tabTitle.attended(); - notifier.attended(); +// Ghostty on macOS does; the bell mark without permission (ADR 056). +installNotifications(ctx, { + sessionId, + replay, + title: tabTitle, + notifications: browserNotifications(env.Notification), + userActivation: env.navigator.userActivation, + now: () => env.performance.now(), }); -window.addEventListener('keydown', () => notifier.attended(), { capture: true }); // The PTY socket (ADR 048, ADR 060). PtyConnection owns the URL, the reconnects and the // close codes; this says what runs around them. @@ -143,7 +113,7 @@ connection = new PtyConnection( size: () => ({ cols: term.cols, rows: term.rows }), created: () => { // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) - clipboardWriter.connected(); + clipboard.connected(); // ... and ignore its bells and notifications until the replay's last piece (ADR 053, // ADR 056) replay.connected(); From 34ae79f0c49fb9d9f2d9bf93cb4d6c28cf3d46ec Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:25:59 -0400 Subject: [PATCH 203/295] refactor(cli): server process and browser opener out of http.ts http.ts held three unrelated things. Starting and stopping the server moves to server-process.ts, with the src/dist entry arithmetic as the pure resolveServerEntry and startServer over injected deps (spawn, log fd, config, reachability probe, exit). openBrowser and toBrowserHost move to browser-opener.ts over an injected spawn, platform and env. http.ts keeps the base URL and the reachability probe until WebttyClient (ADR 063). Tests: server-process covers the four entry cases and startServer with a fake spawn and log fd instead of fs and config namespace spies; the toBrowserHost tests move to browser-opener.test.ts; commands.test.ts spies on server-process for startServer and stopServer. Behaviour and output are unchanged. --- src/cli/browser-opener.test.ts | 72 +++++++ src/cli/browser-opener.ts | 49 +++++ src/cli/commands.test.ts | 41 +--- src/cli/commands.ts | 16 +- src/cli/http.test.ts | 350 ++------------------------------- src/cli/http.ts | 132 +------------ src/cli/server-process.test.ts | 232 ++++++++++++++++++++++ src/cli/server-process.ts | 183 +++++++++++++++++ 8 files changed, 570 insertions(+), 505 deletions(-) create mode 100644 src/cli/browser-opener.test.ts create mode 100644 src/cli/browser-opener.ts create mode 100644 src/cli/server-process.test.ts create mode 100644 src/cli/server-process.ts diff --git a/src/cli/browser-opener.test.ts b/src/cli/browser-opener.test.ts new file mode 100644 index 0000000..6725f8c --- /dev/null +++ b/src/cli/browser-opener.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { + type BrowserOpenerHost, + openBrowser, + processBrowserHost, + toBrowserHost, +} from './browser-opener'; + +const URL = 'http://localhost:2346/s/main'; + +describe('toBrowserHost', () => { + const cases: Array<[string, string]> = [ + ['localhost', 'localhost'], + ['127.0.0.1', '127.0.0.1'], + ['0.0.0.0', 'localhost'], + ['::', 'localhost'], + ['::1', '[::1]'], + ['[::1]', '[::1]'], + ]; + for (const [host, expected] of cases) { + test(`${host} → ${expected}`, () => { + expect(toBrowserHost(host)).toBe(expected); + }); + } +}); + +/** A host on `platform` with a recording spawn and no opt-out variables. */ +function fakeHost(platform: string, env: NodeJS.ProcessEnv = {}) { + const unref = mock(() => {}); + const spawn = mock(() => ({ unref })); + const host: BrowserOpenerHost = { spawn, platform, env }; + return { host, spawn, unref }; +} + +describe('openBrowser', () => { + test('does nothing when WEBTTY_NO_OPEN=1', () => { + const { host, spawn } = fakeHost('darwin', { WEBTTY_NO_OPEN: '1' }); + openBrowser(URL, host); + expect(spawn).not.toHaveBeenCalled(); + }); + + test('does nothing when NODE_ENV=test', () => { + const { host, spawn } = fakeHost('darwin', { NODE_ENV: 'test' }); + openBrowser(URL, host); + expect(spawn).not.toHaveBeenCalled(); + }); + + const cases: Array<[string, string, string[]]> = [ + ['darwin', 'open', [URL]], + ['linux', 'xdg-open', [URL]], + ['win32', 'cmd.exe', ['/c', 'start', '', URL]], + ]; + for (const [platform, command, args] of cases) { + test(`spawns ${command} on ${platform}, detached and unref'd`, () => { + const { host, spawn, unref } = fakeHost(platform); + openBrowser(URL, host); + expect(spawn).toHaveBeenCalledWith(command, args, { detached: true, stdio: 'ignore' }); + expect(unref).toHaveBeenCalled(); + }); + } + + test('the default host is the live process', () => { + const host = processBrowserHost(); + expect(host.platform).toBe(process.platform); + expect(host.env).toBe(process.env); + }); + + test('with the default host under bun test it opens nothing', () => { + expect(process.env.NODE_ENV).toBe('test'); + expect(() => openBrowser(URL)).not.toThrow(); + }); +}); diff --git a/src/cli/browser-opener.ts b/src/cli/browser-opener.ts new file mode 100644 index 0000000..efa92f7 --- /dev/null +++ b/src/cli/browser-opener.ts @@ -0,0 +1,49 @@ +// Opening a session URL in the user's browser, and turning the configured bind host into one a +// browser can navigate to. A util over an injected spawn, platform and environment (ADR 063). +import * as childProcess from 'node:child_process'; + +/** + * Converts a bind host to a browser-navigable host. + * - Bind-all addresses (`0.0.0.0`, `::`) become `localhost`. + * - Bare IPv6 addresses are bracketed (`::1` → `[::1]`). + * - All other values pass through unchanged. + */ +export function toBrowserHost(host: string): string { + if (host === '0.0.0.0' || host === '::') return 'localhost'; + if (host.includes(':') && !host.startsWith('[')) return `[${host}]`; + return host; +} + +/** `child_process.spawn` narrowed to the detached, unref'd call openBrowser makes. */ +export type SpawnDetached = ( + command: string, + args: string[], + options: childProcess.SpawnOptions, +) => { unref(): void }; + +/** What openBrowser reads and calls; the live process by default. */ +export interface BrowserOpenerHost { + spawn: SpawnDetached; + platform: string; + env: NodeJS.ProcessEnv; +} + +/** The live process, read per call so a test's `process.platform` stub is seen. */ +export function processBrowserHost(): BrowserOpenerHost { + return { spawn: childProcess.spawn, platform: process.platform, env: process.env }; +} + +/** + * Opens `url` in the default system browser. + * No-op in test environments or when `WEBTTY_NO_OPEN=1` is set. + */ +export function openBrowser(url: string, host: BrowserOpenerHost = processBrowserHost()): void { + if (host.env.WEBTTY_NO_OPEN === '1') return; + if (host.env.NODE_ENV === 'test') return; + if (host.platform === 'win32') { + host.spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref(); + } else { + const cmd = host.platform === 'darwin' ? 'open' : 'xdg-open'; + host.spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); + } +} diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index e87ba73..38f4b84 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -11,8 +11,9 @@ import { waitForServerDown, waitForServerReady, } from '../test-support/server-harness'; -import { bytesToChars, bytesToDisplay, toBrowserHost } from './commands'; +import { bytesToChars, bytesToDisplay } from './commands'; import * as httpModule from './http'; +import * as serverProcessModule from './server-process'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); @@ -188,32 +189,6 @@ describe('cli — session management', () => { }); }); -describe('toBrowserHost', () => { - test('localhost passes through unchanged', () => { - expect(toBrowserHost('localhost')).toBe('localhost'); - }); - - test('127.0.0.1 passes through unchanged', () => { - expect(toBrowserHost('127.0.0.1')).toBe('127.0.0.1'); - }); - - test('0.0.0.0 maps to localhost', () => { - expect(toBrowserHost('0.0.0.0')).toBe('localhost'); - }); - - test(':: maps to localhost', () => { - expect(toBrowserHost('::')).toBe('localhost'); - }); - - test('bare IPv6 address gets bracketed', () => { - expect(toBrowserHost('::1')).toBe('[::1]'); - }); - - test('already-bracketed IPv6 passes through unchanged', () => { - expect(toBrowserHost('[::1]')).toBe('[::1]'); - }); -}); - describe('bytesToDisplay', () => { test('ESC CR → legacy shift+enter', () => { expect(bytesToDisplay(Buffer.from([0x1b, 0x0d]))).toBe('ESC CR'); @@ -357,7 +332,7 @@ describe('cli — unit (mocked http)', () => { test('cmdStop when running stops server', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); + const stop = spyOn(serverProcessModule, 'stopServer').mockResolvedValueOnce(true); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdStop(); expect(log).toHaveBeenCalledWith('webtty stopped'); @@ -368,7 +343,7 @@ describe('cli — unit (mocked http)', () => { test('cmdStop when stop fails exits with error', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(false); + const stop = spyOn(serverProcessModule, 'stopServer').mockResolvedValueOnce(false); const err = spyOn(console, 'error').mockImplementation(() => {}); const exit = spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); @@ -392,7 +367,7 @@ describe('cli — unit (mocked http)', () => { test('cmdStart when not running starts server', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + const start = spyOn(serverProcessModule, 'startServer').mockResolvedValueOnce(undefined); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdStart(); expect(start).toHaveBeenCalled(); @@ -477,7 +452,7 @@ describe('cli — unit (mocked http)', () => { headers: { 'x-sessions-remaining': '0' }, }), ) as unknown as typeof fetch; - const stop = spyOn(httpModule, 'stopServer').mockResolvedValueOnce(true); + const stop = spyOn(serverProcessModule, 'stopServer').mockResolvedValueOnce(true); const log = spyOn(console, 'log').mockImplementation(() => {}); await cmds.cmdRemove('last'); expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); @@ -554,7 +529,7 @@ describe('cli — unit (mocked http)', () => { test('cmdGo when server not running starts it', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + const start = spyOn(serverProcessModule, 'startServer').mockResolvedValueOnce(undefined); global.fetch = mock(async (url: string) => { if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); @@ -932,7 +907,7 @@ describe('cli — unit (mocked http)', () => { test('cmdRun warm-up when server not running starts it', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + const start = spyOn(serverProcessModule, 'startServer').mockResolvedValueOnce(undefined); global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; const origWS = global.WebSocket; global.WebSocket = makeMockWebSocket('open'); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index adb53dc..60370bd 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -3,19 +3,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { configDir, loadConfig } from '../config'; import { LineSplitter } from '../ndjson'; -import { getBaseUrl, getPort, isServerRunning, openBrowser, startServer, stopServer } from './http'; - -/** - * Converts a bind host to a browser-navigable host. - * - Bind-all addresses (`0.0.0.0`, `::`) become `localhost`. - * - Bare IPv6 addresses are bracketed (`::1` → `[::1]`). - * - All other values pass through unchanged. - */ -export function toBrowserHost(host: string): string { - if (host === '0.0.0.0' || host === '::') return 'localhost'; - if (host.includes(':') && !host.startsWith('[')) return `[${host}]`; - return host; -} +import { openBrowser, toBrowserHost } from './browser-opener'; +import { getBaseUrl, getPort, isServerRunning } from './http'; +import { startServer, stopServer } from './server-process'; /** * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. diff --git a/src/cli/http.test.ts b/src/cli/http.test.ts index f06cc9a..40a8f3b 100644 --- a/src/cli/http.test.ts +++ b/src/cli/http.test.ts @@ -1,354 +1,42 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { getBaseUrl, getPort, isServerRunning } from './http'; const realFetch = globalThis.fetch; -beforeEach(() => { - globalThis.fetch = realFetch; -}); - afterEach(() => { globalThis.fetch = realFetch; }); -function withPlatform(platform: string, fn: () => void): void { - const origNodeEnv = process.env.NODE_ENV; - const origPlatform = process.platform; - delete process.env.NODE_ENV; - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - try { - fn(); - } finally { - Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); - process.env.NODE_ENV = origNodeEnv; - } -} +describe('getBaseUrl', () => { + test('is always 127.0.0.1 on the configured port', () => { + expect(getBaseUrl()).toBe(`http://127.0.0.1:${getPort()}`); + }); +}); describe('isServerRunning', () => { - test('returns true when GET /api/sessions succeeds', async () => { - globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), - ) as unknown as typeof fetch; - - const { isServerRunning } = await import('./http'); + test('returns true when GET /api/sessions answers a JSON array', async () => { + const fetchMock = mock(async () => new Response('[]', { status: 200 })); + globalThis.fetch = fetchMock as unknown as typeof fetch; expect(await isServerRunning()).toBe(true); + expect(fetchMock).toHaveBeenCalledWith(`${getBaseUrl()}/api/sessions`); }); - test('returns false when fetch throws', async () => { - globalThis.fetch = mock(async () => { - throw new Error('connection refused'); - }) as unknown as typeof fetch; - - const { isServerRunning } = await import('./http'); + test('returns false when the answer is not an array', async () => { + globalThis.fetch = mock(async () => new Response('{}')) as unknown as typeof fetch; expect(await isServerRunning()).toBe(false); }); -}); - -describe('stopServer', () => { - test('returns true when server stops after POST succeeds', async () => { - let callCount = 0; - globalThis.fetch = mock(async (_url: string, init?: RequestInit) => { - if (init?.method === 'POST') return new Response('stopping', { status: 200 }); - callCount++; - if (callCount >= 2) throw new Error('connection refused'); - return new Response('[]', { status: 200 }); - }) as unknown as typeof fetch; - - const { stopServer } = await import('./http'); - expect(await stopServer()).toBe(true); - }); - - test('returns false when POST to stop endpoint fails', async () => { - globalThis.fetch = mock(async (_url: string, init?: RequestInit) => { - if (init?.method === 'POST') return new Response('error', { status: 500 }); - return new Response('[]', { status: 200 }); - }) as unknown as typeof fetch; - - const { stopServer } = await import('./http'); - expect(await stopServer()).toBe(false); - }); - - test('returns false when fetch throws', async () => { - globalThis.fetch = mock(async () => { - throw new Error('connection refused'); - }) as unknown as typeof fetch; - - const { stopServer } = await import('./http'); - expect(await stopServer()).toBe(false); - }); - - test('returns false when server does not come down within timeout', async () => { - globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), - ) as unknown as typeof fetch; - - const { stopServer } = await import('./http'); - expect(await stopServer('http://127.0.0.1:1', 100)).toBe(false); - }); -}); - -describe('logPath', () => { - test('returns path inside ~/.config/webtty/', async () => { - const { logPath } = await import('./http'); - expect(logPath()).toBe(path.join(os.homedir(), '.config', 'webtty', 'server.log')); - }); -}); - -describe('startServer', () => { - test('opens log file and passes fd to spawn when logs is true', async () => { - const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); - const openSpy = spyOn(fs, 'openSync').mockReturnValue(99); - const closeSpy = spyOn(fs, 'closeSync').mockImplementation(() => {}); - const fakeChild = { unref: mock(() => {}), on: mock(() => {}) }; - const spawnMock = mock(() => fakeChild); + test('returns false on a non-2xx status', async () => { globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), + async () => new Response('[]', { status: 500 }), ) as unknown as typeof fetch; - - const configModule = await import('../config'); - const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ - ...configModule.DEFAULT_CONFIG, - logs: true, - }); - - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); - - const { startServer } = await import('./http'); - await startServer(10000, spawnMock as never); - - const spawnCall = (spawnMock.mock.calls[0] as unknown[])[2] as unknown as { stdio: unknown }; - expect(spawnCall.stdio).toEqual(['ignore', 99, 99]); - expect(openSpy).toHaveBeenCalledWith(expect.stringContaining('server.log'), 'a'); - expect(closeSpy).toHaveBeenCalledWith(99); - - existsSpy.mockRestore(); - mkdirSpy.mockRestore(); - openSpy.mockRestore(); - closeSpy.mockRestore(); - configSpy.mockRestore(); - }); - - test('exits with error when server entry not found', async () => { - const realExistsSync = fs.existsSync.bind(fs); - const existsSpy = spyOn(fs, 'existsSync').mockImplementation((p) => - String(p).replace(/\\/g, '/').includes('server/index') ? false : realExistsSync(p), - ); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - const { startServer } = await import('./http'); - await startServer(); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('server entry not found')); - expect(exitSpy).toHaveBeenCalledWith(1); - - existsSpy.mockRestore(); - exitSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - test('spawns server and resolves when it becomes reachable', async () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); - const fakeChild = { unref: mock(() => {}), on: mock(() => {}) }; - const spawnMock = mock(() => fakeChild); - - let calls = 0; - globalThis.fetch = mock(async () => { - calls++; - if (calls < 3) throw new Error('not yet'); - return new Response('[]', { status: 200 }); - }) as unknown as typeof fetch; - - const configModule = await import('../config'); - const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ - ...configModule.DEFAULT_CONFIG, - }); - - const { startServer } = await import('./http'); - await startServer(10000, spawnMock as never); - - expect(calls).toBeGreaterThanOrEqual(3); - expect(fakeChild.unref).toHaveBeenCalled(); - - existsSpy.mockRestore(); - configSpy.mockRestore(); - }); - - test('uses node executor on win32 with Bun', async () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); - const fakeChild = { unref: mock(() => {}), on: mock(() => {}) }; - const spawnMock = mock(() => fakeChild); - - globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), - ) as unknown as typeof fetch; - - const configModule = await import('../config'); - const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ - ...configModule.DEFAULT_CONFIG, - }); - - const origPlatform = process.platform; - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - - const { startServer } = await import('./http'); - await startServer(10000, spawnMock as never); - - const [exec] = spawnMock.mock.calls[0] as unknown as [string, string[]]; - expect(exec).toBe('node'); - - Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); - existsSpy.mockRestore(); - configSpy.mockRestore(); - }); - - test('logs error and exits when spawn emits error (non-windows hint)', async () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - const fakeChild = { - unref: mock(() => {}), - on: mock((event: string, cb: (err: Error) => void) => { - if (event === 'error') cb(new Error('spawn ENOENT')); - }), - }; - const spawnMock = mock(() => fakeChild); - - globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), - ) as unknown as typeof fetch; - - const configModule = await import('../config'); - const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ - ...configModule.DEFAULT_CONFIG, - }); - - const { startServer } = await import('./http'); - await startServer(10000, spawnMock as never); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('failed to start server')); - expect(errorSpy).toHaveBeenCalledWith(expect.not.stringContaining('Node.js must be on PATH')); - expect(exitSpy).toHaveBeenCalledWith(1); - - existsSpy.mockRestore(); - exitSpy.mockRestore(); - errorSpy.mockRestore(); - configSpy.mockRestore(); - }); - - test('logs error with win32 hint when spawn emits error on win32', async () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - const fakeChild = { - unref: mock(() => {}), - on: mock((event: string, cb: (err: Error) => void) => { - if (event === 'error') cb(new Error('spawn ENOENT')); - }), - }; - const spawnMock = mock(() => fakeChild); - - globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), - ) as unknown as typeof fetch; - - const configModule = await import('../config'); - const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ - ...configModule.DEFAULT_CONFIG, - }); - - const origPlatform = process.platform; - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - - const { startServer } = await import('./http'); - await startServer(10000, spawnMock as never); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Node.js must be on PATH')); - expect(exitSpy).toHaveBeenCalledWith(1); - - Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true }); - existsSpy.mockRestore(); - exitSpy.mockRestore(); - errorSpy.mockRestore(); - configSpy.mockRestore(); + expect(await isServerRunning()).toBe(false); }); - test('exits with error when server does not start within timeout', async () => { - const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true); - const spawnMock = mock(() => ({ unref: () => {}, on: () => {} })); - const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - + test('returns false when fetch throws', async () => { globalThis.fetch = mock(async () => { - throw new Error('not yet'); + throw new Error('connection refused'); }) as unknown as typeof fetch; - - const configModule = await import('../config'); - const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({ - ...configModule.DEFAULT_CONFIG, - }); - - const { startServer } = await import('./http'); - await startServer(100, spawnMock as never); - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('server did not start in time')); - expect(exitSpy).toHaveBeenCalledWith(1); - - existsSpy.mockRestore(); - exitSpy.mockRestore(); - errorSpy.mockRestore(); - configSpy.mockRestore(); - }); -}); - -describe('openBrowser', () => { - test('does nothing when WEBTTY_NO_OPEN=1', async () => { - const origEnv = process.env.WEBTTY_NO_OPEN; - process.env.WEBTTY_NO_OPEN = '1'; - const spawnMock = mock(() => ({ unref: () => {} })); - - const { openBrowser } = await import('./http'); - openBrowser('http://localhost:2346/s/main', spawnMock as never); - expect(spawnMock).not.toHaveBeenCalled(); - - process.env.WEBTTY_NO_OPEN = origEnv; - }); - - test('spawns open on darwin', async () => { - const spawnMock = mock(() => ({ unref: () => {} })); - const { openBrowser } = await import('./http'); - withPlatform('darwin', () => openBrowser('http://localhost:2346/s/main', spawnMock as never)); - expect(spawnMock).toHaveBeenCalledWith( - 'open', - ['http://localhost:2346/s/main'], - expect.anything(), - ); - }); - - test('spawns xdg-open on linux', async () => { - const spawnMock = mock(() => ({ unref: () => {} })); - const { openBrowser } = await import('./http'); - withPlatform('linux', () => openBrowser('http://localhost:2346/s/main', spawnMock as never)); - expect(spawnMock).toHaveBeenCalledWith( - 'xdg-open', - ['http://localhost:2346/s/main'], - expect.anything(), - ); - }); - - test('spawns cmd.exe on win32', async () => { - const spawnMock = mock(() => ({ unref: () => {} })); - const { openBrowser } = await import('./http'); - withPlatform('win32', () => openBrowser('http://localhost:2346/s/main', spawnMock as never)); - expect(spawnMock).toHaveBeenCalledWith( - 'cmd.exe', - expect.arrayContaining(['start']), - expect.anything(), - ); + expect(await isServerRunning()).toBe(false); }); }); diff --git a/src/cli/http.ts b/src/cli/http.ts index 815c17a..071e9fa 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -1,12 +1,7 @@ -import * as childProcess from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { configDir, loadConfig } from '../config'; -import { isWindows, runningUnderBun } from '../runtime'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +// The CLI's view of where the server listens and whether it answers: the config-derived base +// URL and the reachability probe. A small service; the session calls move to WebttyClient in +// api-client.ts (ADR 063). +import { loadConfig } from '../config'; /** Active server port, read from config on first call (env PORT is intentionally ignored). */ export function getPort(): number { @@ -18,11 +13,6 @@ export function getBaseUrl(): string { return `http://127.0.0.1:${getPort()}`; } -/** Returns the path to the server log file: `~/.config/webtty/server.log`. */ -export function logPath(): string { - return path.join(configDir(), 'server.log'); -} - /** Returns `true` if the webtty server is reachable and responding to API requests. */ export async function isServerRunning(): Promise<boolean> { try { @@ -34,117 +24,3 @@ export async function isServerRunning(): Promise<boolean> { return false; } } - -/** - * Spawns the server process detached, then polls until it is ready or `timeoutMs` expires. - * - * @param timeoutMs - Maximum time to wait for the server to start (default: 10000 ms). - * @param _spawn - Spawn function override for testing (default: childProcess.spawn). - * @throws Exits the process with code 1 if the server entry is not found or fails to start in time. - */ -export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn): Promise<void> { - // On Windows, Bun's net.Socket doesn't support the fd-based named-pipe - // wrapping that node-pty's ConPTY backend requires, so node-pty fails under - // Bun on Windows. bunx works because its #!/usr/bin/env node shim runs the - // server with Node.js instead. Mirror that here explicitly. - const useNode = isWindows() && runningUnderBun(); - const serverExec = useNode ? 'node' : process.execPath; - - // Resolve the server entry. When useNode is true the executor is Node.js, - // which cannot run .ts files, so we always need the compiled .js output. - // The CLI may itself run from source (src/cli/) or from dist (dist/cli/); - // adjust the relative path accordingly so we always land in the same dist/. - const runningFromSrc = __filename.endsWith('.ts'); - let serverEntry: string; - if (useNode) { - serverEntry = runningFromSrc - ? path.resolve(__dirname, '../../dist/server/index.js') // src/cli → dist/server - : path.resolve(__dirname, '../server/index.js'); // dist/cli → dist/server - } else { - serverEntry = path.resolve( - __dirname, - runningFromSrc ? '../server/index.ts' : '../server/index.js', - ); - } - if (!fs.existsSync(serverEntry)) { - console.error(`webtty: server entry not found at ${serverEntry}`); - (process.exit as (code?: number) => void)(1); - return; - } - - const config = loadConfig(); - let stdio: childProcess.SpawnOptions['stdio'] = 'ignore'; - let logFd: number | undefined; - if (config.logs) { - const log = logPath(); - fs.mkdirSync(path.dirname(log), { recursive: true }); - logFd = fs.openSync(log, 'a'); - stdio = ['ignore', logFd, logFd]; - } - - const child = _spawn(serverExec, [serverEntry], { - detached: true, - stdio, - env: { ...process.env, PORT: String(config.port) }, - }); - child.on('error', (err) => { - const hint = useNode - ? ' (Node.js must be on PATH when running webtty under Bun on Windows)' - : ''; - console.error(`webtty: failed to start server: ${err.message}${hint}`); - process.exit(1); - }); - child.unref(); - if (logFd !== undefined) fs.closeSync(logFd); - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await isServerRunning()) return; - await new Promise((r) => setTimeout(r, 100)); - } - console.error('webtty: server did not start in time'); - process.exit(1); -} - -/** - * Sends `POST /api/server/stop`, then polls until the server is no longer reachable. - * - * @param baseUrl - The server base URL (default: getBaseUrl()). - * @param timeoutMs - Maximum time to wait for the server to stop (default: 5000 ms). - * @returns `true` if the server stopped successfully, `false` otherwise. - */ -export async function stopServer( - baseUrl: string = getBaseUrl(), - timeoutMs = 5000, -): Promise<boolean> { - try { - const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); - if (!res.ok) return false; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!(await isServerRunning())) return true; - await new Promise((r) => setTimeout(r, 100)); - } - return false; - } catch { - return false; - } -} - -/** - * Opens `url` in the default system browser. - * No-op in test environments or when `WEBTTY_NO_OPEN=1` is set. - * - * @param url - The URL to open. - * @param _spawn - Spawn function override for testing (default: childProcess.spawn). - */ -export function openBrowser(url: string, _spawn = childProcess.spawn): void { - if (process.env.WEBTTY_NO_OPEN === '1') return; - if (process.env.NODE_ENV === 'test') return; - if (process.platform === 'win32') { - _spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref(); - } else { - const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; - _spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref(); - } -} diff --git a/src/cli/server-process.test.ts b/src/cli/server-process.test.ts new file mode 100644 index 0000000..4b54db8 --- /dev/null +++ b/src/cli/server-process.test.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import os from 'node:os'; +import path from 'node:path'; +import { DEFAULT_CONFIG } from '../config'; +import { + defaultServerProcessDeps, + logPath, + resolveServerEntry, + type ServerProcessDeps, + startServer, + stopServer, +} from './server-process'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe('resolveServerEntry', () => { + const cases: Array<[string, string, boolean, boolean, string]> = [ + ['src under bun', '/pkg/src/cli', true, false, '/pkg/src/server/index.ts'], + ['dist under bun', '/pkg/dist/cli', false, false, '/pkg/dist/server/index.js'], + ['src under node', '/pkg/src/cli', true, true, '/pkg/dist/server/index.js'], + ['dist under node', '/pkg/dist/cli', false, true, '/pkg/dist/server/index.js'], + ]; + for (const [name, cliDir, runningFromSrc, useNode, expected] of cases) { + test(name, () => { + expect(resolveServerEntry({ cliDir, runningFromSrc, useNode })).toBe(path.resolve(expected)); + }); + } +}); + +describe('logPath', () => { + test('returns path inside ~/.config/webtty/', () => { + expect(logPath()).toBe(path.join(os.homedir(), '.config', 'webtty', 'server.log')); + }); +}); + +describe('defaultServerProcessDeps', () => { + test('points at this checkout and the live process', () => { + const deps = defaultServerProcessDeps(); + expect(deps.entry.runningFromSrc).toBe(true); + expect(deps.entry.cliDir).toBe(import.meta.dir); + expect(deps.execPath).toBe(process.execPath); + expect(deps.env).toBe(process.env); + expect(deps.useNode).toBe(process.platform === 'win32'); + }); + + test('openLog creates the directory and opens the file for appending', async () => { + const fs = await import('node:fs'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-log-')); + const file = path.join(dir, 'nested', 'server.log'); + const deps = defaultServerProcessDeps(); + const fd = deps.openLog(file); + fs.writeSync(fd, 'line\n'); + deps.closeFd(fd); + expect(fs.readFileSync(file, 'utf8')).toBe('line\n'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('err and exit go to console.error and process.exit', () => { + const error = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + try { + const deps = defaultServerProcessDeps(); + deps.err('boom'); + deps.exit(1); + expect(error).toHaveBeenCalledWith('boom'); + expect(exit).toHaveBeenCalledWith(1); + } finally { + error.mockRestore(); + exit.mockRestore(); + } + }); + + test('sleep resolves after the delay', async () => { + const start = Date.now(); + await defaultServerProcessDeps().sleep(5); + expect(Date.now() - start).toBeGreaterThanOrEqual(4); + }); +}); + +/** A child process that records unref and, when `spawnError` is set, emits it on 'error'. */ +function fakeChild(spawnError?: Error) { + return { + unref: mock(() => {}), + on: mock((event: string, cb: (err: Error) => void) => { + if (event === 'error' && spawnError) cb(spawnError); + }), + }; +} + +/** Deps that record every call; the server answers on the first probe unless overridden. */ +function fakeDeps(overrides: Partial<ServerProcessDeps> = {}) { + const child = fakeChild(); + const errors: string[] = []; + const exits: number[] = []; + const spawn = mock(() => child); + const deps: ServerProcessDeps = { + spawn, + loadConfig: () => ({ ...DEFAULT_CONFIG, port: 4321 }), + existsSync: () => true, + openLog: mock(() => 99), + closeFd: mock(() => {}), + isRunning: async () => true, + err: (text) => errors.push(text), + exit: (code) => exits.push(code), + sleep: async () => {}, + env: { HOME: '/home/u' }, + execPath: '/usr/bin/bun', + useNode: false, + entry: { cliDir: '/pkg/src/cli', runningFromSrc: true }, + ...overrides, + }; + return { deps, spawn, child, errors, exits }; +} + +describe('startServer', () => { + test('spawns the executor on the entry, detached with PORT, and returns once reachable', async () => { + let probes = 0; + const { deps, spawn, child, exits } = fakeDeps({ isRunning: async () => ++probes >= 3 }); + await startServer(10000, deps); + + expect(spawn).toHaveBeenCalledWith('/usr/bin/bun', [path.resolve('/pkg/src/server/index.ts')], { + detached: true, + stdio: 'ignore', + env: { HOME: '/home/u', PORT: '4321' }, + }); + expect(child.unref).toHaveBeenCalled(); + expect(probes).toBe(3); + expect(exits).toEqual([]); + }); + + test('passes the log fd to stdio and closes it when logs is on', async () => { + const { deps, spawn } = fakeDeps({ loadConfig: () => ({ ...DEFAULT_CONFIG, logs: true }) }); + await startServer(10000, deps); + + const options = (spawn.mock.calls[0] as unknown[])[2] as { stdio: unknown }; + expect(options.stdio).toEqual(['ignore', 99, 99]); + expect(deps.openLog).toHaveBeenCalledWith(logPath()); + expect(deps.closeFd).toHaveBeenCalledWith(99); + }); + + test('uses node on the built entry when useNode', async () => { + const { deps, spawn } = fakeDeps({ useNode: true }); + await startServer(10000, deps); + const [exec, args] = spawn.mock.calls[0] as unknown as [string, string[]]; + expect(exec).toBe('node'); + expect(args).toEqual([path.resolve('/pkg/dist/server/index.js')]); + }); + + test('exits 1 without spawning when the entry is missing', async () => { + const { deps, spawn, errors, exits } = fakeDeps({ existsSync: () => false }); + await startServer(10000, deps); + expect(errors).toEqual([ + `webtty: server entry not found at ${path.resolve('/pkg/src/server/index.ts')}`, + ]); + expect(exits).toEqual([1]); + expect(spawn).not.toHaveBeenCalled(); + }); + + test('reports a spawn error without the Windows hint', async () => { + const child = fakeChild(new Error('spawn ENOENT')); + const { deps, errors, exits } = fakeDeps({ + spawn: () => child, + }); + await startServer(10000, deps); + expect(errors).toEqual(['webtty: failed to start server: spawn ENOENT']); + expect(exits).toEqual([1]); + }); + + test('reports a spawn error with the Windows hint when useNode', async () => { + const child = fakeChild(new Error('spawn ENOENT')); + const { deps, errors } = fakeDeps({ + useNode: true, + spawn: () => child, + }); + await startServer(10000, deps); + expect(errors).toEqual([ + 'webtty: failed to start server: spawn ENOENT (Node.js must be on PATH when running webtty under Bun on Windows)', + ]); + }); + + test('exits 1 when the server never answers before the timeout', async () => { + const sleep = mock(() => new Promise<void>((r) => setTimeout(r, 5))); + const { deps, errors, exits } = fakeDeps({ isRunning: async () => false, sleep }); + await startServer(30, deps); + expect(sleep).toHaveBeenCalledWith(100); + expect(errors).toEqual(['webtty: server did not start in time']); + expect(exits).toEqual([1]); + }); +}); + +describe('stopServer', () => { + test('returns true when server stops after POST succeeds', async () => { + let callCount = 0; + globalThis.fetch = mock(async (_url: string, init?: RequestInit) => { + if (init?.method === 'POST') return new Response('stopping', { status: 200 }); + callCount++; + if (callCount >= 2) throw new Error('connection refused'); + return new Response('[]', { status: 200 }); + }) as unknown as typeof fetch; + + expect(await stopServer()).toBe(true); + }); + + test('returns false when POST to stop endpoint fails', async () => { + globalThis.fetch = mock(async (_url: string, init?: RequestInit) => { + if (init?.method === 'POST') return new Response('error', { status: 500 }); + return new Response('[]', { status: 200 }); + }) as unknown as typeof fetch; + + expect(await stopServer()).toBe(false); + }); + + test('returns false when fetch throws', async () => { + globalThis.fetch = mock(async () => { + throw new Error('connection refused'); + }) as unknown as typeof fetch; + + expect(await stopServer()).toBe(false); + }); + + test('returns false when server does not come down within timeout', async () => { + globalThis.fetch = mock( + async () => new Response('[]', { status: 200 }), + ) as unknown as typeof fetch; + + expect(await stopServer('http://127.0.0.1:1', 100)).toBe(false); + }); +}); diff --git a/src/cli/server-process.ts b/src/cli/server-process.ts new file mode 100644 index 0000000..c64d9e8 --- /dev/null +++ b/src/cli/server-process.ts @@ -0,0 +1,183 @@ +// Starting and stopping the webtty server as a detached background process. A service over +// injected deps (spawn, filesystem, config, reachability probe), with the src/dist entry +// arithmetic split out as the pure resolveServerEntry (ADR 063). +import * as childProcess from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { type Config, configDir, loadConfig } from '../config'; +import { isWindows, runningUnderBun } from '../runtime'; +import { getBaseUrl, isServerRunning } from './http'; + +/** Where the CLI module runs from, and with which executor the server will run. */ +export interface ServerEntryOptions { + /** The directory of the running CLI module: `src/cli` or `dist/cli`. */ + cliDir: string; + /** True when the CLI runs from TypeScript source (`src/cli/*.ts`). */ + runningFromSrc: boolean; + /** True when the server must run under Node.js, which cannot run `.ts` files. */ + useNode: boolean; +} + +/** + * The server entry to spawn. Under Node.js it is always the built `dist/server/index.js`; under + * Bun it is the sibling of the CLI, `src/server/index.ts` from source or `dist/server/index.js` + * once built. + */ +export function resolveServerEntry({ + cliDir, + runningFromSrc, + useNode, +}: ServerEntryOptions): string { + if (useNode) { + return runningFromSrc + ? path.resolve(cliDir, '../../dist/server/index.js') // src/cli → dist/server + : path.resolve(cliDir, '../server/index.js'); // dist/cli → dist/server + } + return path.resolve(cliDir, runningFromSrc ? '../server/index.ts' : '../server/index.js'); +} + +/** Returns the path to the server log file: `~/.config/webtty/server.log`. */ +export function logPath(): string { + return path.join(configDir(), 'server.log'); +} + +/** The part of a spawned ChildProcess that startServer uses. */ +export interface ServerChild { + on(event: 'error', listener: (err: Error) => void): unknown; + unref(): void; +} + +/** `child_process.spawn` narrowed to the one call startServer makes, so a fake needs no casts. */ +export type SpawnServer = ( + command: string, + args: string[], + options: childProcess.SpawnOptions, +) => ServerChild; + +/** Everything startServer touches outside itself; {@link defaultServerProcessDeps} gives the real ones. */ +export interface ServerProcessDeps { + spawn: SpawnServer; + loadConfig: () => Config; + existsSync: (p: string) => boolean; + /** Opens the log file for appending (creating its directory) and returns the fd. */ + openLog: (file: string) => number; + closeFd: (fd: number) => void; + isRunning: () => Promise<boolean>; + err: (text: string) => void; + /** Ends the process; a fake may return, so startServer returns right after it. */ + exit: (code: number) => void; + sleep: (ms: number) => Promise<void>; + /** The environment the server inherits (PORT is set on top of it). */ + env: NodeJS.ProcessEnv; + /** The executable running the CLI, reused for the server unless `useNode`. */ + execPath: string; + /** + * On Windows, Bun's net.Socket doesn't support the fd-based named-pipe wrapping that + * node-pty's ConPTY backend requires, so node-pty fails under Bun on Windows. bunx works + * because its #!/usr/bin/env node shim runs the server with Node.js instead; this mirrors it. + */ + useNode: boolean; + entry: Omit<ServerEntryOptions, 'useNode'>; +} + +/** The production deps, read per call so tests that stub `process.platform` see their stub. */ +export function defaultServerProcessDeps(): ServerProcessDeps { + const moduleFile = fileURLToPath(import.meta.url); + return { + spawn: childProcess.spawn, + loadConfig, + existsSync: fs.existsSync, + openLog: (file) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + return fs.openSync(file, 'a'); + }, + closeFd: fs.closeSync, + isRunning: isServerRunning, + err: (text) => console.error(text), + exit: (code) => process.exit(code), + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), + env: process.env, + execPath: process.execPath, + useNode: isWindows() && runningUnderBun(), + entry: { cliDir: path.dirname(moduleFile), runningFromSrc: moduleFile.endsWith('.ts') }, + }; +} + +/** + * Spawns the server process detached, then polls until it is ready or `timeoutMs` expires. + * Exits 1 when the entry is missing, the spawn fails or the server never answers. + * + * @param timeoutMs - Maximum time to wait for the server to start. + * @param overrides - Deps to replace, for tests; the rest are {@link defaultServerProcessDeps}. + */ +export async function startServer( + timeoutMs = 10000, + overrides: Partial<ServerProcessDeps> = {}, +): Promise<void> { + const deps = { ...defaultServerProcessDeps(), ...overrides }; + const { useNode } = deps; + const serverExec = useNode ? 'node' : deps.execPath; + const serverEntry = resolveServerEntry({ ...deps.entry, useNode }); + if (!deps.existsSync(serverEntry)) { + deps.err(`webtty: server entry not found at ${serverEntry}`); + deps.exit(1); + return; + } + + const config = deps.loadConfig(); + let stdio: childProcess.SpawnOptions['stdio'] = 'ignore'; + let logFd: number | undefined; + if (config.logs) { + logFd = deps.openLog(logPath()); + stdio = ['ignore', logFd, logFd]; + } + + const child = deps.spawn(serverExec, [serverEntry], { + detached: true, + stdio, + env: { ...deps.env, PORT: String(config.port) }, + }); + child.on('error', (err) => { + const hint = useNode + ? ' (Node.js must be on PATH when running webtty under Bun on Windows)' + : ''; + deps.err(`webtty: failed to start server: ${err.message}${hint}`); + deps.exit(1); + }); + child.unref(); + if (logFd !== undefined) deps.closeFd(logFd); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await deps.isRunning()) return; + await deps.sleep(100); + } + deps.err('webtty: server did not start in time'); + deps.exit(1); +} + +/** + * Sends `POST /api/server/stop`, then polls until the server is no longer reachable. + * + * @param baseUrl - The server base URL (default: getBaseUrl()). + * @param timeoutMs - Maximum time to wait for the server to stop (default: 5000 ms). + * @returns `true` if the server stopped successfully, `false` otherwise. + */ +export async function stopServer( + baseUrl: string = getBaseUrl(), + timeoutMs = 5000, +): Promise<boolean> { + try { + const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); + if (!res.ok) return false; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await isServerRunning())) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return false; + } catch { + return false; + } +} From a3dea9a45269cf3c8642f1ac15d7cfceb4a13d6d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:26:22 -0400 Subject: [PATCH 204/295] refactor(cli): main, args, help and deps behind a thin entry index.ts parsed argv and dispatched at import time. It is now the entry only (await main(process.argv)); main.ts is the composition root that parses and dispatches over CliDeps (out, err, exit, version and the command table, built by createCliDeps). parseArgs and GO_ALIASES live in cli-args.ts, the usage text in help-text.ts, both pure (ADR 063). Tests: cli-args covers the --dir splice anywhere in argv, including a trailing --dir, and the go aliases; help-text pins the exact bytes; main checks every command, alias, usage error and exit code with fake deps. Help output and exit codes are byte-identical (diffed against the old entry for help, --help, -h, -v, an unknown command and run without id). --- src/cli/cli-args.test.ts | 37 ++++++++++ src/cli/cli-args.ts | 35 +++++++++ src/cli/cli-deps.ts | 62 ++++++++++++++++ src/cli/help-text.test.ts | 29 ++++++++ src/cli/help-text.ts | 34 +++++++++ src/cli/index.ts | 111 +--------------------------- src/cli/main.test.ts | 151 ++++++++++++++++++++++++++++++++++++++ src/cli/main.ts | 79 ++++++++++++++++++++ 8 files changed, 431 insertions(+), 107 deletions(-) create mode 100644 src/cli/cli-args.test.ts create mode 100644 src/cli/cli-args.ts create mode 100644 src/cli/cli-deps.ts create mode 100644 src/cli/help-text.test.ts create mode 100644 src/cli/help-text.ts create mode 100644 src/cli/main.test.ts create mode 100644 src/cli/main.ts diff --git a/src/cli/cli-args.test.ts b/src/cli/cli-args.test.ts new file mode 100644 index 0000000..6be1efa --- /dev/null +++ b/src/cli/cli-args.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { GO_ALIASES, parseArgs } from './cli-args'; + +describe('parseArgs', () => { + const cases: Array<[string, string[], string | undefined, string[], string | undefined]> = [ + ['no arguments', [], undefined, [], undefined], + ['a command and its arguments', ['mv', 'a', 'b'], 'mv', ['a', 'b'], undefined], + ['--dir before the command', ['--dir', '/tmp/x', 'go', 'id'], 'go', ['id'], '/tmp/x'], + ['--dir after the command', ['go', '--dir', '/tmp/x', 'id'], 'go', ['id'], '/tmp/x'], + ['--dir alone', ['--dir', '/tmp/x'], undefined, [], '/tmp/x'], + ['--dir at the end of argv', ['go', 'id', '--dir'], 'go', ['id'], path.resolve('.')], + ['a relative --dir', ['--dir', 'sub'], undefined, [], path.resolve('sub')], + ['only the first --dir', ['--dir', '/a', 'go', '--dir', '/b'], 'go', ['--dir', '/b'], '/a'], + ]; + for (const [name, args, command, rest, baseDir] of cases) { + test(name, () => { + expect(parseArgs(args)).toEqual({ + command, + rest, + baseDir: baseDir === undefined ? undefined : path.resolve(baseDir), + }); + }); + } + + test('does not modify its input', () => { + const args = ['--dir', '/tmp/x', 'go']; + parseArgs(args); + expect(args).toEqual(['--dir', '/tmp/x', 'go']); + }); +}); + +describe('GO_ALIASES', () => { + test('lists exactly go, a, attach and open', () => { + expect([...GO_ALIASES].sort()).toEqual(['a', 'attach', 'go', 'open']); + }); +}); diff --git a/src/cli/cli-args.ts b/src/cli/cli-args.ts new file mode 100644 index 0000000..26dd641 --- /dev/null +++ b/src/cli/cli-args.ts @@ -0,0 +1,35 @@ +// Parsing the CLI's argv into a command, its arguments and the `--dir` base directory. A pure +// util: no I/O beyond resolving the directory against the working directory (ADR 063). +import path from 'node:path'; + +/** The commands that open a session in the browser; with no command at all, `go main` runs. */ +export const GO_ALIASES: ReadonlySet<string> = new Set(['go', 'a', 'attach', 'open']); + +/** A parsed command line. */ +export interface CliArgs { + /** The first argument left after `--dir` is removed, or undefined for a bare `webtty`. */ + command: string | undefined; + /** The arguments after the command. */ + rest: string[]; + /** The absolute `--dir` value, the working directory for a new session's shell. */ + baseDir: string | undefined; +} + +/** + * Splits the user's arguments (argv without the runtime and script) into a {@link CliArgs}. + * `--dir <path>` may sit anywhere; it and its value are removed before the command is read. + * A trailing `--dir` with no value means the current directory. + * + * @param args - `process.argv.slice(2)`. + */ +export function parseArgs(args: readonly string[]): CliArgs { + const remaining = [...args]; + const dirFlagIdx = remaining.indexOf('--dir'); + let baseDir: string | undefined; + if (dirFlagIdx !== -1) { + baseDir = path.resolve(remaining[dirFlagIdx + 1] ?? '.'); + remaining.splice(dirFlagIdx, 2); + } + const [command, ...rest] = remaining; + return { command, rest, baseDir }; +} diff --git a/src/cli/cli-deps.ts b/src/cli/cli-deps.ts new file mode 100644 index 0000000..f290872 --- /dev/null +++ b/src/cli/cli-deps.ts @@ -0,0 +1,62 @@ +// Everything the CLI dispatcher reaches outside itself: the output streams, the exit call, the +// version and the command handlers. Types plus a factory over the real globals, so main.ts is +// tested with fakes instead of subprocesses (ADR 063). +import { getVersion } from '../version'; +import { + cmdConfig, + cmdGo, + cmdKey, + cmdList, + cmdRemove, + cmdRename, + cmdRun, + cmdStart, + cmdStop, +} from './commands'; + +/** The command handlers main() dispatches to, one per command (aliases resolve before the call). */ +export interface CliCommands { + go(id: string, baseDir?: string): Promise<void>; + list(filter?: string): Promise<void>; + remove(id?: string): Promise<void>; + rename(id?: string, newId?: string): Promise<void>; + stop(): Promise<void>; + start(): Promise<void>; + config(): void; + run(id: string, cmd: string | undefined, args: string[]): Promise<void>; + key(): void; +} + +/** What main() needs from the process; tests pass recording fakes. */ +export interface CliDeps { + /** Prints one line to stdout (console.log). */ + out(text: string): void; + /** Prints one line to stderr (console.error). */ + err(text: string): void; + /** Ends the process with `code`; a fake may return, so callers return right after it. */ + exit(code: number): void; + /** The package version `webtty -v` prints. */ + version(): string; + commands: CliCommands; +} + +/** The production deps: the real console, process.exit and the command functions. */ +export function createCliDeps(): CliDeps { + return { + out: (text) => console.log(text), + err: (text) => console.error(text), + exit: (code) => process.exit(code), + version: getVersion, + commands: { + go: cmdGo, + list: cmdList, + remove: cmdRemove, + rename: cmdRename, + stop: cmdStop, + start: cmdStart, + config: cmdConfig, + run: cmdRun, + key: cmdKey, + }, + }; +} diff --git a/src/cli/help-text.test.ts b/src/cli/help-text.test.ts new file mode 100644 index 0000000..cae85ff --- /dev/null +++ b/src/cli/help-text.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { helpText } from './help-text'; + +describe('helpText', () => { + test('is byte-identical to the published usage', () => { + expect(helpText()).toBe( + [ + 'Launch Terminal UI in the browser.', + '', + 'USAGE', + ' webtty Open main session in the browser', + ' webtty [command] Execute a specific command', + '', + 'COMMANDS', + ' go [id] Open a new or existing session in the browser', + ' ls [id] List all sessions, or filter by id substring', + ' rm <id> Destroy a session', + ' mv <id> <new-id> Rename a session', + ' run <id> [cmd] Start PTY (no browser) or run a command in a session', + ' stop Stop the webtty server', + ' start Start the webtty server', + ' config Open the config file in $VISUAL, $EDITOR, or a default editor', + ' key Capture a key combo and print its chars value for keyboardBindings', + ' help Show this help message', + ' -v, --version Print the webtty version', + ].join('\n'), + ); + }); +}); diff --git a/src/cli/help-text.ts b/src/cli/help-text.ts new file mode 100644 index 0000000..0a2339d --- /dev/null +++ b/src/cli/help-text.ts @@ -0,0 +1,34 @@ +// The text `webtty help` prints. A pure util kept apart from the dispatcher so its exact bytes +// are pinned by a test (ADR 063). + +const INDENT = ' '; +/** Width of the widest term, "mv <id> <new-id>". */ +const TERM_COLUMN = 18; + +function row(term: string, desc: string): string { + return `${INDENT}${term.padEnd(TERM_COLUMN)} ${desc}`; +} + +/** The usage text, one string without a trailing newline (the caller prints it as one line). */ +export function helpText(): string { + return [ + 'Launch Terminal UI in the browser.', + '', + 'USAGE', + row('webtty', 'Open main session in the browser'), + row('webtty [command]', 'Execute a specific command'), + '', + 'COMMANDS', + row('go [id]', 'Open a new or existing session in the browser'), + row('ls [id]', 'List all sessions, or filter by id substring'), + row('rm <id>', 'Destroy a session'), + row('mv <id> <new-id>', 'Rename a session'), + row('run <id> [cmd]', 'Start PTY (no browser) or run a command in a session'), + row('stop', 'Stop the webtty server'), + row('start', 'Start the webtty server'), + row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), + row('key', 'Capture a key combo and print its chars value for keyboardBindings'), + row('help', 'Show this help message'), + row('-v, --version', 'Print the webtty version'), + ].join('\n'); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index c63433a..bc77560 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,108 +1,5 @@ -import path from 'node:path'; -import { getVersion } from '../version'; -import { - cmdConfig, - cmdGo, - cmdKey, - cmdList, - cmdRemove, - cmdRename, - cmdRun, - cmdStart, - cmdStop, -} from './commands'; +// The CLI entry (dist/cli/index.js, the package `bin`): runs main() on the real argv and +// process. A thin entry; the dispatch is in main.ts (ADR 063). +import { main } from './main'; -const GO_ALIASES = new Set(['go', 'a', 'attach', 'open']); - -function printHelp(): void { - const indent = ' '; - const col = 18; // width of the widest term: "mv <id> <new-id>" - const row = (term: string, desc: string) => `${indent}${term.padEnd(col)} ${desc}`; - - console.log( - [ - 'Launch Terminal UI in the browser.', - '', - 'USAGE', - row('webtty', 'Open main session in the browser'), - row('webtty [command]', 'Execute a specific command'), - '', - 'COMMANDS', - row('go [id]', 'Open a new or existing session in the browser'), - row('ls [id]', 'List all sessions, or filter by id substring'), - row('rm <id>', 'Destroy a session'), - row('mv <id> <new-id>', 'Rename a session'), - row('run <id> [cmd]', 'Start PTY (no browser) or run a command in a session'), - row('stop', 'Stop the webtty server'), - row('start', 'Start the webtty server'), - row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), - row('key', 'Capture a key combo and print its chars value for keyboardBindings'), - row('help', 'Show this help message'), - row('-v, --version', 'Print the webtty version'), - ].join('\n'), - ); -} - -const allArgs = process.argv.slice(2); -const dirFlagIdx = allArgs.indexOf('--dir'); -let cliBaseDir: string | undefined; -if (dirFlagIdx !== -1) { - cliBaseDir = path.resolve(allArgs[dirFlagIdx + 1] ?? '.'); - allArgs.splice(dirFlagIdx, 2); -} -const [cmd, ...rest] = allArgs; - -if (!cmd) { - await cmdGo('main', cliBaseDir); -} else if (GO_ALIASES.has(cmd)) { - await cmdGo(rest[0] ?? 'main', cliBaseDir); -} else { - switch (cmd) { - case 'ls': - case 'list': - await cmdList(rest[0]); - break; - case 'rm': - case 'remove': - await cmdRemove(rest[0]); - break; - case 'mv': - case 'move': - case 'rename': - await cmdRename(rest[0], rest[1]); - break; - case 'stop': - await cmdStop(); - break; - case 'start': - await cmdStart(); - break; - case 'config': - cmdConfig(); - break; - case 'run': { - const [id, runCmd, ...runArgs] = rest; - if (!id) { - console.error('usage: webtty run <id> [cmd [args...]]'); - process.exit(1); - } - await cmdRun(id, runCmd, runArgs); - break; - } - case 'key': - cmdKey(); - break; - case '-v': - case '--version': - console.log(getVersion()); - break; - case 'help': - case '--help': - case '-h': - printHelp(); - break; - default: - console.error(`webtty: unknown command '${cmd}'\nRun \`webtty help\` for usage.`); - process.exit(1); - } -} +await main(process.argv); diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts new file mode 100644 index 0000000..3a2b5d7 --- /dev/null +++ b/src/cli/main.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, spyOn, test } from 'bun:test'; +import path from 'node:path'; +import { type CliCommands, type CliDeps, createCliDeps } from './cli-deps'; +import { helpText } from './help-text'; +import { main } from './main'; + +/** Deps whose commands record `name(args)` and whose streams and exit record into arrays. */ +function fakeDeps() { + const calls: Array<[string, ...unknown[]]> = []; + const out: string[] = []; + const err: string[] = []; + const exits: number[] = []; + const record = + (name: string) => + (...args: unknown[]) => { + calls.push([name, ...args]); + }; + const asyncRecord = + (name: string) => + async (...args: unknown[]) => { + calls.push([name, ...args]); + }; + const commands: CliCommands = { + go: asyncRecord('go'), + list: asyncRecord('list'), + remove: asyncRecord('remove'), + rename: asyncRecord('rename'), + stop: asyncRecord('stop'), + start: asyncRecord('start'), + config: record('config'), + run: asyncRecord('run'), + key: record('key'), + }; + const deps: CliDeps = { + out: (text) => out.push(text), + err: (text) => err.push(text), + exit: (code) => exits.push(code), + version: () => '9.9.9', + commands, + }; + return { deps, calls, out, err, exits }; +} + +/** Runs main as `webtty ...args` and returns what it recorded. */ +async function run(...args: string[]) { + const fake = fakeDeps(); + await main(['bun', 'webtty', ...args], fake.deps); + return fake; +} + +describe('main dispatch', () => { + const cases: Array<[string[], Array<[string, ...unknown[]]>]> = [ + [[], [['go', 'main', undefined]]], + [['go'], [['go', 'main', undefined]]], + [['go', 'x'], [['go', 'x', undefined]]], + [['a', 'x'], [['go', 'x', undefined]]], + [['attach', 'x'], [['go', 'x', undefined]]], + [['open'], [['go', 'main', undefined]]], + [['--dir', '/tmp/w'], [['go', 'main', path.resolve('/tmp/w')]]], + [['go', 'x', '--dir', '/tmp/w'], [['go', 'x', path.resolve('/tmp/w')]]], + [['ls'], [['list', undefined]]], + [['list', 'ab'], [['list', 'ab']]], + [['rm', 'x'], [['remove', 'x']]], + [['remove'], [['remove', undefined]]], + [['mv', 'a', 'b'], [['rename', 'a', 'b']]], + [['move', 'a'], [['rename', 'a', undefined]]], + [['rename', 'a', 'b'], [['rename', 'a', 'b']]], + [['stop'], [['stop']]], + [['start'], [['start']]], + [['config'], [['config']]], + [['run', 'x'], [['run', 'x', undefined, []]]], + [['run', 'x', 'ls', '-la', 'dir'], [['run', 'x', 'ls', ['-la', 'dir']]]], + [['key'], [['key']]], + ]; + for (const [args, expected] of cases) { + test(`webtty ${args.join(' ') || '(no args)'}`, async () => { + const { calls, out, err, exits } = await run(...args); + expect(calls).toEqual(expected); + expect(out).toEqual([]); + expect(err).toEqual([]); + expect(exits).toEqual([]); + }); + } + + for (const flag of ['-v', '--version']) { + test(`${flag} prints the version`, async () => { + const { calls, out, exits } = await run(flag); + expect(out).toEqual(['9.9.9']); + expect(calls).toEqual([]); + expect(exits).toEqual([]); + }); + } + + for (const flag of ['help', '--help', '-h']) { + test(`${flag} prints the help text`, async () => { + const { out, exits } = await run(flag); + expect(out).toEqual([helpText()]); + expect(exits).toEqual([]); + }); + } + + test('run without an id prints usage and exits 1', async () => { + const { calls, err, exits } = await run('run'); + expect(err).toEqual(['usage: webtty run <id> [cmd [args...]]']); + expect(exits).toEqual([1]); + expect(calls).toEqual([]); + }); + + test('an unknown command prints a hint and exits 1', async () => { + const { calls, err, exits } = await run('bogus'); + expect(err).toEqual(["webtty: unknown command 'bogus'\nRun `webtty help` for usage."]); + expect(exits).toEqual([1]); + expect(calls).toEqual([]); + }); +}); + +describe('createCliDeps', () => { + test('wires every command and the package version', async () => { + const deps = createCliDeps(); + const { cmdGo, cmdKey, cmdRun } = await import('./commands'); + const { getVersion } = await import('../version'); + expect(deps.commands.go).toBe(cmdGo); + expect(deps.commands.key).toBe(cmdKey); + expect(deps.commands.run).toBe(cmdRun); + expect(Object.keys(deps.commands).sort()).toEqual( + ['config', 'go', 'key', 'list', 'remove', 'rename', 'run', 'start', 'stop'].sort(), + ); + expect(deps.version).toBe(getVersion); + }); +}); + +describe('createCliDeps streams', () => { + test('out, err and exit go to console.log, console.error and process.exit', () => { + const log = spyOn(console, 'log').mockImplementation(() => {}); + const error = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + try { + const deps = createCliDeps(); + deps.out('to stdout'); + deps.err('to stderr'); + deps.exit(3); + expect(log).toHaveBeenCalledWith('to stdout'); + expect(error).toHaveBeenCalledWith('to stderr'); + expect(exit).toHaveBeenCalledWith(3); + } finally { + log.mockRestore(); + error.mockRestore(); + exit.mockRestore(); + } + }); +}); diff --git a/src/cli/main.ts b/src/cli/main.ts new file mode 100644 index 0000000..4a70d45 --- /dev/null +++ b/src/cli/main.ts @@ -0,0 +1,79 @@ +// The CLI's composition root: parse argv, then dispatch to one command handler with the deps it +// was given. Aliases and usage errors live here; what each command does lives in commands.ts +// (ADR 063). + +import { GO_ALIASES, parseArgs } from './cli-args'; +import { type CliDeps, createCliDeps } from './cli-deps'; +import { helpText } from './help-text'; + +/** + * Runs one CLI invocation. With no command it opens the `main` session, like `webtty go`. + * + * @param argv - The full `process.argv` (runtime and script path first). + * @param deps - The process and the command handlers; the real ones by default. + */ +export async function main( + argv: readonly string[], + deps: CliDeps = createCliDeps(), +): Promise<void> { + const { command, rest, baseDir } = parseArgs(argv.slice(2)); + const { commands } = deps; + + if (!command) { + await commands.go('main', baseDir); + return; + } + if (GO_ALIASES.has(command)) { + await commands.go(rest[0] ?? 'main', baseDir); + return; + } + switch (command) { + case 'ls': + case 'list': + await commands.list(rest[0]); + return; + case 'rm': + case 'remove': + await commands.remove(rest[0]); + return; + case 'mv': + case 'move': + case 'rename': + await commands.rename(rest[0], rest[1]); + return; + case 'stop': + await commands.stop(); + return; + case 'start': + await commands.start(); + return; + case 'config': + commands.config(); + return; + case 'run': { + const [id, runCmd, ...runArgs] = rest; + if (!id) { + deps.err('usage: webtty run <id> [cmd [args...]]'); + deps.exit(1); + return; + } + await commands.run(id, runCmd, runArgs); + return; + } + case 'key': + commands.key(); + return; + case '-v': + case '--version': + deps.out(deps.version()); + return; + case 'help': + case '--help': + case '-h': + deps.out(helpText()); + return; + default: + deps.err(`webtty: unknown command '${command}'\nRun \`webtty help\` for usage.`); + deps.exit(1); + } +} From 7a5a6f21a199b54dd62928c2811814b73f367574 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:30:22 -0400 Subject: [PATCH 205/295] docs(arch): mark CliDeps commands table as interim; ADR 063 builds deps in main.ts --- docs/adrs/063.cli.command-modules.md | 2 +- src/cli/cli-deps.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/adrs/063.cli.command-modules.md b/docs/adrs/063.cli.command-modules.md index eacf3d3..93d1fce 100644 --- a/docs/adrs/063.cli.command-modules.md +++ b/docs/adrs/063.cli.command-modules.md @@ -16,7 +16,7 @@ ## Decision - **`main.ts`** (`main(argv, deps)`) is the CLI's composition root; `index.ts` stays the entry (`dist/cli/index.js`, the `bin`) and only calls it. `cli-args.ts` (`parseArgs`, pure, including `--dir` at the end of `argv`) and `help-text.ts` are pure. -- **`CliDeps`** (`cli-deps.ts`): `{ client, out, err, exit, spawn, config }`, built once from the real globals in `index.ts`. Every command takes `(args, deps)`, so tests capture output and the exit code without subprocesses or namespace spies. +- **`CliDeps`** (`cli-deps.ts`): `{ client, out, err, exit, spawn, config }`, built once from the real globals in `main.ts` (the default for `main`'s `deps` parameter, so `index.ts` only calls `main(process.argv)`). Every command takes `(args, deps)`, so tests capture output and the exit code without subprocesses or namespace spies. - **`api-client.ts`**: `WebttyClient(baseUrl, fetch)` with `isRunning`, `getSession`, `ensureSession` (the one copy of ensure-or-create), `listSessions`, `removeSession`, `renameSession`, `stop` and `execute` (an async iterator of NDJSON messages over a `LineSplitter`). - **`server-process.ts`**: `resolveServerEntry({ runningFromSrc, useNode })` (pure, four cases tested), `startServer`, `stopServer`. **`browser-opener.ts`**: `openBrowser`, `toBrowserHost`. - **`commands/`**: one file per command (`go`, `list`, `remove`, `rename`, `start-stop`, `edit-config`, `run`, `key`) plus `key-format.ts`; `run.ts` is two functions, `warmUp` and `exec`. diff --git a/src/cli/cli-deps.ts b/src/cli/cli-deps.ts index f290872..5e811f9 100644 --- a/src/cli/cli-deps.ts +++ b/src/cli/cli-deps.ts @@ -1,6 +1,8 @@ // Everything the CLI dispatcher reaches outside itself: the output streams, the exit call, the // version and the command handlers. Types plus a factory over the real globals, so main.ts is // tested with fakes instead of subprocesses (ADR 063). +// Interim shape: the `commands` table goes away in the commands/ split, when CliDeps becomes +// { client, out, err, exit, spawn, config } and each command takes (args, deps) (ADR 063). import { getVersion } from '../version'; import { cmdConfig, @@ -37,6 +39,7 @@ export interface CliDeps { exit(code: number): void; /** The package version `webtty -v` prints. */ version(): string; + /** The command handlers; temporary until the commands/ split passes deps to each command. */ commands: CliCommands; } From 95c4413345dc5cc4bb0530f4203ebd8d1a78d927 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:35:46 -0400 Subject: [PATCH 206/295] refactor(client): font zoom, paste and find feature installers, and the binding matcher index.ts keeps its two container capture keydown listeners for now; what they and the other key and paste listeners use moves out of it: - features/keyboard/key-bindings.ts: findBinding(e, bindings), pure - features/keyboard/font-zoom.ts: the zoom keys, the clamp and the steps, pure - features/keyboard/font-zoom-feature.ts: the window capture keydown listener, still the last one on the window and still calling fit() synchronously - features/paste/paste-service.ts: PasteService (text, non-text Ctrl+V, the clipboard read with its Ctrl+V fallback; navigator injected, read per paste) - features/paste/paste-feature.ts: onPasteRequest and the container capture paste listener - features/find/find-feature.ts: the find bar in the body and its onFindChange subscription, timers from the env Listener targets, types, phases and per-target order are unchanged; the onPasteRequest subscription now follows onFindChange, a different emitter. index.ts drops below the size limit, so its ratchet entry goes. --- scripts/architecture/ratchet-allowlist.ts | 3 - src/client/features/find/find-feature.test.ts | 80 ++++++++++ src/client/features/find/find-feature.ts | 63 ++++++++ .../keyboard/font-zoom-feature.test.ts | 81 ++++++++++ .../features/keyboard/font-zoom-feature.ts | 35 +++++ .../features/keyboard/font-zoom.test.ts | 61 ++++++++ src/client/features/keyboard/font-zoom.ts | 41 ++++++ .../features/keyboard/key-bindings.test.ts | 51 +++++++ src/client/features/keyboard/key-bindings.ts | 35 +++++ .../features/paste/paste-feature.test.ts | 100 +++++++++++++ src/client/features/paste/paste-feature.ts | 44 ++++++ .../features/paste/paste-service.test.ts | 95 ++++++++++++ src/client/features/paste/paste-service.ts | 73 +++++++++ src/client/index.ts | 139 +++--------------- 14 files changed, 783 insertions(+), 118 deletions(-) create mode 100644 src/client/features/find/find-feature.test.ts create mode 100644 src/client/features/find/find-feature.ts create mode 100644 src/client/features/keyboard/font-zoom-feature.test.ts create mode 100644 src/client/features/keyboard/font-zoom-feature.ts create mode 100644 src/client/features/keyboard/font-zoom.test.ts create mode 100644 src/client/features/keyboard/font-zoom.ts create mode 100644 src/client/features/keyboard/key-bindings.test.ts create mode 100644 src/client/features/keyboard/key-bindings.ts create mode 100644 src/client/features/paste/paste-feature.test.ts create mode 100644 src/client/features/paste/paste-feature.ts create mode 100644 src/client/features/paste/paste-service.test.ts create mode 100644 src/client/features/paste/paste-service.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index b2c992d..9f7aec1 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -7,7 +7,6 @@ import type { Allowlist } from './ratchet'; const ENGINE_SPLIT = 'vendor engine split, ghostty.ts into engine/ (ADR 061)'; const VENDOR_TESTS = 'vendor tests split with their facades into vendor/tests/ (ADR 061, ADR 064)'; -const CLIENT_ROOT = 'client composition root, index.ts an entry only (ADR 060)'; const SERVER_HARNESS = 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; @@ -19,8 +18,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'scripts/update-ghostty-vt.ts': { lines: 360, step: 'scripts/ghostty-vt/ split (ADR 059)' }, 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - // 565 at the checker's commit; the folder moves lengthened its imports by 3 lines. - 'src/client/index.ts': { lines: 339, step: CLIENT_ROOT }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, diff --git a/src/client/features/find/find-feature.test.ts b/src/client/features/find/find-feature.test.ts new file mode 100644 index 0000000..9a04c20 --- /dev/null +++ b/src/client/features/find/find-feature.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { ManualClock } from '../../test-support/manual-clock'; +import { FIND_DELAY_MS } from './find-bar'; +import { installFind } from './find-feature'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** The find feature on fakes, its search delay on a manual clock. */ +function setup() { + fake = fakeContext(); + const clock = new ManualClock(); + const looked: string[] = []; + const feature = installFind(fake.ctx, { + shortcutActionOf: (e) => { + looked.push(e.code); + return e.code === 'Escape' ? 'findClose' : undefined; + }, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + }); + const doc = fake.ctx.doc; + const bar = () => doc.querySelector('.find-bar') as HTMLElement | null; + const input = () => doc.getElementById('find-bar-input') as HTMLInputElement; + return { feature, clock, looked, bar, input }; +} + +describe('installFind', () => { + test('appends the bar to the body and subscribes once to onFindChange', () => { + const { bar } = setup(); + expect(bar()?.parentElement).toBe(fake.ctx.doc.body); + expect(fake.term.subscriptions).toEqual(['onFindChange']); + expect(fake.win.added).toEqual([]); + }); + + test('opens, searches after the delay and moves through the terminal', () => { + const { feature, clock, bar, input } = setup(); + expect(feature.isOpen).toBe(false); + feature.open(); + expect(feature.isOpen).toBe(true); + expect(bar()?.hidden).toBe(false); + input().value = 'needle'; + input().dispatchEvent(new fake.dom.window.Event('input')); + clock.advance(FIND_DELAY_MS); + feature.go('next'); + feature.go('previous'); + expect(fake.term.calls).toEqual(['find:needle', 'findNext', 'findPrevious']); + }); + + test('closing ends the search and refocuses the terminal', () => { + const { feature } = setup(); + feature.open(); + feature.close(); + expect(feature.isOpen).toBe(false); + expect(fake.term.calls).toEqual(['endFind', 'focus']); + }); + + test('the field reads the find keys through the lookup it was given', () => { + const { feature, looked, input } = setup(); + feature.open(); + input().dispatchEvent(new fake.dom.window.KeyboardEvent('keydown', { code: 'Escape' })); + expect(looked).toEqual(['Escape']); + expect(feature.isOpen).toBe(false); + }); + + test('shows the terminal’s match count', () => { + const { bar } = setup(); + fake.term.fire('onFindChange', { total: 5, selected: 1 }); + expect(bar()?.querySelector('.find-bar-count')?.textContent).toBe('2/5'); + }); + + test('dispose removes the bar and the subscription; twice is a no-op', () => { + const { feature, bar } = setup(); + feature.dispose(); + feature.dispose(); + expect(bar()).toBeNull(); + fake.term.fire('onFindChange', { total: 1, selected: 0 }); + }); +}); diff --git a/src/client/features/find/find-feature.ts b/src/client/features/find/find-feature.ts new file mode 100644 index 0000000..99ddba1 --- /dev/null +++ b/src/client/features/find/find-feature.ts @@ -0,0 +1,63 @@ +// Find in the scrollback with libghostty-vt's search (feature, ADR 055, ADR 060): the find +// bar, appended to the body outside the terminal's container so its keys are its own, and +// fed the terminal's match count. Closing it ends the search and refocuses the terminal. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { DisposableStore } from '../../vendor/ghostty-web'; +import { FindBar } from './find-bar'; +import type { ShortcutActionOf } from './find-keys'; + +/** What the find feature needs besides the context. */ +export interface FindDeps { + /** The keyboard feature's shortcut lookup, so the field knows the find keys (ADR 055). */ + shortcutActionOf: ShortcutActionOf; + setTimeout(handler: () => void, ms: number): number; + clearTimeout(id: number): void; +} + +/** The find bar as the key dispatcher drives it. */ +export interface FindFeature extends Feature { + /** Whether the bar is showing; the next, previous and close keys are the app's otherwise. */ + readonly isOpen: boolean; + open(): void; + close(): void; + go(direction: 'next' | 'previous'): void; +} + +/** Builds the find bar in the body and subscribes it to `onFindChange`. */ +export function installFind(ctx: ClientContext, deps: FindDeps): FindFeature { + const { term, doc, terminalTheme } = ctx; + const store = new DisposableStore(); + const bar = store.add( + new FindBar( + doc.body, + terminalTheme, + deps.shortcutActionOf, + { + find: (needle) => term.find(needle), + next: () => term.findNext(), + previous: () => term.findPrevious(), + close: () => { + term.endFind(); + term.focus(); + }, + }, + { + document: doc, + setTimeout: (run, ms) => deps.setTimeout(run, ms), + // The handle is the one deps.setTimeout returned. + clearTimeout: (handle) => deps.clearTimeout(handle as number), + }, + ), + ); + store.add(term.onFindChange((state) => bar.update(state))); + return { + get isOpen() { + return bar.isOpen; + }, + open: () => bar.open(), + close: () => bar.close(), + go: (direction) => bar.go(direction), + dispose: () => store.dispose(), + }; +} diff --git a/src/client/features/keyboard/font-zoom-feature.test.ts b/src/client/features/keyboard/font-zoom-feature.test.ts new file mode 100644 index 0000000..1c5ecf1 --- /dev/null +++ b/src/client/features/keyboard/font-zoom-feature.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { installFontZoom } from './font-zoom-feature'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** Font zoom on a fake window, with its fits counted. */ +function setup(fontSize = 14) { + fake = fakeContext({ fontSize }); + let fits = 0; + const feature = installFontZoom(fake.ctx, { fit: () => fits++ }); + /** Dispatches a keydown of `code` with Ctrl held; returns the event calls it made. */ + const press = (code: string, mods: Partial<KeyboardEventInit> = {}) => { + const calls: string[] = []; + const event = { + type: 'keydown', + code, + ctrlKey: true, + metaKey: false, + shiftKey: false, + ...mods, + preventDefault: () => calls.push('preventDefault'), + stopPropagation: () => calls.push('stopPropagation'), + }; + fake.win.dispatch(event); + return calls; + }; + return { feature, press, fits: () => fits, size: () => fake.term.options.fontSize }; +} + +describe('installFontZoom', () => { + test('adds one window capture keydown listener', () => { + setup(); + expect(fake.win.added.map(({ target, type, capture }) => ({ target, type, capture }))).toEqual([ + { target: 'window', type: 'keydown', capture: true }, + ]); + expect(fake.term.subscriptions).toEqual([]); + }); + + test('zooms in, out and back, fitting at once, and consumes the key (ADR 033)', () => { + const { press, fits, size } = setup(); + expect(press('Equal')).toEqual(['preventDefault', 'stopPropagation']); + expect(size()).toBe(15); + press('NumpadAdd', { ctrlKey: false, metaKey: true }); + expect(size()).toBe(16); + press('Minus'); + press('Minus'); + press('Minus'); + expect(size()).toBe(13); + press('Digit0'); + expect(size()).toBe(14); + expect(fits()).toBe(6); + }); + + test('other keys are left alone', () => { + const { press, fits, size } = setup(); + expect(press('KeyA')).toEqual([]); + expect(press('Equal', { ctrlKey: false })).toEqual([]); + expect(size()).toBeUndefined(); + expect(fits()).toBe(0); + }); + + test('a configured size out of range starts clamped, so the first step goes the right way', () => { + const { press, size } = setup(40); + press('Minus'); + expect(size()).toBe(31); + press('Digit0'); + expect(size()).toBe(32); + }); + + test('dispose removes the listener; twice is a no-op', () => { + const { feature, press, fits } = setup(); + feature.dispose(); + feature.dispose(); + expect(fake.win.active).toEqual([]); + expect(fake.win.removed).toHaveLength(1); + press('Equal'); + expect(fits()).toBe(0); + }); +}); diff --git a/src/client/features/keyboard/font-zoom-feature.ts b/src/client/features/keyboard/font-zoom-feature.ts new file mode 100644 index 0000000..3a98629 --- /dev/null +++ b/src/client/features/keyboard/font-zoom-feature.ts @@ -0,0 +1,35 @@ +// Font zoom on Ctrl/Cmd with +, - or 0 (feature, ADR 033, ADR 060). A window capture +// listener, so it works whatever has focus; preventDefault stops the browser's page zoom +// and stopPropagation keeps the key from reaching the terminal as input. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import { clampFontSize, zoomedFontSize, zoomStepFor } from './font-zoom'; + +/** What font zoom needs besides the context. */ +export interface FontZoomDeps { + /** Fits the grid at once, so the new size never paints at the old grid (ADR 033). */ + fit(): void; +} + +/** The installed font zoom; nothing to call, only to dispose. */ +export type FontZoomFeature = Feature; + +/** Adds the window capture `keydown` listener that zooms from the configured font size. */ +export function installFontZoom(ctx: ClientContext, deps: FontZoomDeps): FontZoomFeature { + const { term, config, win } = ctx; + const store = new DisposableStore(); + let fontSize = clampFontSize(config.fontSize); + const onKeyDown = (e: KeyboardEvent): void => { + const step = zoomStepFor(e); + if (!step) return; + e.preventDefault(); + e.stopPropagation(); + fontSize = zoomedFontSize(step, fontSize, config.fontSize); + term.options.fontSize = fontSize; + deps.fit(); + }; + // ClientWindow is a plain listener target, so its events arrive untyped. + store.add(listen(win, 'keydown', (e) => onKeyDown(e as KeyboardEvent), { capture: true })); + return { dispose: () => store.dispose() }; +} diff --git a/src/client/features/keyboard/font-zoom.test.ts b/src/client/features/keyboard/font-zoom.test.ts new file mode 100644 index 0000000..db0c47f --- /dev/null +++ b/src/client/features/keyboard/font-zoom.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test'; +import { + clampFontSize, + type FontZoomStep, + MAX_FONT_SIZE, + MIN_FONT_SIZE, + type ZoomKeyEvent, + zoomedFontSize, + zoomStepFor, +} from './font-zoom'; + +/** A keydown of `code` with Ctrl held unless the mods say otherwise. */ +function key(code: string, mods: Partial<ZoomKeyEvent> = {}): ZoomKeyEvent { + return { code, ctrlKey: true, metaKey: false, shiftKey: false, ...mods }; +} + +describe('clampFontSize', () => { + test.each([ + [14, 14], + [MIN_FONT_SIZE, 6], + [MAX_FONT_SIZE, 32], + [2, 6], + [72, 32], + ])('%d → %d', (size, expected) => { + expect(clampFontSize(size)).toBe(expected); + }); +}); + +describe('zoomStepFor', () => { + test.each<[string, ZoomKeyEvent, FontZoomStep | null]>([ + ['Ctrl+=', key('Equal'), 'in'], + ['Ctrl++ (Shift+=)', key('Equal', { shiftKey: true }), 'in'], + ['Cmd+=', key('Equal', { ctrlKey: false, metaKey: true }), 'in'], + ['Ctrl+numpad +', key('NumpadAdd'), 'in'], + ['Ctrl+-', key('Minus'), 'out'], + ['Ctrl+_ (Shift+-)', key('Minus', { shiftKey: true }), null], + ['Ctrl+numpad -', key('NumpadSubtract'), 'out'], + ['Ctrl+numpad - with Shift', key('NumpadSubtract', { shiftKey: true }), 'out'], + ['Ctrl+0', key('Digit0'), 'reset'], + ['Ctrl+) (Shift+0)', key('Digit0', { shiftKey: true }), null], + ['Ctrl+numpad 0', key('Numpad0'), 'reset'], + ['= without Ctrl or Cmd', key('Equal', { ctrlKey: false }), null], + ['Ctrl+another key', key('KeyA'), null], + ])('%s', (_name, e, expected) => { + expect(zoomStepFor(e)).toBe(expected); + }); +}); + +describe('zoomedFontSize', () => { + test.each<[FontZoomStep, number, number, number]>([ + ['in', 14, 14, 15], + ['in', 32, 14, 32], + ['out', 14, 14, 13], + ['out', 6, 14, 6], + ['reset', 20, 14, 14], + ['reset', 20, 2, 6], + ['reset', 20, 72, 32], + ])('%s from %d (configured %d) → %d', (step, current, configured, expected) => { + expect(zoomedFontSize(step, current, configured)).toBe(expected); + }); +}); diff --git a/src/client/features/keyboard/font-zoom.ts b/src/client/features/keyboard/font-zoom.ts new file mode 100644 index 0000000..259d8fb --- /dev/null +++ b/src/client/features/keyboard/font-zoom.ts @@ -0,0 +1,41 @@ +// The font zoom keys and the size each one sets (util, pure, ADR 060): Ctrl/Cmd with +, - +// or 0, as VS Code binds them, within the range a terminal cell stays usable in. + +/** The smallest font size zoom goes to. */ +export const MIN_FONT_SIZE = 6; +/** The largest font size zoom goes to. */ +export const MAX_FONT_SIZE = 32; + +/** What a zoom key does. */ +export type FontZoomStep = 'in' | 'out' | 'reset'; + +/** The key fields a zoom key is read from. */ +export type ZoomKeyEvent = Pick<KeyboardEvent, 'code' | 'ctrlKey' | 'metaKey' | 'shiftKey'>; + +/** `size` within [MIN_FONT_SIZE, MAX_FONT_SIZE]. */ +export function clampFontSize(size: number): number { + return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size)); +} + +/** + * The zoom step a keydown is, if any. `e.code` is the physical key, so the keys work on + * any layout; + needs no Shift, and Shift with - or 0 (`_`, `)`) is not a zoom. + */ +export function zoomStepFor(e: ZoomKeyEvent): FontZoomStep | null { + if (!e.ctrlKey && !e.metaKey) return null; + if (e.code === 'Equal' || e.code === 'NumpadAdd') return 'in'; + if ((e.code === 'Minus' && !e.shiftKey) || e.code === 'NumpadSubtract') return 'out'; + if ((e.code === 'Digit0' && !e.shiftKey) || e.code === 'Numpad0') return 'reset'; + return null; +} + +/** + * The size after `step`, one point at a time; reset goes back to the configured size. + * The configured size is clamped too, so one outside the range never inverts the + * direction of the first step. + */ +export function zoomedFontSize(step: FontZoomStep, current: number, configured: number): number { + if (step === 'in') return Math.min(MAX_FONT_SIZE, current + 1); + if (step === 'out') return Math.max(MIN_FONT_SIZE, current - 1); + return clampFontSize(configured); +} diff --git a/src/client/features/keyboard/key-bindings.test.ts b/src/client/features/keyboard/key-bindings.test.ts new file mode 100644 index 0000000..61176d3 --- /dev/null +++ b/src/client/features/keyboard/key-bindings.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; +import type { KeyboardBinding } from '../../app/client-config'; +import { type BindingKeyEvent, findBinding } from './key-bindings'; + +/** A keydown with no modifiers unless given. */ +function key(k: string, mods: Partial<BindingKeyEvent> = {}): BindingKeyEvent { + return { key: k, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, ...mods }; +} + +const ctrlK: KeyboardBinding = { key: 'k', mods: ['ctrl'], chars: '\x0b' }; +const shiftEnter: KeyboardBinding = { key: 'Enter', mods: ['Shift'], chars: '\x1b\r' }; +const allMods: KeyboardBinding = { key: 'x', mods: ['meta', 'alt', 'ctrl', 'shift'], chars: 'x' }; +const bare: KeyboardBinding = { key: 'F1', mods: [], chars: '' }; +const noMods = { key: 'F2', chars: 'f2' } as KeyboardBinding; +const bindings = [ctrlK, shiftEnter, allMods, bare, noMods]; + +describe('findBinding', () => { + test.each([ + ['exact modifiers', key('k', { ctrlKey: true }), ctrlK], + ['an upper-case key', key('K', { ctrlKey: true }), ctrlK], + ['upper-case modifiers in the config', key('enter', { shiftKey: true }), shiftEnter], + [ + 'every modifier, in any order', + key('x', { shiftKey: true, ctrlKey: true, altKey: true, metaKey: true }), + allMods, + ], + ['an empty binding (it claims the key)', key('F1'), bare], + ['a binding without mods', key('F2'), noMods], + ])('matches %s', (_name, e, expected) => { + expect(findBinding(e, bindings)).toBe(expected); + }); + + test.each([ + ['an extra modifier', key('k', { ctrlKey: true, shiftKey: true })], + ['a missing modifier', key('k')], + ['a different modifier', key('k', { altKey: true })], + ['another key', key('j', { ctrlKey: true })], + ['one modifier short of all', key('x', { shiftKey: true, ctrlKey: true, altKey: true })], + ])('does not match %s', (_name, e) => { + expect(findBinding(e, bindings)).toBeUndefined(); + }); + + test('the first matching binding wins', () => { + const second: KeyboardBinding = { ...ctrlK, chars: 'second' }; + expect(findBinding(key('k', { ctrlKey: true }), [ctrlK, second])).toBe(ctrlK); + }); + + test('no bindings, no match', () => { + expect(findBinding(key('k', { ctrlKey: true }), [])).toBeUndefined(); + }); +}); diff --git a/src/client/features/keyboard/key-bindings.ts b/src/client/features/keyboard/key-bindings.ts new file mode 100644 index 0000000..a4b2a87 --- /dev/null +++ b/src/client/features/keyboard/key-bindings.ts @@ -0,0 +1,35 @@ +// Matches a keydown against the configured key bindings (util, pure, ADR 018, ADR 060): +// keys compare case-insensitively and modifiers must match exactly, so `ctrl+k` does not +// fire for Ctrl+Shift+K. + +import type { KeyboardBinding } from '../../app/client-config'; + +/** The key fields a binding is matched on. */ +export type BindingKeyEvent = Pick< + KeyboardEvent, + 'key' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey' +>; + +/** + * The first configured binding a keydown is, if any. A binding without chars still + * matches: it claims the combo, so neither the terminal nor a shortcut sees it. + */ +export function findBinding( + e: BindingKeyEvent, + bindings: readonly KeyboardBinding[], +): KeyboardBinding | undefined { + const key = e.key.toLowerCase(); + const active = new Set([ + ...(e.shiftKey ? ['shift'] : []), + ...(e.ctrlKey ? ['ctrl'] : []), + ...(e.altKey ? ['alt'] : []), + ...(e.metaKey ? ['meta'] : []), + ]); + return bindings.find((b) => { + if (b.key.toLowerCase() !== key) return false; + const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); + if (required.size !== active.size) return false; + for (const m of required) if (!active.has(m)) return false; + return true; + }); +} diff --git a/src/client/features/paste/paste-feature.test.ts b/src/client/features/paste/paste-feature.test.ts new file mode 100644 index 0000000..831ac25 --- /dev/null +++ b/src/client/features/paste/paste-feature.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { recordListeners } from '../../test-support/recording-target'; +import { installPaste } from './paste-feature'; +import { PasteService } from './paste-service'; + +let fake: FakeContext; +afterEach(() => fake.dom.close()); + +/** The feature on a context whose container's listeners are recorded; the socket is open. */ +function setup() { + fake = fakeContext(); + const container = recordListeners(fake.container, 'container'); + const sent: string[] = []; + let reads = 0; + const paste = new PasteService( + fake.term, + { isOpen: () => true, send: (data) => sent.push(data) }, + { + clipboard: { + readText: async () => { + reads++; + return 'clip'; + }, + }, + }, + ); + const feature = installPaste(fake.ctx, { paste }); + // A later listener on the same node, which stopImmediatePropagation keeps from running + let reachedLater = false; + fake.container.addEventListener('paste', () => (reachedLater = true), true); + /** Dispatches a real `paste` event holding `text` (null: no clipboard data). */ + const pasteEvent = (text: string | null) => { + const { window } = fake.dom; + let clipboardData: DataTransfer | null = null; + if (text !== null) { + clipboardData = new window.DataTransfer(); + clipboardData.setData('text/plain', text); + } + reachedLater = false; + const event = new window.ClipboardEvent('paste', { clipboardData, cancelable: true }); + fake.container.dispatchEvent(event); + return { prevented: event.defaultPrevented, stopped: !reachedLater }; + }; + return { container, sent, feature, pasteEvent, reads: () => reads }; +} + +describe('installPaste', () => { + test('subscribes to onPasteRequest, then adds the container capture paste listener', () => { + const { container } = setup(); + expect(fake.term.subscriptions).toEqual(['onPasteRequest']); + expect(container.added.map(({ target, type, capture }) => ({ target, type, capture }))).toEqual( + [ + { target: 'container', type: 'paste', capture: true }, + // the test's own later listener + { target: 'container', type: 'paste', capture: true }, + ], + ); + expect(fake.win.added).toEqual([]); + }); + + test('the right click set to paste reads the clipboard (ADR 051)', async () => { + const { reads } = setup(); + fake.term.fire('onPasteRequest'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(reads()).toBe(1); + expect(fake.term.calls).toEqual(['paste:clip']); + }); + + test('a paste event is consumed and its text goes through the terminal', () => { + const { pasteEvent } = setup(); + expect(pasteEvent('hi')).toEqual({ prevented: true, stopped: true }); + expect(fake.term.calls).toEqual(['paste:hi']); + }); + + test('a paste event with no text sends Ctrl+V (ADR 037)', () => { + const { pasteEvent, sent } = setup(); + pasteEvent(''); + expect(sent).toEqual(['\x16']); + expect(fake.term.calls).toEqual(['scrollToBottom']); + }); + + test('a paste event without clipboard data is left alone', () => { + const { pasteEvent } = setup(); + expect(pasteEvent(null)).toEqual({ prevented: false, stopped: false }); + expect(fake.term.calls).toEqual([]); + }); + + test('dispose removes the listener and the subscription; twice is a no-op', async () => { + const { container, feature, reads, pasteEvent } = setup(); + feature.dispose(); + feature.dispose(); + expect(container.active).toHaveLength(1); + expect(container.removed).toHaveLength(1); + expect(pasteEvent('after')).toEqual({ prevented: false, stopped: false }); + fake.term.fire('onPasteRequest'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(reads()).toBe(0); + }); +}); diff --git a/src/client/features/paste/paste-feature.ts b/src/client/features/paste/paste-feature.ts new file mode 100644 index 0000000..e16a546 --- /dev/null +++ b/src/client/features/paste/paste-feature.ts @@ -0,0 +1,44 @@ +// Wires the paste gestures to the paste service (feature, ADR 014, ADR 051, ADR 060): the +// right click set to paste, and the browser's `paste` event on the terminal. The paste +// keys go through the key dispatcher, which calls the same service. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import type { PasteService } from './paste-service'; + +/** What the paste feature needs besides the context. */ +export interface PasteDeps { + /** Built by the root before the key dispatcher, which pastes through it too. */ + paste: PasteService; +} + +/** The installed paste gestures; nothing to call, only to dispose. */ +export type PasteFeature = Feature; + +/** Subscribes to `onPasteRequest`, then adds the container capture `paste` listener. */ +export function installPaste(ctx: ClientContext, deps: PasteDeps): PasteFeature { + const { term, container } = ctx; + const { paste } = deps; + const store = new DisposableStore(); + // rightClickBehavior "paste" (ADR 051) + store.add( + term.onPasteRequest(() => { + paste.pasteFromClipboard(); + }), + ); + store.add( + listen( + container, + 'paste', + (e: ClipboardEvent) => { + const cd = e.clipboardData; + if (!cd) return; + e.preventDefault(); + e.stopImmediatePropagation(); + paste.paste(cd.getData('text/plain')); + }, + { capture: true }, + ), + ); + return { dispose: () => store.dispose() }; +} diff --git a/src/client/features/paste/paste-service.test.ts b/src/client/features/paste/paste-service.test.ts new file mode 100644 index 0000000..8e79f0e --- /dev/null +++ b/src/client/features/paste/paste-service.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { type ClipboardSource, PasteService } from './paste-service'; + +/** A service on logging fakes; the socket is open and the clipboard holds `clip` unless told. */ +function setup(opts: { open?: boolean; navigator?: ClipboardSource } = {}) { + const log: string[] = []; + let open = opts.open ?? true; + const service = new PasteService( + { + paste: (text) => log.push(`paste:${text}`), + scrollToBottom: () => log.push('scrollToBottom'), + }, + { isOpen: () => open, send: (data) => log.push(`send:${JSON.stringify(data)}`) }, + opts.navigator ?? { clipboard: { readText: async () => 'clip' } }, + ); + return { log, service, close: () => (open = false) }; +} + +/** Lets a clipboard read's promise and its handler run. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('PasteService.paste', () => { + test('text goes through the terminal, which encodes it (ADR 049)', () => { + const { log, service } = setup(); + service.paste('one\ntwo'); + expect(log).toEqual(['paste:one\ntwo']); + }); + + test('a non-text paste (empty text) scrolls to the bottom and sends Ctrl+V (ADR 037)', () => { + const { log, service } = setup(); + service.paste(''); + expect(log).toEqual(['scrollToBottom', 'send:"\\u0016"']); + }); + + test.each(['text', ''])('a closed socket sends nothing (%p)', (text) => { + const { log, service } = setup({ open: false }); + service.paste(text); + expect(log).toEqual([]); + }); +}); + +describe('PasteService.pasteFromClipboard', () => { + test('reads the clipboard and pastes its text', async () => { + const { log, service } = setup(); + expect(service.pasteFromClipboard()).toBe(true); + await settle(); + expect(log).toEqual(['paste:clip']); + }); + + test('a failed read sends Ctrl+V, as Cmd+V would reach the app (ADR 014)', async () => { + const { log, service } = setup({ + navigator: { clipboard: { readText: () => Promise.reject(new Error('denied')) } }, + }); + expect(service.pasteFromClipboard()).toBe(true); + await settle(); + expect(log).toEqual(['scrollToBottom', 'send:"\\u0016"']); + }); + + test('a failed read after the socket closed sends nothing', async () => { + const { log, service, close } = setup({ + navigator: { clipboard: { readText: () => Promise.reject(new Error('denied')) } }, + }); + service.pasteFromClipboard(); + close(); + await settle(); + expect(log).toEqual([]); + }); + + test('a read that lands after the socket closed sends nothing', async () => { + const { log, service, close } = setup(); + service.pasteFromClipboard(); + close(); + await settle(); + expect(log).toEqual([]); + }); + + test.each<[string, ClipboardSource]>([ + ['no clipboard API (http)', {}], + ['a clipboard without readText', { clipboard: {} as Pick<Clipboard, 'readText'> }], + ])('returns false with %s and reads nothing', (_name, navigator) => { + const { log, service } = setup({ navigator }); + expect(service.pasteFromClipboard()).toBe(false); + expect(log).toEqual([]); + }); + + test('reads navigator.clipboard at each paste, not once', async () => { + const navigator: { clipboard?: Pick<Clipboard, 'readText'> } = {}; + const { log, service } = setup({ navigator }); + expect(service.pasteFromClipboard()).toBe(false); + navigator.clipboard = { readText: async () => 'later' }; + expect(service.pasteFromClipboard()).toBe(true); + await settle(); + expect(log).toEqual(['paste:later']); + }); +}); diff --git a/src/client/features/paste/paste-service.ts b/src/client/features/paste/paste-service.ts new file mode 100644 index 0000000..88d1ac2 --- /dev/null +++ b/src/client/features/paste/paste-service.ts @@ -0,0 +1,73 @@ +// Sends pastes to the PTY (service, ADR 014, ADR 037, ADR 049, ADR 060): text through the +// terminal, which encodes it as Ghostty does, a non-text paste as Ctrl+V, and a clipboard +// read for the keys and the right click that paste without a browser `paste` event. + +import { nonTextPaste } from './non-text-paste'; + +/** The terminal as a paste reaches it. */ +export interface PasteTerminal { + /** Encodes and sends text: bracketed under mode 2004, control bytes stripped (ADR 049). */ + paste(text: string): void; + scrollToBottom(): void; +} + +/** The PTY socket as the paste service writes to it. */ +export interface PasteSink { + isOpen(): boolean; + send(data: string): void; +} + +/** The navigator as the paste service reads it; `clipboard` is read at each paste. */ +export interface ClipboardSource { + readonly clipboard?: Pick<Clipboard, 'readText'>; +} + +/** Ctrl+V, the key a non-text paste and a failed clipboard read send. */ +const CTRL_V = '\x16'; + +/** Routes each paste to the terminal or the socket; nothing is sent while the socket is down. */ +export class PasteService { + constructor( + private readonly term: PasteTerminal, + private readonly connection: PasteSink, + private readonly navigator: ClipboardSource, + ) {} + + /** + * Pastes `text`. Empty text is a non-text paste (an image): the receiving TUI may read + * the native clipboard on Ctrl+V. Ctrl+V is never a text fallback, since remote TUIs + * can read it as an image-paste shortcut (ADR 037). + */ + paste(text: string): void { + if (!this.connection.isOpen()) return; + const ctrlV = nonTextPaste(text); + if (ctrlV) { + this.term.scrollToBottom(); + this.connection.send(ctrlV); + } else this.term.paste(text); + } + + /** + * Reads the clipboard and pastes it, as Cmd+V does; a failed read sends Ctrl+V. + * Returns false when the browser cannot read the clipboard. + */ + pasteFromClipboard(): boolean { + const clipboard = this.navigator.clipboard; + if (!clipboard?.readText) return false; + clipboard.readText().then( + (text) => this.paste(text), + () => this.sendCtrlV(), + ); + return true; + } + + /** + * ghostty-web swallows Ctrl+V without sending it to the PTY (unlike xterm.js) and relies + * on the browser `paste` event, which on macOS fires only for Cmd+V (ADR 014). + */ + private sendCtrlV(): void { + if (!this.connection.isOpen()) return; + this.term.scrollToBottom(); + this.connection.send(CTRL_V); + } +} diff --git a/src/client/index.ts b/src/client/index.ts index f8d6f3b..2d2546b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,5 @@ import { browserEnv } from './app/browser-env'; -import { type KeyboardBinding, loadClientConfig } from './app/client-config'; +import { loadClientConfig } from './app/client-config'; import type { ClientContext } from './app/client-context'; import { detectMac } from './app/platform'; import { toTerminalOptions } from './app/terminal-options'; @@ -9,12 +9,15 @@ import { PtyConnection } from './connection/pty-connection'; import { ReplayWindow } from './connection/replay-window'; import { installClipboard } from './features/clipboard/clipboard-feature'; import { Composer } from './features/compose/composer'; -import { FindBar } from './features/find/find-bar'; +import { installFind } from './features/find/find-feature'; +import { installFontZoom } from './features/keyboard/font-zoom-feature'; +import { findBinding } from './features/keyboard/key-bindings'; import { shortcutFor } from './features/keyboard/shortcut-map'; import { KittyGraphics } from './features/kitty/graphics/kitty-graphics'; import { browserNotifications } from './features/notifications/browser-notifications'; import { installNotifications } from './features/notifications/notifications-feature'; -import { nonTextPaste } from './features/paste/non-text-paste'; +import { installPaste } from './features/paste/paste-feature'; +import { PasteService } from './features/paste/paste-service'; import { SmoothScroll } from './features/scroll/smooth-scroll'; import { WheelStream } from './features/scroll/wheel-stream'; import { TabTitle } from './features/tab-title/tab-title'; @@ -148,33 +151,12 @@ term.attachCustomWheelEventHandler( term.attachWheelPace((e: WheelEvent) => wheelStream.forApp(e)); term.attachBeforeRender((now: number) => smoothScroll.frame(now)); -// Shared by the configured-binding listener below and the Ctrl+V paste -// listener further down, so the latter can tell whether a user has already -// claimed the combo (including an intentional no-op binding meant to -// suppress default behavior) before adding its own handling on top. -function findBinding(e: KeyboardEvent): KeyboardBinding | undefined { - const key = e.key.toLowerCase(); - const active = new Set([ - ...(e.shiftKey ? ['shift'] : []), - ...(e.ctrlKey ? ['ctrl'] : []), - ...(e.altKey ? ['alt'] : []), - ...(e.metaKey ? ['meta'] : []), - ]); - return config.keyboardBindings.find((b) => { - if (b.key.toLowerCase() !== key) return false; - const required = new Set((Array.isArray(b.mods) ? b.mods : []).map((m) => m.toLowerCase())); - if (required.size !== active.size) return false; - for (const m of required) if (!active.has(m)) return false; - return true; - }); -} - // Intercept configured key+mods combos before ghostty-web sees them and send // the bound chars directly to the PTY. See ADR 018. container.addEventListener( 'keydown', (e: KeyboardEvent) => { - const binding = findBinding(e); + const binding = findBinding(e, config.keyboardBindings); if (!binding) return; e.preventDefault(); e.stopPropagation(); @@ -187,32 +169,8 @@ container.addEventListener( { capture: true }, ); -// Intercept Ctrl/Cmd +/- to resize the font without Shift, matching VS Code. -// Uses window so it fires regardless of focus, and preventDefault stops the -// browser's own page-zoom from triggering at the same time. stopPropagation -// prevents ghostty-web from forwarding the key as literal PTY input. -// e.code is used for physical key identity, independent of keyboard layout. -// currentFontSize is clamped to [6, 32] on init so a config value outside -// that range never inverts the zoom direction on the first keypress. -let currentFontSize = Math.min(32, Math.max(6, config.fontSize)); -window.addEventListener( - 'keydown', - (e: KeyboardEvent) => { - if (!e.ctrlKey && !e.metaKey) return; - const zoomIn = e.code === 'Equal' || e.code === 'NumpadAdd'; - const zoomOut = (e.code === 'Minus' && !e.shiftKey) || e.code === 'NumpadSubtract'; - const zoomReset = (e.code === 'Digit0' && !e.shiftKey) || e.code === 'Numpad0'; - if (!zoomIn && !zoomOut && !zoomReset) return; - e.preventDefault(); - e.stopPropagation(); - if (zoomIn) currentFontSize = Math.min(32, currentFontSize + 1); - else if (zoomOut) currentFontSize = Math.max(6, currentFontSize - 1); - else currentFontSize = Math.min(32, Math.max(6, config.fontSize)); - term.options.fontSize = currentFontSize; - layout.fit(); - }, - { capture: true }, -); +// Font zoom: the last window capture keydown listener, fitting at once (ADR 033). +installFontZoom(ctx, { fit: () => layout.fit() }); // Forward terminal keystrokes and input to the PTY over WebSocket. term.onData((data: string) => connection.send(data)); @@ -222,45 +180,12 @@ term.onResize(({ cols, rows }: { cols: number; rows: number }) => connection.sendResize(cols, rows), ); -// ghostty-web swallows Ctrl+V without sending \x16 to the PTY (unlike -// xterm.js), relying entirely on the browser 'paste' event. On macOS that -// event only fires for Cmd+V (the OS paste gesture) - Ctrl+V produces no -// event at all. See ADR 014. -function sendCtrlV(): void { - if (!connection.isOpen()) return; - term.scrollToBottom(); - connection.send('\x16'); -} - -// Text goes through the terminal, which encodes it as Ghostty does: bracketed -// while mode 2004 is on, control bytes stripped, and without 2004 newlines sent -// as CR (they may act as Enter). Ctrl+V is not a text fallback (remote TUIs can -// interpret it as an image-paste shortcut). See ADR 037 and ADR 049. -function sendPaste(text: string): void { - if (!connection.isOpen()) return; - const ctrlV = nonTextPaste(text); - if (ctrlV) { - term.scrollToBottom(); - connection.send(ctrlV); - } else term.paste(text); -} - -// Reads the clipboard and pastes it, as Cmd+V does; a failed read sends Ctrl+V. -// Returns false when the browser cannot read the clipboard. -function pasteFromClipboard(): boolean { - if (!navigator.clipboard?.readText) return false; - navigator.clipboard.readText().then(sendPaste, sendCtrlV); - return true; -} - -// rightClickBehavior "paste" (ADR 051) -term.onPasteRequest(() => { - pasteFromClipboard(); -}); +// Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, ADR 049). +const paste = new PasteService(term, connection, env.navigator); // Copy, paste, clear-screen, find and jump-to-prompt keys as Ghostty binds them for this // platform (ADR 054, ADR 055, ADR 057). -// Ctrl+V never reaches us as a 'paste' event on macOS (see above), so the clipboard is +// Ctrl+V never reaches us as a 'paste' event on macOS (ADR 014), so the clipboard is // read on keydown to make it behave like Cmd+V. Both keydown listeners are capture // listeners on `container`, so they run in registration order - this one runs after the // configured-binding listener above, so skip a combo the user has already bound @@ -271,15 +196,15 @@ container.addEventListener( 'keydown', (e: KeyboardEvent) => { const shortcut = shortcutFor(e, mac); - if (!shortcut || findBinding(e)) return; + if (!shortcut || findBinding(e, config.keyboardBindings)) return; if (shortcut.action.startsWith('find')) { // Escape, closing and moving are the app's while the bar is closed (ADR 055) - if (shortcut.action !== 'find' && !findBar.isOpen) return; + if (shortcut.action !== 'find' && !find.isOpen) return; e.preventDefault(); e.stopImmediatePropagation(); - if (shortcut.action === 'find') findBar.open(); - else if (shortcut.action === 'findClose') findBar.close(); - else findBar.go(shortcut.action === 'findNext' ? 'next' : 'previous'); + if (shortcut.action === 'find') find.open(); + else if (shortcut.action === 'findClose') find.close(); + else find.go(shortcut.action === 'findNext' ? 'next' : 'previous'); return; } if (shortcut.action === 'promptPrevious' || shortcut.action === 'promptNext') { @@ -291,7 +216,7 @@ container.addEventListener( } if (shortcut.action === 'paste') { // Without the clipboard API the browser's own paste may still fire 'paste' - if (pasteFromClipboard()) e.preventDefault(); + if (paste.pasteFromClipboard()) e.preventDefault(); e.stopImmediatePropagation(); return; } @@ -304,18 +229,11 @@ container.addEventListener( { capture: true }, ); -// Find in the scrollback with libghostty-vt's search (ADR 055). The bar is outside the -// container: its keys are its own. Closing it ends the search and refocuses the terminal. -const findBar = new FindBar(document.body, terminalTheme, (e) => shortcutFor(e, mac)?.action, { - find: (needle) => term.find(needle), - next: () => term.findNext(), - previous: () => term.findPrevious(), - close: () => { - term.endFind(); - term.focus(); - }, +// Find in the scrollback (ADR 055); the shortcut listener above reads it only on a key. +const find = installFind(ctx, { + ...env, + shortcutActionOf: (e) => shortcutFor(e, mac)?.action, }); -term.onFindChange((state) => findBar.update(state)); // Cmd+K: nothing on the alternate screen; the clear is this tab's own, and a reload // replays the session's output again (ADR 054) @@ -326,14 +244,5 @@ function clearScreen(): boolean { return true; } -container.addEventListener( - 'paste', - (e: ClipboardEvent) => { - const cd = e.clipboardData; - if (!cd) return; - e.preventDefault(); - e.stopImmediatePropagation(); - sendPaste(cd.getData('text/plain')); - }, - { capture: true }, -); +// The right click set to paste, then the container capture paste listener (ADR 051). +installPaste(ctx, { paste }); From 717b162b916d563ec9c19922e062d23ef27f6cc4 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:37:01 -0400 Subject: [PATCH 207/295] refactor(client): one key dispatcher for configured bindings and Ghostty's shortcuts The binding listener and the shortcut listener become one container capture keydown listener (features/keyboard/key-dispatcher.ts), registered where the binding listener was. A configured binding wins (preventDefault, stopPropagation, scroll to the bottom and send its chars while the socket is open); otherwise the shortcut runs through a command map (features/keyboard/shortcut-commands.ts), one handler per action, each settling its key with today's preventDefault / stopImmediatePropagation calls in today's order. Equivalent: stopPropagation never stopped the second listener on the same node, which is why it re-checked the binding, and the listeners registered between the two were on other targets. The find bar is now built before the dispatcher (SPEC step 9): it is the only body append and the only onFindChange subscriber. The PasteService is built there too; its listeners stay last. Font zoom remains the last window capture keydown listener. key-dispatcher.test.ts runs a verbatim copy of the old pair and the dispatcher on 20 keys in all 64 states (platform, socket, find bar, selection or clear done, clipboard API, bindings) and compares every call, the event's included. FakeTerminal gains jumpToPrompt and copySelection. --- .../features/keyboard/key-dispatcher.test.ts | 336 ++++++++++++++++++ .../features/keyboard/key-dispatcher.ts | 115 ++++++ .../keyboard/shortcut-commands.test.ts | 105 ++++++ .../features/keyboard/shortcut-commands.ts | 88 +++++ src/client/index.ts | 115 ++---- src/client/test-support/fake-terminal.ts | 12 + 6 files changed, 689 insertions(+), 82 deletions(-) create mode 100644 src/client/features/keyboard/key-dispatcher.test.ts create mode 100644 src/client/features/keyboard/key-dispatcher.ts create mode 100644 src/client/features/keyboard/shortcut-commands.test.ts create mode 100644 src/client/features/keyboard/shortcut-commands.ts diff --git a/src/client/features/keyboard/key-dispatcher.test.ts b/src/client/features/keyboard/key-dispatcher.test.ts new file mode 100644 index 0000000..ea1d73e --- /dev/null +++ b/src/client/features/keyboard/key-dispatcher.test.ts @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { KeyboardBinding } from '../../app/client-config'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { recordListeners } from '../../test-support/recording-target'; +import { findBinding } from './key-bindings'; +import { createKeyHandler, type DispatchedKey, installKeyDispatcher } from './key-dispatcher'; +import { shortcutFor } from './shortcut-map'; + +/** The state a key meets: the platform, the socket, the find bar, the selection, the clipboard. */ +interface World { + mac: boolean; + socketOpen: boolean; + barOpen: boolean; + /** What copySelection and clearScreen return. */ + done: boolean; + clipboard: boolean; + bindings: KeyboardBinding[]; +} + +/** A keydown that logs its event calls and stops the listener chain as the DOM does. */ +interface LoggedKey extends DispatchedKey { + immediateStopped: boolean; +} + +function keydown(init: Partial<KeyboardEventInit> & { code: string }, log: string[]): LoggedKey { + const e: LoggedKey = { + key: init.key ?? init.code, + code: init.code, + shiftKey: init.shiftKey ?? false, + ctrlKey: init.ctrlKey ?? false, + altKey: init.altKey ?? false, + metaKey: init.metaKey ?? false, + immediateStopped: false, + preventDefault: () => log.push('preventDefault'), + stopPropagation: () => log.push('stopPropagation'), + stopImmediatePropagation: () => { + log.push('stopImmediatePropagation'); + e.immediateStopped = true; + }, + }; + return e; +} + +/** The handles both versions act on, logging every call to `log`. */ +function handles(w: World, log: string[]) { + const answer = (call: string, result: boolean) => () => { + log.push(call); + return result; + }; + return { + term: { + scrollToBottom: () => log.push('scrollToBottom'), + jumpToPrompt: (delta: number) => log.push(`jumpToPrompt:${delta}`), + copySelection: answer('copySelection', w.done), + }, + connection: { isOpen: () => w.socketOpen, send: (data: string) => log.push(`send:${data}`) }, + findBar: { + isOpen: w.barOpen, + open: () => log.push('find.open'), + close: () => log.push('find.close'), + go: (direction: string) => log.push(`find.go:${direction}`), + }, + clearScreen: answer('clearScreen', w.done), + pasteFromClipboard: answer('pasteFromClipboard', w.clipboard), + }; +} + +/** + * The two container capture keydown listeners index.ts had before the dispatcher (95c4413), copied + * from it with their globals made parameters; run in registration order on one node. + */ +function oldPair(w: World, log: string[], e: LoggedKey): void { + const { term, connection, findBar, clearScreen, pasteFromClipboard } = handles(w, log); + const bindingListener = () => { + const binding = findBinding(e, w.bindings); + if (!binding) return; + e.preventDefault(); + e.stopPropagation(); + if (binding.chars && connection.isOpen()) { + term.scrollToBottom(); + connection.send(binding.chars); + } + }; + const shortcutListener = () => { + const shortcut = shortcutFor(e, w.mac); + if (!shortcut || findBinding(e, w.bindings)) return; + if (shortcut.action.startsWith('find')) { + if (shortcut.action !== 'find' && !findBar.isOpen) return; + e.preventDefault(); + e.stopImmediatePropagation(); + if (shortcut.action === 'find') findBar.open(); + else if (shortcut.action === 'findClose') findBar.close(); + else findBar.go(shortcut.action === 'findNext' ? 'next' : 'previous'); + return; + } + if (shortcut.action === 'promptPrevious' || shortcut.action === 'promptNext') { + term.jumpToPrompt(shortcut.action === 'promptPrevious' ? -1 : 1); + e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + if (shortcut.action === 'paste') { + if (pasteFromClipboard()) e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + const done = shortcut.action === 'copy' ? term.copySelection() : clearScreen(); + if (!done && shortcut.performable) return; + e.preventDefault(); + e.stopImmediatePropagation(); + }; + // stopPropagation never stops a listener on the same node; stopImmediatePropagation does. + for (const listener of [bindingListener, shortcutListener]) { + if (e.immediateStopped) break; + listener(); + } +} + +function newDispatcher(w: World, log: string[], e: LoggedKey): void { + const { term, connection, findBar, clearScreen, pasteFromClipboard } = handles(w, log); + const handle = createKeyHandler( + { term, bindings: w.bindings, mac: w.mac }, + { connection, find: findBar, clearScreen, pasteFromClipboard }, + ); + handle(e); +} + +/** What one version does with `init` in `w`: its calls, in order, the event's included. */ +function run(version: typeof oldPair, w: World, init: Parameters<typeof keydown>[0]): string[] { + const log: string[] = []; + version(w, log, keydown(init, log)); + return log; +} + +const BINDINGS: KeyboardBinding[] = [ + { key: 'Enter', mods: ['shift'], chars: '\x1b\r' }, + { key: 'q', mods: ['ctrl'], chars: '' }, + // Overrides Ghostty's find shortcut on Linux and Windows + { key: 'F', mods: ['ctrl', 'shift'], chars: 'F!' }, +]; + +/** Every key the dispatcher acts on, plus ones it leaves alone. */ +const KEYS: [string, Parameters<typeof keydown>[0]][] = [ + ['a binding with chars', { code: 'Enter', key: 'Enter', shiftKey: true }], + ['an empty binding', { code: 'KeyQ', key: 'q', ctrlKey: true }], + ['a binding over a shortcut', { code: 'KeyF', key: 'F', ctrlKey: true, shiftKey: true }], + ['Escape', { code: 'Escape', key: 'Escape' }], + ['Ctrl+V', { code: 'KeyV', key: 'v', ctrlKey: true }], + ['Ctrl+Shift+V', { code: 'KeyV', key: 'V', ctrlKey: true, shiftKey: true }], + ['Shift+Insert', { code: 'Insert', key: 'Insert', shiftKey: true }], + ['Ctrl+Shift+C', { code: 'KeyC', key: 'C', ctrlKey: true, shiftKey: true }], + ['Ctrl+Insert', { code: 'Insert', key: 'Insert', ctrlKey: true }], + ['Ctrl+Shift+Up', { code: 'ArrowUp', key: 'ArrowUp', ctrlKey: true, shiftKey: true }], + ['Ctrl+Shift+Down', { code: 'ArrowDown', key: 'ArrowDown', ctrlKey: true, shiftKey: true }], + ['Cmd+K', { code: 'KeyK', key: 'k', metaKey: true }], + ['Cmd+F', { code: 'KeyF', key: 'f', metaKey: true }], + ['Cmd+Shift+F', { code: 'KeyF', key: 'F', metaKey: true, shiftKey: true }], + ['Cmd+G', { code: 'KeyG', key: 'g', metaKey: true }], + ['Cmd+Shift+G', { code: 'KeyG', key: 'G', metaKey: true, shiftKey: true }], + ['Cmd+Up', { code: 'ArrowUp', key: 'ArrowUp', metaKey: true }], + ['Cmd+Shift+Down', { code: 'ArrowDown', key: 'ArrowDown', metaKey: true, shiftKey: true }], + ['a plain letter', { code: 'KeyA', key: 'a' }], + ['Ctrl+C', { code: 'KeyC', key: 'c', ctrlKey: true }], +]; + +/** Every combination of the world's switches, with and without the bindings. */ +function worlds(): World[] { + const all: World[] = []; + for (let bits = 0; bits < 64; bits++) { + const on = (bit: number) => (bits & (1 << bit)) !== 0; + all.push({ + mac: on(0), + socketOpen: on(1), + barOpen: on(2), + done: on(3), + clipboard: on(4), + bindings: on(5) ? BINDINGS : [], + }); + } + return all; +} + +describe('createKeyHandler is equivalent to the old binding and shortcut listener pair', () => { + test.each(KEYS)('%s, in every state', (_name, init) => { + for (const w of worlds()) { + expect({ w, calls: run(newDispatcher, w, init) }).toEqual({ + w, + calls: run(oldPair, w, init), + }); + } + }); +}); + +describe('createKeyHandler', () => { + const world: World = { + mac: false, + socketOpen: true, + barOpen: false, + done: false, + clipboard: true, + bindings: BINDINGS, + }; + + test.each<[string, Partial<World>, Parameters<typeof keydown>[0], string[]]>([ + [ + 'a binding scrolls to the bottom and sends its chars (ADR 018, ADR 055)', + {}, + { code: 'Enter', key: 'Enter', shiftKey: true }, + ['preventDefault', 'stopPropagation', 'scrollToBottom', 'send:\x1b\r'], + ], + [ + 'a binding sends nothing while the socket is closed', + { socketOpen: false }, + { code: 'Enter', key: 'Enter', shiftKey: true }, + ['preventDefault', 'stopPropagation'], + ], + [ + 'an empty binding only consumes the key', + {}, + { code: 'KeyQ', key: 'q', ctrlKey: true }, + ['preventDefault', 'stopPropagation'], + ], + [ + 'a binding overrides a shortcut', + {}, + { code: 'KeyF', key: 'F', ctrlKey: true, shiftKey: true }, + ['preventDefault', 'stopPropagation', 'scrollToBottom', 'send:F!'], + ], + [ + 'the shortcut runs without the binding', + { bindings: [] }, + { code: 'KeyF', key: 'F', ctrlKey: true, shiftKey: true }, + ['preventDefault', 'stopImmediatePropagation', 'find.open'], + ], + ['Escape is the app’s while the bar is closed', {}, { code: 'Escape', key: 'Escape' }, []], + [ + 'Escape closes an open bar', + { barOpen: true }, + { code: 'Escape', key: 'Escape' }, + ['preventDefault', 'stopImmediatePropagation', 'find.close'], + ], + [ + 'a performable copy with nothing selected falls through to the app', + {}, + { code: 'KeyC', key: 'C', ctrlKey: true, shiftKey: true }, + ['copySelection'], + ], + [ + 'a performable clear on the alternate screen falls through to the app', + { mac: true }, + { code: 'KeyK', key: 'k', metaKey: true }, + ['clearScreen'], + ], + [ + 'Ctrl+V reads the clipboard and consumes the key', + {}, + { code: 'KeyV', key: 'v', ctrlKey: true }, + ['pasteFromClipboard', 'preventDefault', 'stopImmediatePropagation'], + ], + [ + 'Ctrl+V without the clipboard API leaves the browser’s paste to run', + { clipboard: false }, + { code: 'KeyV', key: 'v', ctrlKey: true }, + ['pasteFromClipboard', 'stopImmediatePropagation'], + ], + [ + 'jump to prompt is consumed (ADR 057)', + { mac: true }, + { code: 'ArrowUp', key: 'ArrowUp', metaKey: true }, + ['jumpToPrompt:-1', 'preventDefault', 'stopImmediatePropagation'], + ], + ['any other key is left alone', {}, { code: 'KeyA', key: 'a' }, []], + ])('%s', (_name, change, init, calls) => { + expect(run(newDispatcher, { ...world, ...change }, init)).toEqual(calls); + }); +}); + +describe('installKeyDispatcher', () => { + let fake: FakeContext; + afterEach(() => fake.dom.close()); + + /** The dispatcher on a context whose container's listeners are recorded. */ + function setup() { + fake = fakeContext({ keyboardBindings: [{ key: 'k', mods: ['ctrl'], chars: 'K!' }] }); + const container = recordListeners(fake.container, 'container'); + const sent: string[] = []; + const feature = installKeyDispatcher(fake.ctx, { + connection: { isOpen: () => true, send: (data) => sent.push(data) }, + find: { isOpen: false, open: () => {}, close: () => {}, go: () => {} }, + clearScreen: () => false, + pasteFromClipboard: () => false, + }); + const press = (init: KeyboardEventInit) => { + const event = new fake.dom.window.KeyboardEvent('keydown', { cancelable: true, ...init }); + fake.container.dispatchEvent(event); + return event; + }; + return { container, sent, feature, press }; + } + + test('adds one container capture keydown listener and nothing on the window', () => { + const { container } = setup(); + expect(container.added.map(({ target, type, capture }) => ({ target, type, capture }))).toEqual( + [{ target: 'container', type: 'keydown', capture: true }], + ); + expect(fake.win.added).toEqual([]); + expect(fake.term.subscriptions).toEqual([]); + }); + + test('a configured binding on a real keydown reaches the socket, from the context config', () => { + const { sent, press } = setup(); + const event = press({ key: 'k', code: 'KeyK', ctrlKey: true }); + expect(event.defaultPrevented).toBe(true); + expect(sent).toEqual(['K!']); + expect(fake.term.calls).toEqual(['scrollToBottom']); + }); + + test('a shortcut acts on the context terminal', () => { + const { press } = setup(); + fake.term.hasSelection = true; + const event = press({ key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }); + expect(event.defaultPrevented).toBe(true); + press({ key: 'ArrowDown', code: 'ArrowDown', ctrlKey: true, shiftKey: true }); + expect(fake.term.calls).toEqual(['copySelection', 'jumpToPrompt:1']); + }); + + test('dispose removes the listener; twice is a no-op', () => { + const { container, sent, feature, press } = setup(); + feature.dispose(); + feature.dispose(); + expect(container.active).toEqual([]); + expect(container.removed).toHaveLength(1); + press({ key: 'k', code: 'KeyK', ctrlKey: true }); + expect(sent).toEqual([]); + }); +}); diff --git a/src/client/features/keyboard/key-dispatcher.ts b/src/client/features/keyboard/key-dispatcher.ts new file mode 100644 index 0000000..18ceaaf --- /dev/null +++ b/src/client/features/keyboard/key-dispatcher.ts @@ -0,0 +1,115 @@ +// The one container capture `keydown` listener for webtty's own keys (feature, ADR 018, +// ADR 054, ADR 055, ADR 057, ADR 060). A configured binding wins; otherwise Ghostty's +// shortcuts run through the command map. As a capture listener on the container it runs +// after the terminal's own capture listener (the context-menu key) and before the input +// handler's bubble listener, so a key it consumes never reaches the app. + +import type { KeyboardBinding } from '../../app/client-config'; +import type { ClientContext, Feature } from '../../app/client-context'; +import { DisposableStore, listen } from '../../vendor/ghostty-web'; +import { findBinding } from './key-bindings'; +import { createShortcutCommands, type FindBarHost, type KeyControl } from './shortcut-commands'; +import { shortcutFor } from './shortcut-map'; + +/** The PTY socket as the dispatcher writes to it. */ +export interface KeySink { + isOpen(): boolean; + send(data: string): void; +} + +/** What the dispatcher needs besides the context: the handles the root built before it. */ +export interface KeyDispatcherDeps { + connection: KeySink; + find: FindBarHost; + /** Cmd+K: the root's clear, which also clears the graphics and the smooth scroll (ADR 054). */ + clearScreen(): boolean; + /** The paste service's clipboard read; false when the browser cannot read it (ADR 014). */ + pasteFromClipboard(): boolean; +} + +/** The installed dispatcher; nothing to call, only to dispose. */ +export type KeyDispatcherFeature = Feature; + +/** The terminal as the dispatcher's keys act on it. */ +export interface KeyTerminal { + scrollToBottom(): void; + jumpToPrompt(delta: number): void; + copySelection(): boolean; +} + +/** What the keydown handler reads: the terminal, the configured bindings and the platform. */ +export interface KeyHandlerHost { + term: KeyTerminal; + bindings: readonly KeyboardBinding[]; + /** Whether Ghostty's macOS shortcuts apply (ADR 054). */ + mac: boolean; +} + +/** The key fields and event calls the dispatcher uses. */ +export type DispatchedKey = Pick< + KeyboardEvent, + | 'key' + | 'code' + | 'shiftKey' + | 'ctrlKey' + | 'altKey' + | 'metaKey' + | 'preventDefault' + | 'stopPropagation' + | 'stopImmediatePropagation' +>; + +/** The dispatcher's keydown handler, separate from its listener so it is tested on plain events. */ +export function createKeyHandler( + host: KeyHandlerHost, + deps: KeyDispatcherDeps, +): (e: DispatchedKey) => void { + const { term, bindings, mac } = host; + const commands = createShortcutCommands({ + find: deps.find, + jumpToPrompt: (delta) => term.jumpToPrompt(delta), + copySelection: () => term.copySelection(), + clearScreen: deps.clearScreen, + pasteFromClipboard: deps.pasteFromClipboard, + }); + return (e) => { + // Configured key+mods combos win over the terminal and the shortcuts, and send their + // chars straight to the PTY (ADR 018). A binding without chars only consumes the key. + const binding = findBinding(e, bindings); + if (binding) { + e.preventDefault(); + e.stopPropagation(); + if (binding.chars && deps.connection.isOpen()) { + // A keystroke scrolls to the bottom, as the terminal's own keys do (ADR 055) + term.scrollToBottom(); + deps.connection.send(binding.chars); + } + return; + } + const shortcut = shortcutFor(e, mac); + if (shortcut) commands[shortcut.action](shortcut, keyControl(e)); + }; +} + +/** Adds the container capture `keydown` listener. */ +export function installKeyDispatcher( + ctx: ClientContext, + deps: KeyDispatcherDeps, +): KeyDispatcherFeature { + const store = new DisposableStore(); + const { term, config, mac } = ctx; + const handle = createKeyHandler({ term, bindings: config.keyboardBindings, mac }, deps); + store.add(listen(ctx.container, 'keydown', handle, { capture: true })); + return { dispose: () => store.dispose() }; +} + +/** The key's calls as the commands make them; `stopPropagation` is the bindings' alone. */ +function keyControl(e: DispatchedKey): KeyControl { + return { + consume: () => { + e.preventDefault(); + e.stopImmediatePropagation(); + }, + stop: () => e.stopImmediatePropagation(), + }; +} diff --git a/src/client/features/keyboard/shortcut-commands.test.ts b/src/client/features/keyboard/shortcut-commands.test.ts new file mode 100644 index 0000000..bf9f288 --- /dev/null +++ b/src/client/features/keyboard/shortcut-commands.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { createShortcutCommands, type ShortcutAction } from './shortcut-commands'; + +/** What a command did with its key, as the tests read it. */ +type Settled = 'consume' | 'stop' | 'pass'; + +/** The commands on a logging host whose answers the case sets. */ +function setup(state: { barOpen?: boolean; done?: boolean; clipboard?: boolean } = {}) { + const log: string[] = []; + /** Logs `call` and answers `result`. */ + const answer = (call: string, result: boolean) => () => { + log.push(call); + return result; + }; + const commands = createShortcutCommands({ + find: { + isOpen: state.barOpen ?? false, + open: () => log.push('open'), + close: () => log.push('close'), + go: (direction) => log.push(`go:${direction}`), + }, + jumpToPrompt: (delta) => log.push(`jump:${delta}`), + copySelection: answer('copy', state.done ?? false), + clearScreen: answer('clear', state.done ?? false), + pasteFromClipboard: answer('paste', state.clipboard ?? false), + }); + /** Runs `action`; the log gets the key's `consume` or `stop` in call order. */ + const run = (action: ShortcutAction, performable = true): Settled => { + let settled: Settled = 'pass'; + commands[action]( + { action, performable }, + { + consume: () => { + log.push('consume'); + settled = 'consume'; + }, + stop: () => { + log.push('stop'); + settled = 'stop'; + }, + }, + ); + return settled; + }; + return { log, run }; +} + +describe('createShortcutCommands', () => { + test.each([true, false])('find opens the bar and consumes the key (bar open: %p)', (barOpen) => { + const { log, run } = setup({ barOpen }); + expect(run('find', false)).toBe('consume'); + expect(log).toEqual(['consume', 'open']); + }); + + test.each<[ShortcutAction, string]>([ + ['findClose', 'close'], + ['findNext', 'go:next'], + ['findPrevious', 'go:previous'], + ])('%s acts while the bar is open', (action, call) => { + const { log, run } = setup({ barOpen: true }); + expect(run(action)).toBe('consume'); + expect(log).toEqual(['consume', call]); + }); + + test.each<ShortcutAction>(['findClose', 'findNext', 'findPrevious'])( + '%s is the app’s while the bar is closed (ADR 055)', + (action) => { + const { log, run } = setup({ barOpen: false }); + expect(run(action)).toBe('pass'); + expect(log).toEqual([]); + }, + ); + + test.each<[ShortcutAction, string]>([ + ['promptPrevious', 'jump:-1'], + ['promptNext', 'jump:1'], + ])('%s jumps and is consumed either way (ADR 057)', (action, call) => { + const { log, run } = setup(); + expect(run(action, false)).toBe('consume'); + expect(log).toEqual([call, 'consume']); + }); + + test.each<[boolean, Settled]>([ + [true, 'consume'], + [false, 'stop'], + ])('paste with a readable clipboard %p → %s', (clipboard, outcome) => { + const { log, run } = setup({ clipboard }); + expect(run('paste', false)).toBe(outcome); + expect(log).toEqual(['paste', outcome]); + }); + + test.each<[ShortcutAction, boolean, boolean, Settled]>([ + ['copy', true, true, 'consume'], + ['copy', false, true, 'pass'], + ['copy', false, false, 'consume'], + ['copy', true, false, 'consume'], + ['clear', true, true, 'consume'], + ['clear', false, true, 'pass'], + ['clear', false, false, 'consume'], + ])('%s done %p, performable %p → %s', (action, done, performable, outcome) => { + const { log, run } = setup({ done }); + expect(run(action, performable)).toBe(outcome); + expect(log).toEqual(outcome === 'pass' ? [action] : [action, outcome]); + }); +}); diff --git a/src/client/features/keyboard/shortcut-commands.ts b/src/client/features/keyboard/shortcut-commands.ts new file mode 100644 index 0000000..1dc4b1c --- /dev/null +++ b/src/client/features/keyboard/shortcut-commands.ts @@ -0,0 +1,88 @@ +// What each of Ghostty's shortcuts does (command map, ADR 054, ADR 055, ADR 057, ADR 060): +// one handler per action, each settling its key in the order Ghostty's shortcuts always +// have, so the key dispatcher is a table lookup and each command is tested alone. + +import type { Shortcut } from './shortcut-map'; + +/** A shortcut action, the command map's key. */ +export type ShortcutAction = Shortcut['action']; + +/** + * What a command does with its key: `consume` prevents its default and stops every later + * listener; `stop` only stops the later listeners, so the browser's own default still + * runs. A command that calls neither leaves the key to the terminal and the app. + */ +export interface KeyControl { + consume(): void; + stop(): void; +} + +/** The find bar as the shortcuts drive it; features/keyboard never imports features/find. */ +export interface FindBarHost { + readonly isOpen: boolean; + open(): void; + close(): void; + go(direction: 'next' | 'previous'): void; +} + +/** What the commands act on, passed in by the key dispatcher. */ +export interface ShortcutHost { + find: FindBarHost; + /** Scrolls to the previous (-1) or next (1) prompt (ADR 057). */ + jumpToPrompt(delta: -1 | 1): void; + /** Copies the selection; false when there is none. */ + copySelection(): boolean; + /** Clears the screen; false on the alternate screen (ADR 054). */ + clearScreen(): boolean; + /** Reads the clipboard and pastes it; false when the browser cannot read it (ADR 014). */ + pasteFromClipboard(): boolean; +} + +/** A shortcut's command: runs it and settles its key through `key`. */ +export type ShortcutCommand = (shortcut: Shortcut, key: KeyControl) => void; + +/** The commands, one per action, acting on `host`. */ +export function createShortcutCommands( + host: ShortcutHost, +): Record<ShortcutAction, ShortcutCommand> { + // Escape, closing and moving are the app's while the bar is closed (ADR 055) + const whileFinding = + (run: () => void): ShortcutCommand => + (_shortcut, key) => { + if (!host.find.isOpen) return; + key.consume(); + run(); + }; + // Not done and performable: the input handler sends the key to the app, as Ghostty does + const performable = + (run: () => boolean): ShortcutCommand => + (shortcut, key) => { + if (!run() && shortcut.performable) return; + key.consume(); + }; + // Ghostty's jump_to_prompt is consumed with or without a prompt to go to (ADR 057) + const jump = + (delta: -1 | 1): ShortcutCommand => + (_shortcut, key) => { + host.jumpToPrompt(delta); + key.consume(); + }; + return { + find: (_shortcut, key) => { + key.consume(); + host.find.open(); + }, + findClose: whileFinding(() => host.find.close()), + findNext: whileFinding(() => host.find.go('next')), + findPrevious: whileFinding(() => host.find.go('previous')), + promptPrevious: jump(-1), + promptNext: jump(1), + // Without the clipboard API the browser's own paste may still fire 'paste' + paste: (_shortcut, key) => { + if (host.pasteFromClipboard()) key.consume(); + else key.stop(); + }, + copy: performable(() => host.copySelection()), + clear: performable(() => host.clearScreen()), + }; +} diff --git a/src/client/index.ts b/src/client/index.ts index 2d2546b..2ec4a91 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -11,7 +11,7 @@ import { installClipboard } from './features/clipboard/clipboard-feature'; import { Composer } from './features/compose/composer'; import { installFind } from './features/find/find-feature'; import { installFontZoom } from './features/keyboard/font-zoom-feature'; -import { findBinding } from './features/keyboard/key-bindings'; +import { installKeyDispatcher } from './features/keyboard/key-dispatcher'; import { shortcutFor } from './features/keyboard/shortcut-map'; import { KittyGraphics } from './features/kitty/graphics/kitty-graphics'; import { browserNotifications } from './features/notifications/browser-notifications'; @@ -151,23 +151,38 @@ term.attachCustomWheelEventHandler( term.attachWheelPace((e: WheelEvent) => wheelStream.forApp(e)); term.attachBeforeRender((now: number) => smoothScroll.frame(now)); -// Intercept configured key+mods combos before ghostty-web sees them and send -// the bound chars directly to the PTY. See ADR 018. -container.addEventListener( - 'keydown', - (e: KeyboardEvent) => { - const binding = findBinding(e, config.keyboardBindings); - if (!binding) return; - e.preventDefault(); - e.stopPropagation(); - if (binding.chars && connection.isOpen()) { - // A keystroke scrolls to the bottom, as the terminal's own keys do (ADR 055) - term.scrollToBottom(); - connection.send(binding.chars); - } - }, - { capture: true }, -); +// Cmd+K: nothing on the alternate screen; the clear is this tab's own, and a reload +// replays the session's output again (ADR 054) +function clearScreen(): boolean { + if (!term.clearScreen()) return false; + graphics.clear(); + smoothScroll.onOutput(); + return true; +} + +// Find in the scrollback (ADR 055). Built before the key dispatcher, which drives it; the +// bar is the only append to the body and the only `onFindChange` subscriber, so building +// it earlier than the old shortcut listener changes no order (SPEC step 9). +const { mac } = ctx; +const find = installFind(ctx, { + ...env, + shortcutActionOf: (e) => shortcutFor(e, mac)?.action, +}); + +// Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, ADR 049). +// Built here as the dispatcher's paste keys use it; its own listeners are added last. +const paste = new PasteService(term, connection, env.navigator); + +// The one container capture keydown listener, where the binding listener was: configured +// bindings (ADR 018), then Ghostty's copy, paste, clear, find and jump-to-prompt keys for +// this platform (ADR 054, ADR 055, ADR 057). It runs before the input handler, which +// listens on the container too. +installKeyDispatcher(ctx, { + connection, + find, + clearScreen, + pasteFromClipboard: () => paste.pasteFromClipboard(), +}); // Font zoom: the last window capture keydown listener, fitting at once (ADR 033). installFontZoom(ctx, { fit: () => layout.fit() }); @@ -180,69 +195,5 @@ term.onResize(({ cols, rows }: { cols: number; rows: number }) => connection.sendResize(cols, rows), ); -// Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, ADR 049). -const paste = new PasteService(term, connection, env.navigator); - -// Copy, paste, clear-screen, find and jump-to-prompt keys as Ghostty binds them for this -// platform (ADR 054, ADR 055, ADR 057). -// Ctrl+V never reaches us as a 'paste' event on macOS (ADR 014), so the clipboard is -// read on keydown to make it behave like Cmd+V. Both keydown listeners are capture -// listeners on `container`, so they run in registration order - this one runs after the -// configured-binding listener above, so skip a combo the user has already bound -// (including an intentionally empty binding meant to consume the key and suppress the -// default). Both run before the input handler, which listens on the container too. -const { mac } = ctx; -container.addEventListener( - 'keydown', - (e: KeyboardEvent) => { - const shortcut = shortcutFor(e, mac); - if (!shortcut || findBinding(e, config.keyboardBindings)) return; - if (shortcut.action.startsWith('find')) { - // Escape, closing and moving are the app's while the bar is closed (ADR 055) - if (shortcut.action !== 'find' && !find.isOpen) return; - e.preventDefault(); - e.stopImmediatePropagation(); - if (shortcut.action === 'find') find.open(); - else if (shortcut.action === 'findClose') find.close(); - else find.go(shortcut.action === 'findNext' ? 'next' : 'previous'); - return; - } - if (shortcut.action === 'promptPrevious' || shortcut.action === 'promptNext') { - // Ghostty's jump_to_prompt is consumed with or without a prompt to go to (ADR 057) - term.jumpToPrompt(shortcut.action === 'promptPrevious' ? -1 : 1); - e.preventDefault(); - e.stopImmediatePropagation(); - return; - } - if (shortcut.action === 'paste') { - // Without the clipboard API the browser's own paste may still fire 'paste' - if (paste.pasteFromClipboard()) e.preventDefault(); - e.stopImmediatePropagation(); - return; - } - const done = shortcut.action === 'copy' ? term.copySelection() : clearScreen(); - // Not done and performable: the input handler sends the key to the app, as Ghostty does - if (!done && shortcut.performable) return; - e.preventDefault(); - e.stopImmediatePropagation(); - }, - { capture: true }, -); - -// Find in the scrollback (ADR 055); the shortcut listener above reads it only on a key. -const find = installFind(ctx, { - ...env, - shortcutActionOf: (e) => shortcutFor(e, mac)?.action, -}); - -// Cmd+K: nothing on the alternate screen; the clear is this tab's own, and a reload -// replays the session's output again (ADR 054) -function clearScreen(): boolean { - if (!term.clearScreen()) return false; - graphics.clear(); - smoothScroll.onOutput(); - return true; -} - // The right click set to paste, then the container capture paste listener (ADR 051). installPaste(ctx, { paste }); diff --git a/src/client/test-support/fake-terminal.ts b/src/client/test-support/fake-terminal.ts index c3bebf2..1010a6b 100644 --- a/src/client/test-support/fake-terminal.ts +++ b/src/client/test-support/fake-terminal.ts @@ -43,6 +43,8 @@ export class FakeTerminal { /** Undefined until {@link attachRenderer}, as before `open()`. */ renderer: FakeRenderer | undefined; wasmTerm: object | undefined; + /** Whether there is a selection, for `copySelection`. */ + hasSelection = false; /** Everything passed to `write`, in order. */ readonly written: (string | Uint8Array)[] = []; /** Every command called, as `name` or `name:arg`, in order. */ @@ -137,6 +139,16 @@ export class FakeTerminal { return this.renderer; } + jumpToPrompt(delta: number): void { + this.calls.push(`jumpToPrompt:${delta}`); + } + + /** Records the copy; true when the test has set a selection. */ + copySelection(): boolean { + this.calls.push('copySelection'); + return this.hasSelection; + } + scrollToBottom(): void { this.calls.push('scrollToBottom'); this.viewportY = 0; From ec01a6b51f8ad612fae79dff2a7c9853db3d8c2e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:41:41 -0400 Subject: [PATCH 208/295] refactor(cli): WebttyClient and one module per command - api-client.ts: WebttyClient(baseUrl, fetch, openSocket) holds every request the CLI makes: isRunning, listSessions, ensureSession (the one ensure-or-create shared by go and run's warm-up), removeSession, renameSession, stop, execute (NDJSON messages over a LineSplitter) and openPty. A failed request becomes ServerUnreachableError, so commands print "webtty is not running" exactly where the old try/catch did. The base URL is a function read per request, as getBaseUrl() was. - commands/: go, list, remove, rename, start-stop, edit-config, run (warmUp and exec), key and key-format; each takes (args, deps). - CliDeps is { client, config, out, err, write, exit, version, server, openBrowser, editor, tty }; main(argv, deps, commands) dispatches over CLI_COMMANDS. commands.ts and http.ts are gone; server-process stops and probes through the client. - Tests: each command runs in-process on scripted fetch and recording out/err/exit, asserting output strings and exit codes exactly; no module-namespace spies remain. The subprocess tests of the entry move to index.test.ts. Output text, exit codes, flags and aliases are unchanged (ADR 063). --- src/cli/api-client.test.ts | 297 ++++++++ src/cli/api-client.ts | 215 ++++++ src/cli/browser-opener.ts | 6 + src/cli/cli-deps.ts | 91 +-- src/cli/commands.test.ts | 995 ------------------------- src/cli/commands.ts | 357 --------- src/cli/commands/edit-config.test.ts | 90 +++ src/cli/commands/edit-config.ts | 50 ++ src/cli/commands/go.test.ts | 61 ++ src/cli/commands/go.ts | 33 + src/cli/commands/key-format.test.ts | 53 ++ src/cli/commands/key-format.ts | 34 + src/cli/commands/key.test.ts | 168 +++++ src/cli/commands/key.ts | 97 +++ src/cli/commands/list.test.ts | 54 ++ src/cli/commands/list.ts | 30 + src/cli/commands/remove.test.ts | 62 ++ src/cli/commands/remove.ts | 38 + src/cli/commands/rename.test.ts | 62 ++ src/cli/commands/rename.ts | 34 + src/cli/commands/run.test.ts | 144 ++++ src/cli/commands/run.ts | 106 +++ src/cli/commands/start-stop.test.ts | 45 ++ src/cli/commands/start-stop.ts | 29 + src/cli/http.test.ts | 42 -- src/cli/http.ts | 26 - src/cli/index.test.ts | 257 +++++++ src/cli/main.test.ts | 134 ++-- src/cli/main.ts | 69 +- src/cli/server-process.test.ts | 55 +- src/cli/server-process.ts | 17 +- src/cli/test-support/fake-cli-deps.ts | 89 +++ src/cli/test-support/scripted-fetch.ts | 91 +++ 33 files changed, 2365 insertions(+), 1566 deletions(-) create mode 100644 src/cli/api-client.test.ts create mode 100644 src/cli/api-client.ts delete mode 100644 src/cli/commands.test.ts delete mode 100644 src/cli/commands.ts create mode 100644 src/cli/commands/edit-config.test.ts create mode 100644 src/cli/commands/edit-config.ts create mode 100644 src/cli/commands/go.test.ts create mode 100644 src/cli/commands/go.ts create mode 100644 src/cli/commands/key-format.test.ts create mode 100644 src/cli/commands/key-format.ts create mode 100644 src/cli/commands/key.test.ts create mode 100644 src/cli/commands/key.ts create mode 100644 src/cli/commands/list.test.ts create mode 100644 src/cli/commands/list.ts create mode 100644 src/cli/commands/remove.test.ts create mode 100644 src/cli/commands/remove.ts create mode 100644 src/cli/commands/rename.test.ts create mode 100644 src/cli/commands/rename.ts create mode 100644 src/cli/commands/run.test.ts create mode 100644 src/cli/commands/run.ts create mode 100644 src/cli/commands/start-stop.test.ts create mode 100644 src/cli/commands/start-stop.ts delete mode 100644 src/cli/http.test.ts delete mode 100644 src/cli/http.ts create mode 100644 src/cli/index.test.ts create mode 100644 src/cli/test-support/fake-cli-deps.ts create mode 100644 src/cli/test-support/scripted-fetch.ts diff --git a/src/cli/api-client.test.ts b/src/cli/api-client.test.ts new file mode 100644 index 0000000..addf3d5 --- /dev/null +++ b/src/cli/api-client.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, test } from 'bun:test'; +import { configuredClient, localBaseUrl, ServerUnreachableError, WebttyClient } from './api-client'; +import { + FAKE_BASE_URL, + fakeSockets, + json, + lines, + ndjson, + type Responder, + refused, + type SocketBehaviour, + scriptedFetch, +} from './test-support/scripted-fetch'; + +function clientFor(routes: Record<string, Responder>, socket?: SocketBehaviour) { + const { fetchFn, requests } = scriptedFetch(routes); + const sockets = fakeSockets(socket); + return { + client: new WebttyClient(FAKE_BASE_URL, fetchFn, sockets.openSocket), + requests, + sockets, + }; +} + +/** Collects every message of an execute stream. */ +async function collect(messages: AsyncIterable<Record<string, unknown>> | null) { + const all: Array<Record<string, unknown>> = []; + for await (const message of messages ?? []) all.push(message); + return all; +} + +describe('localBaseUrl and configuredClient', () => { + test('the base URL is always 127.0.0.1 on the given port', () => { + expect(localBaseUrl(7681)).toBe('http://127.0.0.1:7681'); + }); + + test('configuredClient builds a client without reading the config yet', () => { + expect(configuredClient()).toBeInstanceOf(WebttyClient); + }); + + test('a base URL function is read on every request', async () => { + const urls: string[] = []; + let port = 1; + const client = new WebttyClient( + () => localBaseUrl(port++), + async (url) => { + urls.push(url); + return json([]); + }, + ); + await client.isRunning(); + await client.isRunning(); + expect(urls).toEqual(['http://127.0.0.1:1/api/sessions', 'http://127.0.0.1:2/api/sessions']); + }); + + test('the default fetch is the global one, looked up per call', async () => { + const realFetch = globalThis.fetch; + const seen: string[] = []; + globalThis.fetch = Object.assign( + async (url: string | URL | Request) => { + seen.push(String(url)); + return json([]); + }, + { preconnect: realFetch.preconnect }, + ); + try { + expect(await new WebttyClient(FAKE_BASE_URL).isRunning()).toBe(true); + } finally { + globalThis.fetch = realFetch; + } + expect(seen).toEqual([`${FAKE_BASE_URL}/api/sessions`]); + }); +}); + +describe('isRunning', () => { + const cases: Array<[string, Responder, boolean]> = [ + ['a JSON array', () => json([]), true], + ['a JSON object', () => json({}), false], + ['a non-2xx status', () => json([], 500), false], + ['a body that is not JSON', () => new Response('<html>'), false], + ['a refused connection', refused, false], + ]; + for (const [name, respond, expected] of cases) { + test(`${name} → ${expected}`, async () => { + const { client } = clientFor({ 'GET /api/sessions': respond }); + expect(await client.isRunning()).toBe(expected); + }); + } +}); + +describe('listSessions', () => { + test('returns the sessions', async () => { + const sessions = [{ id: 'main', connected: true, createdAt: 1 }]; + const { client } = clientFor({ 'GET /api/sessions': () => json(sessions) }); + expect(await client.listSessions()).toEqual(sessions); + }); + + test('throws ServerUnreachableError, keeping the cause, when the server is down', async () => { + const { client } = clientFor({ 'GET /api/sessions': refused }); + const error = await client.listSessions().catch((e: unknown) => e); + expect(error).toBeInstanceOf(ServerUnreachableError); + expect((error as Error).message).toBe('fetch failed'); + expect((error as Error).cause).toBeInstanceOf(TypeError); + }); + + test('a body that is not JSON is not mistaken for an unreachable server', async () => { + const { client } = clientFor({ 'GET /api/sessions': () => new Response('<html>') }); + const error = await client.listSessions().catch((e: unknown) => e); + expect(error).toBeInstanceOf(SyntaxError); + }); + + test('a non-Error rejection still becomes ServerUnreachableError', async () => { + const client = new WebttyClient(FAKE_BASE_URL, () => Promise.reject('down')); + const error = await client.listSessions().catch((e: unknown) => e); + expect((error as Error).message).toBe('down'); + }); +}); + +describe('ensureSession', () => { + test('returns an existing session without creating one', async () => { + const { client, requests } = clientFor({ 'GET /api/sessions/a%20b': () => json({}) }); + expect(await client.ensureSession('a b')).toEqual({ id: 'a b' }); + expect(requests.map((r) => r.method)).toEqual(['GET']); + }); + + test('creates a missing session with its baseDir and returns the id the server gave', async () => { + const { client, requests } = clientFor({ + 'GET /api/sessions/x': () => json({ error: 'not found' }, 404), + 'POST /api/sessions': () => json({ id: 'x' }, 201), + }); + expect(await client.ensureSession('x', '/work')).toEqual({ id: 'x' }); + expect(requests[1]).toEqual({ + method: 'POST', + path: '/api/sessions', + body: { id: 'x', baseDir: '/work' }, + }); + }); + + test('sends no baseDir key when none is given', async () => { + const { client, requests } = clientFor({ + 'GET /api/sessions/x': () => json({}, 404), + 'POST /api/sessions': () => json({ id: 'x' }, 201), + }); + await client.ensureSession('x'); + expect(requests[1]?.body).toEqual({ id: 'x' }); + }); + + test("returns the server's error, or a status message without one", async () => { + const withError = clientFor({ + 'GET /api/sessions/x': () => json({}, 404), + 'POST /api/sessions': () => json({ error: 'invalid id' }, 400), + }); + expect(await withError.client.ensureSession('x')).toEqual({ error: 'invalid id' }); + const without = clientFor({ + 'GET /api/sessions/x': () => json({}, 404), + 'POST /api/sessions': () => json({}, 500), + }); + expect(await without.client.ensureSession('x')).toEqual({ + error: 'failed to create session (500)', + }); + }); +}); + +describe('removeSession, renameSession and stop', () => { + test('removeSession returns the status and X-Sessions-Remaining', async () => { + const { client, requests } = clientFor({ + 'DELETE /api/sessions/x': () => + new Response(null, { status: 204, headers: { 'X-Sessions-Remaining': '0' } }), + }); + expect(await client.removeSession('x')).toEqual({ status: 204, sessionsRemaining: '0' }); + expect(requests).toHaveLength(1); + }); + + test('renameSession PATCHes the new id and reads the error lazily', async () => { + const { client, requests } = clientFor({ + 'PATCH /api/sessions/x': () => json({ error: 'id taken' }, 409), + }); + const result = await client.renameSession('x', 'y'); + expect(requests[0]?.body).toEqual({ id: 'y' }); + expect([result.status, result.ok]).toEqual([409, false]); + expect(await result.error()).toBe('id taken'); + }); + + test('stop POSTs /api/server/stop and reports whether it was accepted', async () => { + const accepted = clientFor({ 'POST /api/server/stop': () => new Response('stopping') }); + expect(await accepted.client.stop()).toBe(true); + const rejected = clientFor({ + 'POST /api/server/stop': () => new Response('no', { status: 500 }), + }); + expect(await rejected.client.stop()).toBe(false); + }); +}); + +describe('execute', () => { + const route = 'POST /s/my%20s/execute'; + + test('POSTs the command and yields each NDJSON message across chunk splits', async () => { + const text = lines( + { stream: 'stdout', data: 'a' }, + { stream: 'stderr', data: 'b' }, + { exit: 0 }, + ); + const { client, requests } = clientFor({ + [route]: () => ndjson(text.slice(0, 7), text.slice(7, 30), text.slice(30)), + }); + const res = await client.execute('my s', 'ls', ['-l']); + expect(requests[0]?.body).toEqual({ cmd: 'ls', args: ['-l'] }); + expect([res.status, res.ok]).toEqual([200, true]); + expect(await collect(res.messages)).toEqual([ + { stream: 'stdout', data: 'a' }, + { stream: 'stderr', data: 'b' }, + { exit: 0 }, + ]); + }); + + test('a stream without an exit message just ends', async () => { + const { client } = clientFor({ [route]: () => ndjson(lines({ stream: 'stdout', data: 'x' })) }); + const res = await client.execute('my s', 'ls', []); + expect(await collect(res.messages)).toEqual([{ stream: 'stdout', data: 'x' }]); + }); + + test('an unterminated last line is not yielded', async () => { + const { client } = clientFor({ [route]: () => ndjson('{"exit":0}') }); + const res = await client.execute('my s', 'ls', []); + expect(await collect(res.messages)).toEqual([]); + }); + + test('a line that is not JSON throws while iterating', async () => { + const { client } = clientFor({ [route]: () => ndjson('not json\n') }); + const res = await client.execute('my s', 'ls', []); + await expect(collect(res.messages)).rejects.toThrow(SyntaxError); + }); + + test('a connection that closes mid-stream rejects the iteration', async () => { + let sent = false; + const body = new ReadableStream<Uint8Array>({ + pull(controller) { + if (sent) controller.error(new Error('socket hang up')); + else controller.enqueue(new TextEncoder().encode(lines({ stream: 'stdout', data: 'x' }))); + sent = true; + }, + }); + const { client } = clientFor({ [route]: () => new Response(body) }); + const res = await client.execute('my s', 'ls', []); + const seen: unknown[] = []; + const error = await (async () => { + for await (const message of res.messages ?? []) seen.push(message); + })().catch((e: unknown) => e); + expect(seen).toEqual([{ stream: 'stdout', data: 'x' }]); + expect((error as Error).message).toBe('socket hang up'); + }); + + test('a response without a body has no messages', async () => { + const { client } = clientFor({ [route]: () => new Response(null, { status: 404 }) }); + const res = await client.execute('my s', 'ls', []); + expect(res).toEqual({ status: 404, ok: false, messages: null }); + }); +}); + +describe('openPty', () => { + test('opens the 80x24 PTY socket on the ws URL, closes it with 1000 and resolves', async () => { + const { client, sockets } = clientFor({}, 'open'); + await client.openPty('a b'); + expect(sockets.urls).toEqual(['ws://127.0.0.1:7681/ws/a%20b/pty?cols=80&rows=24']); + expect(sockets.closeCodes).toEqual([1000]); + }); + + test('rejects when the socket errors', async () => { + const { client } = clientFor({}, 'error'); + await expect(client.openPty('x')).rejects.toThrow('failed to connect to PTY'); + }); + + test('rejects with the close code when the socket closes before opening', async () => { + const { client } = clientFor({}, 'close-without-open'); + await expect(client.openPty('x')).rejects.toThrow('failed to start PTY (code 1006)'); + }); + + test('the default socket factory is the global WebSocket', async () => { + const realWebSocket = globalThis.WebSocket; + const urls: string[] = []; + class RecordingSocket { + onopen: ((event: Event) => void) | null = null; + constructor(url: string) { + urls.push(url); + queueMicrotask(() => this.onopen?.(new Event('open'))); + } + close() {} + } + globalThis.WebSocket = RecordingSocket as unknown as typeof WebSocket; + try { + await new WebttyClient(FAKE_BASE_URL).openPty('x'); + } finally { + globalThis.WebSocket = realWebSocket; + } + expect(urls).toEqual(['ws://127.0.0.1:7681/ws/x/pty?cols=80&rows=24']); + }); +}); diff --git a/src/cli/api-client.ts b/src/cli/api-client.ts new file mode 100644 index 0000000..7a0a1b2 --- /dev/null +++ b/src/cli/api-client.ts @@ -0,0 +1,215 @@ +// The CLI's one view of the webtty server's HTTP and WebSocket API: every request the commands +// make goes through a WebttyClient. A service over an injected fetch and WebSocket factory, so +// commands are tested against scripted responses instead of a live server (ADR 063). +import { loadConfig } from '../config'; +import { LineSplitter } from '../ndjson'; + +/** The part of `fetch` the client calls; narrower than `typeof fetch` so a fake needs no casts. */ +export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; + +/** The part of a WebSocket {@link WebttyClient.openPty} uses. */ +export interface PtySocket { + onopen: ((event: Event) => void) | null; + onerror: ((event: Event) => void) | null; + onclose: ((event: CloseEvent) => void) | null; + close(code?: number): void; +} + +/** Opens a WebSocket to `url`; the global `WebSocket` in production. */ +export type OpenSocket = (url: string) => PtySocket; + +/** One row of `GET /api/sessions`. */ +export interface SessionSummary { + id: string; + connected: boolean; + createdAt: number; +} + +/** One NDJSON line of `POST /s/:id/execute`: `{stream, data}`, or `{exit, error?}` last. */ +export type ExecuteMessage = Record<string, unknown>; + +/** What `ensureSession` found or made: the session id, or the server's reason it could not. */ +export type EnsureSessionResult = { id: string } | { error: string }; + +/** What `DELETE /api/sessions/:id` answered: the status and `X-Sessions-Remaining`. */ +export interface RemoveSessionResult { + status: number; + sessionsRemaining: string | null; +} + +/** What `PATCH /api/sessions/:id` answered. */ +export interface RenameSessionResult { + status: number; + ok: boolean; + /** Reads the body's `error` field; call it only for a status that carries one. */ + error(): Promise<string | undefined>; +} + +/** The status line of `POST /s/:id/execute` and, when it has a body, its messages in order. */ +export interface ExecuteResponse { + status: number; + ok: boolean; + /** Null when the response has no body. Iterating throws on a line that is not JSON. */ + messages: AsyncIterable<ExecuteMessage> | null; +} + +/** + * Thrown when the server cannot be reached at all (the request never got a response), so a + * command can tell "webtty is not running" apart from a bad answer. + */ +export class ServerUnreachableError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = 'ServerUnreachableError'; + } +} + +/** The loopback base URL for `port`: always 127.0.0.1, which avoids an IPv6 lookup. */ +export function localBaseUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +/** + * The client for the configured server. The config file is read on every request, as the CLI + * always has, so a bad config file surfaces as "not running" where the old calls caught it. + */ +export function configuredClient(): WebttyClient { + return new WebttyClient(() => localBaseUrl(loadConfig().port)); +} + +/** Calls the webtty server's API. One instance per CLI invocation. */ +export class WebttyClient { + private readonly baseUrl: () => string; + + /** + * @param baseUrl - The server's base URL, or a function read on every request. + * @param fetchFn - The HTTP call; the global `fetch` (looked up per call) by default. + * @param openSocket - The WebSocket factory; the global `WebSocket` by default. + */ + constructor( + baseUrl: string | (() => string), + private readonly fetchFn: FetchLike = (url, init) => fetch(url, init), + private readonly openSocket: OpenSocket = (url) => new WebSocket(url), + ) { + this.baseUrl = typeof baseUrl === 'string' ? () => baseUrl : baseUrl; + } + + /** True when `GET /api/sessions` answers 2xx with a JSON array; false on any failure. */ + async isRunning(): Promise<boolean> { + try { + const res = await this.send('/api/sessions'); + if (!res.ok) return false; + return Array.isArray(await res.json()); + } catch { + return false; + } + } + + /** All sessions. Throws {@link ServerUnreachableError} when the server does not answer. */ + async listSessions(): Promise<SessionSummary[]> { + const res = await this.send('/api/sessions'); + return (await res.json()) as SessionSummary[]; + } + + /** + * Returns session `id` if it exists, otherwise creates it (in `baseDir` when given). The one + * copy of ensure-or-create, shared by `go` and `run`'s warm-up. + */ + async ensureSession(id: string, baseDir?: string): Promise<EnsureSessionResult> { + const check = await this.send(`/api/sessions/${encodeURIComponent(id)}`); + if (check.status === 200) return { id }; + const res = await this.send( + '/api/sessions', + jsonRequest('POST', { id, ...(baseDir ? { baseDir } : {}) }), + ); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + return { error: body.error ?? `failed to create session (${res.status})` }; + } + const session = (await res.json()) as { id: string }; + return { id: session.id }; + } + + /** `DELETE /api/sessions/:id`. */ + async removeSession(id: string): Promise<RemoveSessionResult> { + const res = await this.send(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }); + return { status: res.status, sessionsRemaining: res.headers.get('x-sessions-remaining') }; + } + + /** `PATCH /api/sessions/:id` with the new id. */ + async renameSession(id: string, newId: string): Promise<RenameSessionResult> { + const res = await this.send( + `/api/sessions/${encodeURIComponent(id)}`, + jsonRequest('PATCH', { id: newId }), + ); + return { + status: res.status, + ok: res.ok, + error: async () => ((await res.json()) as { error?: string }).error, + }; + } + + /** `POST /api/server/stop`; true when the server accepted. */ + async stop(): Promise<boolean> { + const res = await this.send('/api/server/stop', { method: 'POST' }); + return res.ok; + } + + /** `POST /s/:id/execute`, with the NDJSON body as an async iterator of messages. */ + async execute(id: string, cmd: string, args: string[]): Promise<ExecuteResponse> { + const res = await this.send( + `/s/${encodeURIComponent(id)}/execute`, + jsonRequest('POST', { cmd, args }), + ); + return { status: res.status, ok: res.ok, messages: res.body ? ndjsonMessages(res.body) : null }; + } + + /** + * Opens session `id`'s PTY socket at 80x24, which starts the PTY, then closes it. Rejects with + * the reason when the socket errors or closes before it opened. + */ + openPty(id: string): Promise<void> { + const url = `${this.baseUrl().replace(/^http/, 'ws')}/ws/${encodeURIComponent(id)}/pty?cols=80&rows=24`; + return new Promise<void>((resolve, reject) => { + const ws = this.openSocket(url); + let opened = false; + ws.onopen = () => { + opened = true; + ws.close(1000); + resolve(); + }; + ws.onerror = () => reject(new Error('failed to connect to PTY')); + ws.onclose = (evt) => { + if (!opened) reject(new Error(`failed to start PTY (code ${evt.code})`)); + }; + }); + } + + /** One request; any failure before a response becomes a {@link ServerUnreachableError}. */ + private async send(path: string, init?: RequestInit): Promise<Response> { + try { + return await this.fetchFn(`${this.baseUrl()}${path}`, init); + } catch (err) { + throw new ServerUnreachableError(err); + } + } +} + +function jsonRequest(method: string, body: unknown): RequestInit { + return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; +} + +/** The messages of an NDJSON body, one per line, parsed as they arrive. */ +async function* ndjsonMessages(body: ReadableStream<Uint8Array>): AsyncIterable<ExecuteMessage> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + // The server never sends CR; keeping it leaves the lines exactly as they were before the splitter. + const lines = new LineSplitter({ stripCarriageReturn: false }); + while (true) { + const { done, value } = await reader.read(); + if (done) return; + for (const line of lines.push(decoder.decode(value, { stream: true }))) { + yield JSON.parse(line) as ExecuteMessage; + } + } +} diff --git a/src/cli/browser-opener.ts b/src/cli/browser-opener.ts index efa92f7..32aaf43 100644 --- a/src/cli/browser-opener.ts +++ b/src/cli/browser-opener.ts @@ -1,6 +1,7 @@ // Opening a session URL in the user's browser, and turning the configured bind host into one a // browser can navigate to. A util over an injected spawn, platform and environment (ADR 063). import * as childProcess from 'node:child_process'; +import type { Config } from '../config'; /** * Converts a bind host to a browser-navigable host. @@ -14,6 +15,11 @@ export function toBrowserHost(host: string): string { return host; } +/** The session page URL `go` and `run` print: the configured host made browsable, and the port. */ +export function sessionPageUrl(config: Pick<Config, 'host' | 'port'>, id: string): string { + return `http://${toBrowserHost(config.host)}:${config.port}/s/${id}`; +} + /** `child_process.spawn` narrowed to the detached, unref'd call openBrowser makes. */ export type SpawnDetached = ( command: string, diff --git a/src/cli/cli-deps.ts b/src/cli/cli-deps.ts index 5e811f9..8e40062 100644 --- a/src/cli/cli-deps.ts +++ b/src/cli/cli-deps.ts @@ -1,65 +1,72 @@ -// Everything the CLI dispatcher reaches outside itself: the output streams, the exit call, the -// version and the command handlers. Types plus a factory over the real globals, so main.ts is -// tested with fakes instead of subprocesses (ADR 063). -// Interim shape: the `commands` table goes away in the commands/ split, when CliDeps becomes -// { client, out, err, exit, spawn, config } and each command takes (args, deps) (ADR 063). +// Everything a CLI command reaches outside itself: the API client, the config, the output +// streams, the exit call, server start/stop, the browser, the editor and the TTY. Types plus a +// factory over the real globals, so every command is tested in-process with fakes (ADR 063). +import { type Config, loadConfig } from '../config'; import { getVersion } from '../version'; -import { - cmdConfig, - cmdGo, - cmdKey, - cmdList, - cmdRemove, - cmdRename, - cmdRun, - cmdStart, - cmdStop, -} from './commands'; +import { configuredClient, type WebttyClient } from './api-client'; +import { openBrowser } from './browser-opener'; +import { type EditorHost, processEditorHost } from './commands/edit-config'; +import { type KeyTty, processKeyTty } from './commands/key'; +import { startServer, stopServer } from './server-process'; -/** The command handlers main() dispatches to, one per command (aliases resolve before the call). */ -export interface CliCommands { - go(id: string, baseDir?: string): Promise<void>; - list(filter?: string): Promise<void>; - remove(id?: string): Promise<void>; - rename(id?: string, newId?: string): Promise<void>; - stop(): Promise<void>; +/** Starting and stopping the background server process. */ +export interface ServerControl { + /** Spawns the server and waits until it answers; exits the CLI if it never does. */ start(): Promise<void>; - config(): void; - run(id: string, cmd: string | undefined, args: string[]): Promise<void>; - key(): void; + /** Asks the server to stop and waits until it is gone; false if it did not stop. */ + stop(): Promise<boolean>; } -/** What main() needs from the process; tests pass recording fakes. */ +/** What main() and the commands need from the process; tests pass recording fakes. */ export interface CliDeps { + /** The webtty server's API. */ + client: WebttyClient; + /** Reads `~/.config/webtty/config.json` (on every call, as the CLI always has). */ + config(): Config; /** Prints one line to stdout (console.log). */ out(text: string): void; /** Prints one line to stderr (console.error). */ err(text: string): void; + /** Writes raw text to stdout and stderr, for `run`'s streamed output. */ + write: { stdout(text: string): void; stderr(text: string): void }; /** Ends the process with `code`; a fake may return, so callers return right after it. */ exit(code: number): void; /** The package version `webtty -v` prints. */ version(): string; - /** The command handlers; temporary until the commands/ split passes deps to each command. */ - commands: CliCommands; + server: ServerControl; + /** Opens `url` in the default browser (a no-op under tests or WEBTTY_NO_OPEN=1). */ + openBrowser(url: string): void; + /** The config file and the editor `webtty config` opens it in. */ + editor: EditorHost; + /** The raw-mode terminal `webtty key` reads from. */ + tty: KeyTty; } -/** The production deps: the real console, process.exit and the command functions. */ -export function createCliDeps(): CliDeps { +/** + * The production deps: the real console, process, config, server process and browser. Nothing + * is read here, so `webtty -v` and `help` still never touch the config file. + * + * @param client - The API client; the configured server's by default. + */ +export function createCliDeps(client: WebttyClient = configuredClient()): CliDeps { return { + client, + config: loadConfig, out: (text) => console.log(text), err: (text) => console.error(text), + write: { + stdout: (text) => { + process.stdout.write(text); + }, + stderr: (text) => { + process.stderr.write(text); + }, + }, exit: (code) => process.exit(code), version: getVersion, - commands: { - go: cmdGo, - list: cmdList, - remove: cmdRemove, - rename: cmdRename, - stop: cmdStop, - start: cmdStart, - config: cmdConfig, - run: cmdRun, - key: cmdKey, - }, + server: { start: () => startServer(), stop: () => stopServer(client) }, + openBrowser: (url) => openBrowser(url), + editor: processEditorHost(), + tty: processKeyTty(), }; } diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts deleted file mode 100644 index 38f4b84..0000000 --- a/src/cli/commands.test.ts +++ /dev/null @@ -1,995 +0,0 @@ -import { afterAll, beforeAll, describe, expect, mock, spyOn, test } from 'bun:test'; -import * as childProcessModule from 'node:child_process'; -import { type ChildProcess, spawn } from 'node:child_process'; -import * as fsModule from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - cleanupTmpHome, - getFreePort, - makeTmpHome, - waitForServerDown, - waitForServerReady, -} from '../test-support/server-harness'; -import { bytesToChars, bytesToDisplay } from './commands'; -import * as httpModule from './http'; -import * as serverProcessModule from './server-process'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); -const SERVER_ENTRY = path.resolve(__dirname, '../server/index.ts'); - -const tmpHome = makeTmpHome('cli-test'); - -afterAll(() => { - cleanupTmpHome(tmpHome); -}); - -async function runCli( - port: number, - ...args: string[] -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - // Write config with the test port so loadConfig() in the CLI subprocess returns the right port. - const cfgDir = path.join(tmpHome, '.config', 'webtty'); - fsModule.mkdirSync(cfgDir, { recursive: true }); - fsModule.writeFileSync(path.join(cfgDir, 'config.json'), JSON.stringify({ port })); - - const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], { - env: { ...process.env, WEBTTY_NO_OPEN: '1', HOME: tmpHome }, - stdout: 'pipe', - stderr: 'pipe', - }); - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; -} - -describe('cli — lifecycle', () => { - let port: number; - let baseUrl: string; - - beforeAll(async () => { - port = await getFreePort(); - baseUrl = `http://127.0.0.1:${port}`; - }); - - afterAll(async () => { - await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }).catch(() => {}); - }); - - test('unknown command exits with error', async () => { - const { stderr, exitCode } = await runCli(port, 'unknown'); - expect(exitCode).toBe(1); - expect(stderr).toContain('unknown command'); - }); - - test('start launches the server', async () => { - const { stdout, exitCode } = await runCli(port, 'start'); - expect(exitCode).toBe(0); - expect(stdout).toBe('webtty started'); - expect(await waitForServerReady(baseUrl)).toBe(true); - }); - - test('start when already running reports already running', async () => { - const { stdout, exitCode } = await runCli(port, 'start'); - expect(exitCode).toBe(0); - expect(stdout).toBe('webtty is already running'); - }); - - test('stop shuts down the server', async () => { - const { stdout, exitCode } = await runCli(port, 'stop'); - expect(exitCode).toBe(0); - expect(stdout).toBe('webtty stopped'); - await waitForServerDown(baseUrl); - await expect(fetch(`${baseUrl}/api/sessions`)).rejects.toThrow(); - }); - - test('stop when not running reports not running', async () => { - const { stdout, exitCode } = await runCli(port, 'stop'); - expect(exitCode).toBe(0); - expect(stdout).toBe('webtty is not running'); - }); - - test('start launches server after stop', async () => { - const { stdout, exitCode } = await runCli(port, 'start'); - expect(exitCode).toBe(0); - expect(stdout).toBe('webtty started'); - expect(await waitForServerReady(baseUrl)).toBe(true); - }); -}); - -describe('cli — session management', () => { - let port: number; - let baseUrl: string; - let serverProc: ChildProcess; - - beforeAll(async () => { - port = await getFreePort(); - baseUrl = `http://127.0.0.1:${port}`; - serverProc = spawn(process.execPath, [SERVER_ENTRY], { - env: { ...process.env, PORT: String(port), HOME: tmpHome }, - stdio: 'ignore', - }); - await waitForServerReady(baseUrl); - }); - - afterAll(() => { - serverProc.kill(); - }); - - test('ls prints no sessions on empty server', async () => { - const { stdout, exitCode } = await runCli(port, 'ls'); - expect(exitCode).toBe(0); - expect(stdout).toContain('no sessions'); - }); - - test('go creates a session and prints url', async () => { - const { stdout, exitCode } = await runCli(port, 'go', 'my-session'); - expect(exitCode).toBe(0); - expect(stdout).toContain(`/s/my-session`); - - const res = await fetch(`${baseUrl}/api/sessions/my-session`); - expect(res.status).toBe(200); - }); - - test('go with existing id reuses session without error', async () => { - const { stdout, exitCode } = await runCli(port, 'go', 'my-session'); - expect(exitCode).toBe(0); - expect(stdout).toContain(`/s/my-session`); - }); - - test('go without id opens main session', async () => { - const { stdout, exitCode } = await runCli(port, 'go'); - expect(exitCode).toBe(0); - expect(stdout).toContain('/s/main'); - }); - - test('ls shows created sessions', async () => { - const { stdout, exitCode } = await runCli(port, 'ls'); - expect(exitCode).toBe(0); - expect(stdout).toContain('my-session'); - }); - - test('rename renames a session', async () => { - const { stdout, exitCode } = await runCli(port, 'mv', 'my-session', 'renamed-session'); - expect(exitCode).toBe(0); - expect(stdout).toContain('renamed'); - expect(stdout).toContain('renamed-session'); - - expect((await fetch(`${baseUrl}/api/sessions/my-session`)).status).toBe(404); - expect((await fetch(`${baseUrl}/api/sessions/renamed-session`)).status).toBe(200); - }); - - test('rename non-existent session exits with error', async () => { - const { exitCode, stderr } = await runCli(port, 'mv', 'does-not-exist', 'new-name'); - expect(exitCode).toBe(1); - expect(stderr).toContain('not found'); - }); - - test('rm removes a session', async () => { - const { stdout, exitCode } = await runCli(port, 'rm', 'renamed-session'); - expect(exitCode).toBe(0); - expect(stdout).toContain('removed renamed-session'); - expect((await fetch(`${baseUrl}/api/sessions/renamed-session`)).status).toBe(404); - }); - - test('rm non-existent session exits with error', async () => { - const { exitCode, stderr } = await runCli(port, 'rm', 'does-not-exist'); - expect(exitCode).toBe(1); - expect(stderr).toContain('not found'); - }); - - test('ls when server not running exits with error', async () => { - const { stdout, exitCode } = await runCli(port + 1, 'ls'); - expect(exitCode).toBe(1); - expect(stdout).toContain('not running'); - }); -}); - -describe('bytesToDisplay', () => { - test('ESC CR → legacy shift+enter', () => { - expect(bytesToDisplay(Buffer.from([0x1b, 0x0d]))).toBe('ESC CR'); - }); - - test('ESC [ 1 3 ; 2 u → KKP shift+enter', () => { - expect(bytesToDisplay(Buffer.from([0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75]))).toBe( - 'ESC [ 1 3 ; 2 u', - ); - }); - - test('tab → TAB', () => { - expect(bytesToDisplay(Buffer.from([0x09]))).toBe('TAB'); - }); - - test('space → SPC', () => { - expect(bytesToDisplay(Buffer.from([0x20]))).toBe('SPC'); - }); - - test('del → DEL', () => { - expect(bytesToDisplay(Buffer.from([0x7f]))).toBe('DEL'); - }); - - test('unknown control byte → \\xHH', () => { - expect(bytesToDisplay(Buffer.from([0x00]))).toBe('\\x00'); - }); -}); - -describe('bytesToChars', () => { - test('ESC CR → legacy shift+enter encoding', () => { - expect(bytesToChars(Buffer.from([0x1b, 0x0d]))).toBe('"\\u001b\\r"'); - }); - - test('ESC [ 1 3 ; 2 u → KKP shift+enter', () => { - expect(bytesToChars(Buffer.from([0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75]))).toBe( - '"\\u001b[13;2u"', - ); - }); - - test('printable ASCII passes through', () => { - expect(bytesToChars(Buffer.from('hello'))).toBe('"hello"'); - }); - - test('tab → \\t', () => { - expect(bytesToChars(Buffer.from([0x09]))).toBe('"\\t"'); - }); - - test('non-ASCII control byte → \\uXXXX', () => { - expect(bytesToChars(Buffer.from([0x00]))).toBe('"\\u0000"'); - }); - - test('double quote → \\" (valid JSON escape)', () => { - expect(bytesToChars(Buffer.from([0x22]))).toBe('"\\""'); - }); - - test('backslash → \\\\ (valid JSON escape)', () => { - expect(bytesToChars(Buffer.from([0x5c]))).toBe('"\\\\"'); - }); -}); - -describe('cli — no-arg, help, config', () => { - let port: number; - let baseUrl: string; - let serverProc: ChildProcess; - - beforeAll(async () => { - port = await getFreePort(); - baseUrl = `http://127.0.0.1:${port}`; - serverProc = spawn(process.execPath, [SERVER_ENTRY], { - env: { ...process.env, PORT: String(port), HOME: tmpHome }, - stdio: 'ignore', - }); - await waitForServerReady(baseUrl); - }); - - afterAll(() => { - serverProc.kill(); - }); - - test('no-arg creates main session and prints url', async () => { - const { stdout, exitCode } = await runCli(port); - expect(exitCode).toBe(0); - expect(stdout).toContain('/s/main'); - }); - - test('no-arg reuses main session without error', async () => { - const { stdout, exitCode } = await runCli(port); - expect(exitCode).toBe(0); - expect(stdout).toContain('/s/main'); - }); - - test('key exits with error when not a TTY', async () => { - const { stderr, exitCode } = await runCli(port, 'key'); - expect(exitCode).toBe(1); - expect(stderr).toContain('requires an interactive terminal'); - }); - - test('help prints usage', async () => { - const { stdout, exitCode } = await runCli(port, 'help'); - expect(exitCode).toBe(0); - expect(stdout).toContain('USAGE'); - expect(stdout).toContain('webtty'); - }); - - test('config opens config path in $VISUAL', async () => { - const expectedPath = path.join(tmpHome, '.config', 'webtty', 'config.json'); - const env: Record<string, string> = { - ...process.env, - PORT: String(port), - HOME: tmpHome, - VISUAL: 'echo', - }; - delete env.EDITOR; - const proc = Bun.spawn([process.execPath, CLI_ENTRY, 'config'], { - env, - stdout: 'pipe', - stderr: 'pipe', - }); - const [stdout] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - expect(exitCode).toBe(0); - expect(stdout.trim()).toContain(expectedPath); - }); -}); - -describe('cli — unit (mocked http)', () => { - let cmds: typeof import('./commands'); - let origFetch: typeof fetch; - - beforeAll(async () => { - origFetch = global.fetch; - cmds = await import('./commands'); - }); - - afterAll(() => { - global.fetch = origFetch; - }); - - test('cmdStop when running stops server', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(serverProcessModule, 'stopServer').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStop(); - expect(log).toHaveBeenCalledWith('webtty stopped'); - isRunning.mockRestore(); - stop.mockRestore(); - log.mockRestore(); - }); - - test('cmdStop when stop fails exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const stop = spyOn(serverProcessModule, 'stopServer').mockResolvedValueOnce(false); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdStop()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty stop failed'); - isRunning.mockRestore(); - stop.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdStop when not running logs not running', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStop(); - expect(log).toHaveBeenCalledWith('webtty is not running'); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdStart when not running starts server', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(serverProcessModule, 'startServer').mockResolvedValueOnce(undefined); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStart(); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith('webtty started'); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); - }); - - test('cmdStart when already running logs already running', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdStart(); - expect(log).toHaveBeenCalledWith('webtty is already running'); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdList when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdList()).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdList with sessions prints table', async () => { - const sessions = [{ id: 'main', connected: true, createdAt: 1700000000000 }]; - global.fetch = mock( - async () => new Response(JSON.stringify(sessions)), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); - log.mockRestore(); - }); - - test('cmdList with no sessions prints no sessions', async () => { - global.fetch = mock(async () => new Response(JSON.stringify([]))) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList(); - expect(log).toHaveBeenCalledWith('no sessions'); - log.mockRestore(); - }); - - test('cmdRemove without id exits with error', async () => { - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('requires a session id')); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRemove with valid id removes session', async () => { - global.fetch = mock( - async () => - new Response(null, { - status: 204, - headers: { 'x-sessions-remaining': '1' }, - }), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRemove('my-session'); - expect(log).toHaveBeenCalledWith('removed my-session'); - log.mockRestore(); - }); - - test('cmdRemove last session also stops server', async () => { - global.fetch = mock( - async () => - new Response(null, { - status: 204, - headers: { 'x-sessions-remaining': '0' }, - }), - ) as unknown as typeof fetch; - const stop = spyOn(serverProcessModule, 'stopServer').mockResolvedValueOnce(true); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRemove('last'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('webtty stopped')); - stop.mockRestore(); - log.mockRestore(); - }); - - test('cmdRemove non-existent session exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('ghost')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRemove fetch failure exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('bad')).rejects.toThrow('exit'); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRename without args exits with error', async () => { - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename()).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('requires two arguments')); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRename success logs renamed', async () => { - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRename('old', 'new'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('old')); - log.mockRestore(); - }); - - test('cmdRename not found exits with error', async () => { - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('not found')); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRename fetch error exits with error', async () => { - global.fetch = mock( - async () => new Response(JSON.stringify({ error: 'conflict' }), { status: 409 }), - ) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdGo when server not running starts it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(serverProcessModule, 'startServer').mockResolvedValueOnce(undefined); - global.fetch = mock(async (url: string) => { - if (url.includes('/api/sessions/main')) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ id: 'main' }), { status: 200 }); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); - }); - - test('cmdGo when session exists opens it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/main')); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdGo URL uses toBrowserHost applied to config host', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdGo('main'); - const printed: string = (log.mock.calls[0] as string[])[0]; - // URL must be a valid http URL ending in /s/main — host comes from config via toBrowserHost - expect(printed).toMatch(/^http:\/\/.+:\d+\/s\/main$/); - isRunning.mockRestore(); - log.mockRestore(); - }); - - test('cmdGo session creation failure exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async (url: string) => { - if (url.includes('/api/sessions/fail')) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); - }) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdGo('fail')).rejects.toThrow('exit'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdList when not running (fetch throws) exits', async () => { - global.fetch = mock(async () => { - throw new Error('conn'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdList(undefined)).rejects.toThrow('exit'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdList with filter shows matching sessions', async () => { - const sessions = [ - { id: 'main', connected: true, createdAt: 1700000000000 }, - { id: 'other', connected: false, createdAt: 1700000000000 }, - ]; - global.fetch = mock( - async () => new Response(JSON.stringify(sessions)), - ) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdList('main'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('main')); - log.mockRestore(); - }); - - test('cmdRemove when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRemove('any')).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRename when not running exits with error', async () => { - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const log = spyOn(console, 'log').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRename('x', 'y')).rejects.toThrow('exit'); - expect(log).toHaveBeenCalledWith('webtty is not running'); - log.mockRestore(); - exit.mockRestore(); - }); - - test('cmdConfig opens editor (file exists)', () => { - const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); - const existsSpy = spyOn(fsModule, 'existsSync').mockReturnValue(true); - const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( - {} as ReturnType<typeof childProcessModule.spawnSync>, - ); - cmds.cmdConfig(); - expect(spawnSpy).toHaveBeenCalled(); - mkdirSpy.mockRestore(); - existsSpy.mockRestore(); - spawnSpy.mockRestore(); - }); - - test('cmdConfig creates file when absent', () => { - const origHome = process.env.HOME; - process.env.HOME = `/tmp/webtty-cfg-absent-${Date.now()}`; - const mkdirSpy = spyOn(fsModule, 'mkdirSync').mockImplementation(() => undefined); - const spawnSpy = spyOn(childProcessModule, 'spawnSync').mockReturnValue( - {} as ReturnType<typeof childProcessModule.spawnSync>, - ); - cmds.cmdConfig(); - if (origHome === undefined) delete process.env.HOME; - else process.env.HOME = origHome; - mkdirSpy.mockRestore(); - spawnSpy.mockRestore(); - }); - - test('cmdKey exits with error when not a TTY', () => { - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - expect(() => cmds.cmdKey()).toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty key: requires an interactive terminal'); - Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdKey TTY mode captures and formats key presses', async () => { - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); - const exit = spyOn(process, 'exit').mockImplementation(() => undefined as never); - (process.stdin as NodeJS.ReadStream & { setRawMode: unknown }).setRawMode = mock( - () => process.stdin, - ); - const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); - const onSpy = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); - const log = spyOn(console, 'log').mockImplementation(() => {}); - - let capturedExitHandler: (() => void) | undefined; - const onceSpy = spyOn(process, 'once').mockImplementation((( - event: string | symbol, - handler: (...args: unknown[]) => void, - ) => { - if (event === 'exit') capturedExitHandler = handler as () => void; - return process; - }) as typeof process.once); - - cmds.cmdKey(); - - capturedExitHandler?.(); - - const dataHandler = ( - onSpy as unknown as { mock: { calls: Array<[string, (c: Buffer) => void]> } } - ).mock.calls.find((c) => c[0] === 'data')?.[1]; - - dataHandler?.(Buffer.from([0x61])); - await new Promise((r) => setTimeout(r, 60)); - dataHandler?.(Buffer.from([0x71])); - - (process.stdin as unknown as Record<string, unknown>).setRawMode = undefined; - Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); - exit.mockRestore(); - resume.mockRestore(); - onSpy.mockRestore(); - onceSpy.mockRestore(); - log.mockRestore(); - }); - - // cmdRun — exec mode - - test('cmdRun with cmd when server not running exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: server is not running'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd when fetch throws exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => { - throw new Error('ECONNREFUSED'); - }) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: server is not running'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd 404 exits with session not found', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: session not found: sess'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd 409 exits with PTY not running message', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 409 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: PTY not running for session: sess'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd non-ok status exits with run failed', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: run failed (500)'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd streams stdout and exits 0', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const enc = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'hello\n' })}\n`), - ); - controller.enqueue(enc.encode(`${JSON.stringify({ exit: 0 })}\n`)); - controller.close(); - }, - }); - global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; - const write = spyOn(process.stdout, 'write').mockImplementation(() => true); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit0'); - }); - await expect(cmds.cmdRun('sess', 'echo', ['hello'])).rejects.toThrow('exit0'); - expect(write).toHaveBeenCalledWith('hello\n'); - expect(exit).toHaveBeenCalledWith(0); - isRunning.mockRestore(); - write.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd exits 1 when stream ends without exit line', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const enc = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'partial\n' })}\n`), - ); - controller.close(); - }, - }); - global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; - const write = spyOn(process.stdout, 'write').mockImplementation(() => true); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('sess', 'cmd')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: connection closed unexpectedly'); - expect(exit).toHaveBeenCalledWith(1); - isRunning.mockRestore(); - write.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun with cmd streams stderr and exits 1 with error message', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - const enc = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - enc.encode(`${JSON.stringify({ stream: 'stderr', data: 'err output\n' })}\n`), - ); - controller.enqueue(enc.encode(`${JSON.stringify({ exit: 1, error: 'command failed' })}\n`)); - controller.close(); - }, - }); - global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; - const writeErr = spyOn(process.stderr, 'write').mockImplementation(() => true); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit1'); - }); - await expect(cmds.cmdRun('sess', 'bad-cmd')).rejects.toThrow('exit1'); - expect(writeErr).toHaveBeenCalledWith('err output\n'); - expect(err).toHaveBeenCalledWith('webtty: command failed'); - expect(exit).toHaveBeenCalledWith(1); - isRunning.mockRestore(); - writeErr.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - // cmdRun — warm-up mode (uses global WebSocket) - - function makeMockWebSocket(behavior: 'open' | 'error' | 'close-without-open') { - return class MockWebSocket { - onopen: (() => void) | null = null; - onerror: (() => void) | null = null; - onclose: ((evt: CloseEvent) => void) | null = null; - constructor(_url: string) { - queueMicrotask(() => { - if (behavior === 'open') { - this.onopen?.(); - } else if (behavior === 'error') { - this.onerror?.(); - } else { - this.onclose?.({ code: 1006 } as CloseEvent); - } - }); - } - close(_code?: number) { - queueMicrotask(() => this.onclose?.({ code: 1000 } as CloseEvent)); - } - } as unknown as typeof WebSocket; - } - - test('cmdRun warm-up when server running and session exists prints url', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const origWS = global.WebSocket; - global.WebSocket = makeMockWebSocket('open'); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRun('warmup-sess'); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/warmup-sess')); - isRunning.mockRestore(); - log.mockRestore(); - global.WebSocket = origWS; - }); - - test('cmdRun warm-up when server not running starts it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); - const start = spyOn(serverProcessModule, 'startServer').mockResolvedValueOnce(undefined); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const origWS = global.WebSocket; - global.WebSocket = makeMockWebSocket('open'); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRun('warmup-sess2'); - expect(start).toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/warmup-sess2')); - isRunning.mockRestore(); - start.mockRestore(); - log.mockRestore(); - global.WebSocket = origWS; - }); - - test('cmdRun warm-up when session not found creates it', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - let callCount = 0; - global.fetch = mock(async () => { - callCount++; - if (callCount === 1) return new Response(null, { status: 404 }); // session check - return new Response(JSON.stringify({ id: 'new-sess' }), { status: 200 }); // create session - }) as unknown as typeof fetch; - const origWS = global.WebSocket; - global.WebSocket = makeMockWebSocket('open'); - const log = spyOn(console, 'log').mockImplementation(() => {}); - await cmds.cmdRun('new-sess'); - expect(callCount).toBe(2); - expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/new-sess')); - isRunning.mockRestore(); - log.mockRestore(); - global.WebSocket = origWS; - }); - - test('cmdRun warm-up when session creation fails exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - let callCount = 0; - global.fetch = mock(async () => { - callCount++; - if (callCount === 1) return new Response(null, { status: 404 }); - return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); - }) as unknown as typeof fetch; - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('fail-sess')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith('webtty: bad'); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - }); - - test('cmdRun warm-up when WebSocket errors exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const origWS = global.WebSocket; - global.WebSocket = makeMockWebSocket('error'); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('ws-err-sess')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('failed to start PTY')); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - global.WebSocket = origWS; - }); - - test('cmdRun warm-up when WebSocket closes before open exits with error', async () => { - const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; - const origWS = global.WebSocket; - global.WebSocket = makeMockWebSocket('close-without-open'); - const err = spyOn(console, 'error').mockImplementation(() => {}); - const exit = spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit'); - }); - await expect(cmds.cmdRun('ws-close-sess')).rejects.toThrow('exit'); - expect(err).toHaveBeenCalledWith(expect.stringContaining('failed to start PTY')); - isRunning.mockRestore(); - err.mockRestore(); - exit.mockRestore(); - global.WebSocket = origWS; - }); -}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts deleted file mode 100644 index 60370bd..0000000 --- a/src/cli/commands.ts +++ /dev/null @@ -1,357 +0,0 @@ -import * as childProcess from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { configDir, loadConfig } from '../config'; -import { LineSplitter } from '../ndjson'; -import { openBrowser, toBrowserHost } from './browser-opener'; -import { getBaseUrl, getPort, isServerRunning } from './http'; -import { startServer, stopServer } from './server-process'; - -/** - * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. - * - * @param id - The session ID to open (default: `'main'`). - * @param baseDir - Working directory for the PTY shell (optional). - */ -export async function cmdGo(id = 'main', baseDir?: string): Promise<void> { - if (!(await isServerRunning())) { - await startServer(); - } - - let sessionId: string; - const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); - if (check.status === 200) { - sessionId = id; - } else { - const res = await fetch(`${getBaseUrl()}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, ...(baseDir ? { baseDir } : {}) }), - }); - if (!res.ok) { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); - process.exit(1); - } - const session = (await res.json()) as { id: string }; - sessionId = session.id; - } - - const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${sessionId}`; - console.log(url); - openBrowser(url); -} - -/** - * Lists all active sessions, optionally filtered by a substring of the session ID. - * - * @param filter - Optional substring to filter session IDs. - */ -export async function cmdList(filter?: string): Promise<void> { - let res: Response; - try { - res = await fetch(`${getBaseUrl()}/api/sessions`); - } catch { - console.log('webtty is not running'); - process.exit(1); - } - const all = (await res.json()) as Array<{ - id: string; - connected: boolean; - createdAt: number; - }>; - const sessions = filter ? all.filter((s) => s.id.includes(filter)) : all; - if (sessions.length === 0) { - console.log('no sessions'); - return; - } - console.log('id\t\t\tconnected\tcreated'); - for (const s of sessions) { - const created = new Date(s.createdAt).toLocaleString(); - console.log(`${s.id}\t\t\t${s.connected}\t\t${created}`); - } -} - -/** - * Removes session `id` and stops the server if no sessions remain. - * - * @param id - The session ID to remove. - */ -export async function cmdRemove(id?: string): Promise<void> { - if (!id) { - console.error('webtty: rm requires a session id'); - process.exit(1); - } - let res: Response; - try { - res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, { - method: 'DELETE', - }); - } catch { - console.log('webtty is not running'); - process.exit(1); - } - if (res.status === 204) { - console.log(`removed ${id}`); - if (res.headers.get('x-sessions-remaining') === '0') { - await stopServer(); - console.log('no sessions remaining — webtty stopped'); - } - } else if (res.status === 404) { - console.error(`session ${id} not found`); - process.exit(1); - } else { - console.error(`webtty rm failed (status: ${res.status})`); - process.exit(1); - } -} - -/** - * Renames session `id` to `newId`. - * - * @param id - The current session ID. - * @param newId - The new session ID. - */ -export async function cmdRename(id?: string, newId?: string): Promise<void> { - if (!id || !newId) { - console.error('webtty: rename requires two arguments: [id] [new-id]'); - process.exit(1); - } - let res: Response; - try { - res = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: newId }), - }); - } catch { - console.log('webtty is not running'); - process.exit(1); - } - if (res.ok) { - console.log(`renamed ${id} → ${newId}`); - } else if (res.status === 404) { - console.error(`session ${id} not found`); - process.exit(1); - } else { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `rename failed (${res.status})`}`); - process.exit(1); - } -} - -/** Stops the server if it is running. */ -export async function cmdStop(): Promise<void> { - if (!(await isServerRunning())) { - console.log('webtty is not running'); - return; - } - const ok = await stopServer(); - if (ok) { - console.log('webtty stopped'); - } else { - console.error('webtty stop failed'); - process.exit(1); - } -} - -/** Starts the server if it is not already running. */ -export async function cmdStart(): Promise<void> { - if (await isServerRunning()) { - console.log('webtty is already running'); - return; - } - await startServer(); - console.log('webtty started'); -} - -/** Opens `~/.config/webtty/config.json` in `$VISUAL` / `$EDITOR`, creating it if absent. */ -export function cmdConfig(): void { - const dir = configDir(); - const configPath = path.join(dir, 'config.json'); - fs.mkdirSync(dir, { recursive: true }); - if (!fs.existsSync(configPath)) { - fs.writeFileSync(configPath, '{}\n', 'utf8'); - } - const editor = - process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'vi'); - childProcess.spawnSync(editor, [configPath], { stdio: 'inherit' }); -} - -export function bytesToChars(buf: Buffer): string { - let out = ''; - for (const b of buf) { - if (b === 0x1b) out += '\\u001b'; - else if (b === 0x0d) out += '\\r'; - else if (b === 0x09) out += '\\t'; - else if (b === 0x0a) out += '\\n'; - else if (b === 0x22) out += '\\"'; - else if (b === 0x5c) out += '\\\\'; - else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); - else out += `\\u${b.toString(16).padStart(4, '0')}`; - } - return `"${out}"`; -} - -export function bytesToDisplay(buf: Buffer): string { - return Array.from(buf) - .map((b) => { - if (b === 0x1b) return 'ESC'; - if (b === 0x0d) return 'CR'; - if (b === 0x09) return 'TAB'; - if (b === 0x0a) return 'LF'; - if (b === 0x20) return 'SPC'; - if (b === 0x7f) return 'DEL'; - if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); - return `\\x${b.toString(16).padStart(2, '0')}`; - }) - .join(' '); -} - -export async function cmdRun(id: string, cmd?: string, args: string[] = []): Promise<void> { - if (!cmd) { - // Warm-up mode: start server + session + PTY, no browser - if (!(await isServerRunning())) { - await startServer(); - } - - const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); - if (check.status !== 200) { - const res = await fetch(`${getBaseUrl()}/api/sessions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - if (!res.ok) { - const body = (await res.json()) as { error?: string }; - console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); - process.exit(1); - } - } - - const wsUrl = `ws://127.0.0.1:${getPort()}/ws/${encodeURIComponent(id)}/pty?cols=80&rows=24`; - await new Promise<void>((resolve, reject) => { - const ws = new WebSocket(wsUrl); - let opened = false; - ws.onopen = () => { - opened = true; - ws.close(1000); - resolve(); - }; - ws.onerror = () => reject(new Error('failed to connect to PTY')); - ws.onclose = (evt) => { - if (!opened) reject(new Error(`failed to start PTY (code ${(evt as CloseEvent).code})`)); - }; - }).catch((err: Error) => { - console.error(`webtty: failed to start PTY: ${err.message}`); - process.exit(1); - }); - - const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${id}`; - console.log(url); - return; - } - - // Exec mode: headless command execution - if (!(await isServerRunning())) { - console.error('webtty: server is not running'); - process.exit(1); - } - - let res: Response; - try { - res = await fetch(`${getBaseUrl()}/s/${encodeURIComponent(id)}/execute`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cmd, args }), - }); - } catch { - console.error('webtty: server is not running'); - process.exit(1); - } - - if (res.status === 404) { - console.error(`webtty: session not found: ${id}`); - process.exit(1); - } - if (res.status === 409) { - console.error(`webtty: PTY not running for session: ${id}`); - process.exit(1); - } - if (!res.ok || !res.body) { - console.error(`webtty: run failed (${res.status})`); - process.exit(1); - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - // The server never sends CR; keeping it leaves the lines exactly as they were before the splitter. - const lines = new LineSplitter({ stripCarriageReturn: false }); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - for (const line of lines.push(decoder.decode(value, { stream: true }))) { - const msg = JSON.parse(line) as Record<string, unknown>; - if (msg.stream === 'stdout') process.stdout.write(String(msg.data)); - else if (msg.stream === 'stderr') process.stderr.write(String(msg.data)); - else if ('exit' in msg) { - if (msg.error) console.error(`webtty: ${msg.error}`); - process.exit((msg.exit as number) ?? 1); - } - } - } - console.error('webtty: connection closed unexpectedly'); - process.exit(1); -} - -export function cmdKey(): void { - if (!process.stdin.isTTY) { - console.error('webtty key: requires an interactive terminal'); - process.exit(1); - return; - } - - const dim = '\x1b[2m'; - const bold = '\x1b[1m'; - const reset = '\x1b[0m'; - - const restoreTerminal = () => { - try { - process.stdin.setRawMode(false); - } catch {} - }; - process.once('exit', restoreTerminal); - process.once('SIGINT', restoreTerminal); - process.once('SIGTERM', restoreTerminal); - - process.stdin.setRawMode(true); - process.stdin.resume(); - console.log('Press any key combo to see its chars value. q to quit.\n'); - console.log(` ${dim}received${reset} → ${bold}chars${reset}`); - console.log(` ${'─'.repeat(17)}`); - - let buf = Buffer.alloc(0); - let timer: ReturnType<typeof setTimeout> | null = null; - - const flush = () => { - if (buf.length === 0) return; - const display = bytesToDisplay(buf).padEnd(8); - console.log(` ${dim}${display}${reset} → ${bold}${bytesToChars(buf)}${reset}`); - buf = Buffer.alloc(0); - }; - - process.stdin.on('data', (chunk: Buffer) => { - if (chunk.length === 1 && chunk[0] === 0x71) { - process.stdin.setRawMode(false); - process.removeListener('exit', restoreTerminal); - process.removeListener('SIGINT', restoreTerminal); - process.removeListener('SIGTERM', restoreTerminal); - console.log(` ${'─'.repeat(17)}\n`); - process.exit(0); - } - buf = Buffer.concat([buf, chunk]); - if (timer) clearTimeout(timer); - timer = setTimeout(flush, 50); - }); -} diff --git a/src/cli/commands/edit-config.test.ts b/src/cli/commands/edit-config.test.ts new file mode 100644 index 0000000..e930cff --- /dev/null +++ b/src/cli/commands/edit-config.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fakeCliDeps } from '../test-support/fake-cli-deps'; +import { type EditorHost, editConfig, processEditorHost } from './edit-config'; + +/** An editor host over an in-memory file set that records the editor it ran. */ +function fakeEditor(env: NodeJS.ProcessEnv, platform = 'linux', existing: string[] = []) { + const calls: string[] = []; + const files = new Map<string, string>(existing.map((f) => [f, 'kept'])); + const editor: EditorHost = { + configDir: () => '/home/u/.config/webtty', + mkdirp: (dir) => calls.push(`mkdirp ${dir}`), + exists: (file) => files.has(file), + writeFile: (file, text) => { + calls.push(`write ${file}`); + files.set(file, text); + }, + env, + platform, + runAttached: (command, args) => calls.push(`run ${command} ${args.join(' ')}`), + }; + return { editor, calls, files }; +} + +const CONFIG = '/home/u/.config/webtty/config.json'; + +describe('editConfig', () => { + test('creates a missing config as {} and opens it in $VISUAL', () => { + const fake = fakeEditor({ VISUAL: 'code -w', EDITOR: 'nano' }); + editConfig(fakeCliDeps({}, { editor: fake.editor }).deps); + expect(fake.calls).toEqual([ + 'mkdirp /home/u/.config/webtty', + `write ${CONFIG}`, + `run code -w ${CONFIG}`, + ]); + expect(fake.files.get(CONFIG)).toBe('{}\n'); + }); + + test('leaves an existing config alone', () => { + const fake = fakeEditor({ EDITOR: 'nano' }, 'linux', [CONFIG]); + editConfig(fakeCliDeps({}, { editor: fake.editor }).deps); + expect(fake.calls).toEqual(['mkdirp /home/u/.config/webtty', `run nano ${CONFIG}`]); + expect(fake.files.get(CONFIG)).toBe('kept'); + }); + + const fallbacks: Array<[string, string]> = [ + ['win32', 'notepad'], + ['darwin', 'vi'], + ['linux', 'vi'], + ]; + for (const [platform, editorName] of fallbacks) { + test(`with neither variable set, ${platform} uses ${editorName}`, () => { + const fake = fakeEditor({}, platform, [CONFIG]); + editConfig(fakeCliDeps({}, { editor: fake.editor }).deps); + expect(fake.calls.at(-1)).toBe(`run ${editorName} ${CONFIG}`); + }); + } +}); + +describe('processEditorHost', () => { + test('reads the live environment and platform', () => { + const host = processEditorHost(); + expect(host.env).toBe(process.env); + expect(host.platform).toBe(process.platform); + expect(host.configDir()).toBe(path.join(os.homedir(), '.config', 'webtty')); + }); + + test('creates directories, checks and writes files, and runs a command to completion', () => { + const host = processEditorHost(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-editor-')); + try { + const nested = path.join(dir, 'a', 'b'); + host.mkdirp(nested); + const file = path.join(nested, 'config.json'); + expect(host.exists(file)).toBe(false); + host.writeFile(file, '{}\n'); + expect(host.exists(file)).toBe(true); + expect(fs.readFileSync(file, 'utf8')).toBe('{}\n'); + host.runAttached(process.execPath, [ + '-e', + `require('fs').writeFileSync(${JSON.stringify(file)}, 'ran')`, + ]); + expect(fs.readFileSync(file, 'utf8')).toBe('ran'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/commands/edit-config.ts b/src/cli/commands/edit-config.ts new file mode 100644 index 0000000..bc2a378 --- /dev/null +++ b/src/cli/commands/edit-config.ts @@ -0,0 +1,50 @@ +// `webtty config`: open `~/.config/webtty/config.json` in the user's editor, creating it first. +// A command over an injected EditorHost (filesystem, environment, child process) (ADR 063). +import * as childProcess from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { configDir } from '../../config'; +import type { CliDeps } from '../cli-deps'; + +/** The filesystem, environment and process `webtty config` touches. */ +export interface EditorHost { + /** The config directory, `~/.config/webtty`. */ + configDir(): string; + /** Creates `dir` and its parents if missing. */ + mkdirp(dir: string): void; + exists(file: string): boolean; + writeFile(file: string, text: string): void; + /** Read for `$VISUAL` and `$EDITOR` when the command runs. */ + env: NodeJS.ProcessEnv; + platform: string; + /** Runs `command args` on this terminal and waits for it to exit. */ + runAttached(command: string, args: string[]): void; +} + +/** The live filesystem, environment and `child_process.spawnSync`. */ +export function processEditorHost(): EditorHost { + return { + configDir, + mkdirp: (dir) => fs.mkdirSync(dir, { recursive: true }), + exists: (file) => fs.existsSync(file), + writeFile: (file, text) => fs.writeFileSync(file, text, 'utf8'), + env: process.env, + platform: process.platform, + runAttached: (command, args) => { + childProcess.spawnSync(command, args, { stdio: 'inherit' }); + }, + }; +} + +/** Opens the config file in `$VISUAL`, else `$EDITOR`, else notepad (Windows) or vi. */ +export function editConfig({ editor }: Pick<CliDeps, 'editor'>): void { + const dir = editor.configDir(); + const configPath = path.join(dir, 'config.json'); + editor.mkdirp(dir); + if (!editor.exists(configPath)) { + editor.writeFile(configPath, '{}\n'); + } + const command = + editor.env.VISUAL ?? editor.env.EDITOR ?? (editor.platform === 'win32' ? 'notepad' : 'vi'); + editor.runAttached(command, [configPath]); +} diff --git a/src/cli/commands/go.test.ts b/src/cli/commands/go.test.ts new file mode 100644 index 0000000..522a2f5 --- /dev/null +++ b/src/cli/commands/go.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps, RUNNING, STOPPED } from '../test-support/fake-cli-deps'; +import { json } from '../test-support/scripted-fetch'; +import { go } from './go'; + +const URL_MAIN = 'http://localhost:7681/s/main'; + +describe('go', () => { + test('opens an existing session on a running server', async () => { + const fake = fakeCliDeps({ ...RUNNING, 'GET /api/sessions/main': () => json({ id: 'main' }) }); + await go({}, fake.deps); + expect(fake.out).toEqual([URL_MAIN]); + expect(fake.opened).toEqual([URL_MAIN]); + expect(fake.server).toEqual([]); + expect([fake.err, fake.exits]).toEqual([[], []]); + }); + + test('starts the server, creates the session in --dir and opens the id the server gave', async () => { + const fake = fakeCliDeps({ + ...STOPPED, + 'GET /api/sessions/new': () => json({ error: 'not found' }, 404), + 'POST /api/sessions': () => json({ id: 'new' }, 201), + }); + await go({ id: 'new', baseDir: '/work' }, fake.deps); + expect(fake.server).toEqual(['start']); + expect(fake.requests.at(-1)?.body).toEqual({ id: 'new', baseDir: '/work' }); + expect(fake.out).toEqual(['http://localhost:7681/s/new']); + expect(fake.opened).toEqual(['http://localhost:7681/s/new']); + }); + + test('prints the server error and exits 1 when the session cannot be created', async () => { + const fake = fakeCliDeps({ + ...RUNNING, + 'GET /api/sessions/bad': () => json({}, 404), + 'POST /api/sessions': () => json({ error: 'bad' }, 500), + }); + await go({ id: 'bad' }, fake.deps); + expect(fake.err).toEqual(['webtty: bad']); + expect(fake.exits).toEqual([1]); + expect([fake.out, fake.opened]).toEqual([[], []]); + }); + + test('without a server error it names the status', async () => { + const fake = fakeCliDeps({ + ...RUNNING, + 'GET /api/sessions/bad': () => json({}, 404), + 'POST /api/sessions': () => json({}, 503), + }); + await go({ id: 'bad' }, fake.deps); + expect(fake.err).toEqual(['webtty: failed to create session (503)']); + expect(fake.exits).toEqual([1]); + }); + + test('the URL brackets an IPv6 bind host', async () => { + const fake = fakeCliDeps({ ...RUNNING, 'GET /api/sessions/main': () => json({}) }); + const config = fake.deps.config(); + fake.deps.config = () => ({ ...config, host: '::1' }); + await go({}, fake.deps); + expect(fake.out).toEqual(['http://[::1]:7681/s/main']); + }); +}); diff --git a/src/cli/commands/go.ts b/src/cli/commands/go.ts new file mode 100644 index 0000000..5901aa2 --- /dev/null +++ b/src/cli/commands/go.ts @@ -0,0 +1,33 @@ +// `webtty go [id]` (and `a`, `attach`, `open`, and a bare `webtty`): open a session in the +// browser, starting the server and creating the session when needed. A command (ADR 063). +import { sessionPageUrl } from '../browser-opener'; +import type { CliDeps } from '../cli-deps'; + +/** The arguments of `go`. */ +export interface GoArgs { + /** The session to open; `main` when omitted. */ + id?: string; + /** The working directory for a new session's shell (`--dir`). */ + baseDir?: string; +} + +/** Opens (or creates) the session and prints its URL; exits 1 if it cannot be created. */ +export async function go( + { id = 'main', baseDir }: GoArgs, + deps: Pick<CliDeps, 'client' | 'server' | 'config' | 'out' | 'err' | 'exit' | 'openBrowser'>, +): Promise<void> { + if (!(await deps.client.isRunning())) { + await deps.server.start(); + } + + const session = await deps.client.ensureSession(id, baseDir); + if ('error' in session) { + deps.err(`webtty: ${session.error}`); + deps.exit(1); + return; + } + + const url = sessionPageUrl(deps.config(), session.id); + deps.out(url); + deps.openBrowser(url); +} diff --git a/src/cli/commands/key-format.test.ts b/src/cli/commands/key-format.test.ts new file mode 100644 index 0000000..6a39e9d --- /dev/null +++ b/src/cli/commands/key-format.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; +import { bytesToChars, bytesToDisplay } from './key-format'; + +describe('bytesToDisplay', () => { + const cases: Array<[string, number[], string]> = [ + ['ESC CR → legacy shift+enter', [0x1b, 0x0d], 'ESC CR'], + [ + 'ESC [ 1 3 ; 2 u → KKP shift+enter', + [0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75], + 'ESC [ 1 3 ; 2 u', + ], + ['tab', [0x09], 'TAB'], + ['line feed', [0x0a], 'LF'], + ['space', [0x20], 'SPC'], + ['delete', [0x7f], 'DEL'], + ['printable ASCII', [0x41, 0x7e], 'A ~'], + ['an unnamed control byte', [0x00], '\\x00'], + ['a byte above ASCII', [0xc3], '\\xc3'], + ]; + for (const [name, bytes, expected] of cases) { + test(name, () => { + expect(bytesToDisplay(Buffer.from(bytes))).toBe(expected); + }); + } +}); + +describe('bytesToChars', () => { + const cases: Array<[string, number[], string]> = [ + ['ESC CR → legacy shift+enter', [0x1b, 0x0d], '"\\u001b\\r"'], + [ + 'ESC [ 1 3 ; 2 u → KKP shift+enter', + [0x1b, 0x5b, 0x31, 0x33, 0x3b, 0x32, 0x75], + '"\\u001b[13;2u"', + ], + ['printable ASCII passes through', [...Buffer.from('hello')], '"hello"'], + ['tab', [0x09], '"\\t"'], + ['line feed', [0x0a], '"\\n"'], + ['double quote (a valid JSON escape)', [0x22], '"\\""'], + ['backslash (a valid JSON escape)', [0x5c], '"\\\\"'], + ['other control byte → \\uXXXX', [0x00], '"\\u0000"'], + ['delete → \\u007f', [0x7f], '"\\u007f"'], + ]; + for (const [name, bytes, expected] of cases) { + test(name, () => { + expect(bytesToChars(Buffer.from(bytes))).toBe(expected); + }); + } + + test('every output is a JSON string that parses back to the input', () => { + const bytes = Buffer.from([0x00, 0x09, 0x0a, 0x0d, 0x1b, 0x22, 0x41, 0x5c, 0x7f]); + expect(JSON.parse(bytesToChars(bytes))).toBe(bytes.toString('latin1')); + }); +}); diff --git a/src/cli/commands/key-format.ts b/src/cli/commands/key-format.ts new file mode 100644 index 0000000..9a5ee5b --- /dev/null +++ b/src/cli/commands/key-format.ts @@ -0,0 +1,34 @@ +// How `webtty key` shows the bytes a key combo sent: once readable, once as the JSON string a +// `keyboardBindings` entry's `chars` takes. Pure utils (ADR 063). + +/** The bytes as a JSON string literal, ready to paste as a `chars` value. */ +export function bytesToChars(buf: Uint8Array): string { + let out = ''; + for (const b of buf) { + if (b === 0x1b) out += '\\u001b'; + else if (b === 0x0d) out += '\\r'; + else if (b === 0x09) out += '\\t'; + else if (b === 0x0a) out += '\\n'; + else if (b === 0x22) out += '\\"'; + else if (b === 0x5c) out += '\\\\'; + else if (b >= 0x20 && b < 0x7f) out += String.fromCharCode(b); + else out += `\\u${b.toString(16).padStart(4, '0')}`; + } + return `"${out}"`; +} + +/** The bytes as space-separated names (`ESC`, `CR`, `TAB`, ...), printable ASCII as itself. */ +export function bytesToDisplay(buf: Uint8Array): string { + return Array.from(buf) + .map((b) => { + if (b === 0x1b) return 'ESC'; + if (b === 0x0d) return 'CR'; + if (b === 0x09) return 'TAB'; + if (b === 0x0a) return 'LF'; + if (b === 0x20) return 'SPC'; + if (b === 0x7f) return 'DEL'; + if (b > 0x20 && b < 0x7f) return String.fromCharCode(b); + return `\\x${b.toString(16).padStart(2, '0')}`; + }) + .join(' '); +} diff --git a/src/cli/commands/key.test.ts b/src/cli/commands/key.test.ts new file mode 100644 index 0000000..672092c --- /dev/null +++ b/src/cli/commands/key.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, spyOn, test } from 'bun:test'; +import { fakeCliDeps } from '../test-support/fake-cli-deps'; +import { type KeyTty, key, type ProcessEndEvent, processKeyTty } from './key'; + +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; +const RULE = ` ${'─'.repeat(17)}`; + +/** A TTY that records raw mode, listeners and process hooks, and lets the test type bytes. */ +function fakeTty(isTTY = true) { + const rawMode: boolean[] = []; + const hooks: Array<[string, ProcessEndEvent]> = []; + const restorers = new Map<ProcessEndEvent, () => void>(); + let onData: ((chunk: Uint8Array) => void) | undefined; + let resumed = false; + const tty: KeyTty = { + isTTY, + setRawMode: (on) => { + rawMode.push(on); + }, + resume: () => { + resumed = true; + }, + onData: (listener) => { + onData = listener; + }, + once: (event, listener) => { + hooks.push(['once', event]); + restorers.set(event, listener); + }, + off: (event, listener) => { + hooks.push(['off', event]); + if (restorers.get(event) === listener) restorers.delete(event); + }, + }; + const type = (...bytes: number[]) => onData?.(Buffer.from(bytes)); + return { tty, rawMode, hooks, restorers, type, resumed: () => resumed }; +} + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('key', () => { + test('exits 1 when stdin is not a terminal', () => { + const { tty, rawMode } = fakeTty(false); + const fake = fakeCliDeps({}, { tty }); + key(fake.deps); + expect(fake.err).toEqual(['webtty key: requires an interactive terminal']); + expect(fake.exits).toEqual([1]); + expect(rawMode).toEqual([]); + }); + + test('prints the header, then each combo once its bytes stop arriving', async () => { + const t = fakeTty(); + const fake = fakeCliDeps({}, { tty: t.tty }); + key(fake.deps); + expect(t.rawMode).toEqual([true]); + expect(t.resumed()).toBe(true); + expect(fake.out).toEqual([ + 'Press any key combo to see its chars value. q to quit.\n', + ` ${DIM}received${RESET} → ${BOLD}chars${RESET}`, + RULE, + ]); + + t.type(0x1b); + t.type(0x0d); + await wait(70); + expect(fake.out.at(-1)).toBe( + ` ${DIM}${'ESC CR'.padEnd(8)}${RESET} → ${BOLD}"\\u001b\\r"${RESET}`, + ); + expect(fake.out).toHaveLength(4); + + t.type(0x61); + await wait(70); + expect(fake.out.at(-1)).toBe(` ${DIM}${'a'.padEnd(8)}${RESET} → ${BOLD}"a"${RESET}`); + expect(fake.exits).toEqual([]); + }); + + test('q leaves raw mode, removes the restore hooks, prints the rule and exits 0', () => { + const t = fakeTty(); + const fake = fakeCliDeps({}, { tty: t.tty }); + key(fake.deps); + t.type(0x71); + expect(t.rawMode).toEqual([true, false]); + expect(t.hooks).toEqual([ + ['once', 'exit'], + ['once', 'SIGINT'], + ['once', 'SIGTERM'], + ['off', 'exit'], + ['off', 'SIGINT'], + ['off', 'SIGTERM'], + ]); + expect(t.restorers.size).toBe(0); + expect(fake.out.at(-1)).toBe(`${RULE}\n`); + expect(fake.exits).toEqual([0]); + }); + + test('q inside a longer chunk is an ordinary byte', async () => { + const t = fakeTty(); + const fake = fakeCliDeps({}, { tty: t.tty }); + key(fake.deps); + t.type(0x71, 0x71); + await wait(70); + expect(fake.exits).toEqual([]); + expect(fake.out.at(-1)).toBe(` ${DIM}${'q q'.padEnd(8)}${RESET} → ${BOLD}"qq"${RESET}`); + }); + + test('the restore hook leaves raw mode, and ignores a terminal that refuses', () => { + const t = fakeTty(); + key(fakeCliDeps({}, { tty: t.tty }).deps); + t.restorers.get('SIGINT')?.(); + expect(t.rawMode).toEqual([true, false]); + t.tty.setRawMode = () => { + throw new Error('not a tty'); + }; + expect(() => t.restorers.get('exit')?.()).not.toThrow(); + }); +}); + +describe('processKeyTty', () => { + test('reads isTTY from process.stdin when asked', () => { + const tty = processKeyTty(); + const saved = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + try { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + expect(tty.isTTY).toBe(true); + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }); + expect(tty.isTTY).toBe(false); + } finally { + if (saved) Object.defineProperty(process.stdin, 'isTTY', saved); + else Reflect.deleteProperty(process.stdin, 'isTTY'); + } + }); + + test('forwards to process.stdin and process', () => { + const stdin = process.stdin as typeof process.stdin & { setRawMode?: (on: boolean) => void }; + const savedSetRawMode = stdin.setRawMode; + const rawMode: boolean[] = []; + stdin.setRawMode = (on: boolean) => { + rawMode.push(on); + return process.stdin; + }; + const resume = spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + const on = spyOn(process.stdin, 'on').mockImplementation(() => process.stdin); + const once = spyOn(process, 'once').mockImplementation(() => process); + const off = spyOn(process, 'removeListener').mockImplementation(() => process); + try { + const tty = processKeyTty(); + const listener = () => {}; + tty.setRawMode(true); + tty.resume(); + tty.onData(listener); + tty.once('SIGTERM', listener); + tty.off('SIGTERM', listener); + expect(rawMode).toEqual([true]); + expect(resume).toHaveBeenCalledTimes(1); + expect(on).toHaveBeenCalledWith('data', listener); + expect(once).toHaveBeenCalledWith('SIGTERM', listener); + expect(off).toHaveBeenCalledWith('SIGTERM', listener); + } finally { + stdin.setRawMode = savedSetRawMode; + resume.mockRestore(); + on.mockRestore(); + once.mockRestore(); + off.mockRestore(); + } + }); +}); diff --git a/src/cli/commands/key.ts b/src/cli/commands/key.ts new file mode 100644 index 0000000..49e245a --- /dev/null +++ b/src/cli/commands/key.ts @@ -0,0 +1,97 @@ +// `webtty key`: put the terminal in raw mode and print each key combo's bytes, readable and as a +// `chars` value, until `q`. A command over an injected KeyTty (ADR 063). +import type { CliDeps } from '../cli-deps'; +import { bytesToChars, bytesToDisplay } from './key-format'; + +/** Process events after which the terminal must leave raw mode. */ +export type ProcessEndEvent = 'exit' | 'SIGINT' | 'SIGTERM'; + +/** The terminal and process hooks `webtty key` uses. */ +export interface KeyTty { + /** Whether stdin is an interactive terminal, read when the command runs. */ + readonly isTTY: boolean; + setRawMode(on: boolean): void; + resume(): void; + onData(listener: (chunk: Uint8Array) => void): void; + /** `process.once`. */ + once(event: ProcessEndEvent, listener: () => void): void; + /** `process.removeListener`. */ + off(event: ProcessEndEvent, listener: () => void): void; +} + +/** Bytes that arrive within this many ms of each other print as one key combo. */ +const COMBO_GAP_MS = 50; +const END_EVENTS: readonly ProcessEndEvent[] = ['exit', 'SIGINT', 'SIGTERM']; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; +const RULE = ` ${'─'.repeat(17)}`; + +/** The live `process.stdin` and `process`. */ +export function processKeyTty(): KeyTty { + return { + get isTTY() { + return Boolean(process.stdin.isTTY); + }, + setRawMode: (on) => { + process.stdin.setRawMode(on); + }, + resume: () => { + process.stdin.resume(); + }, + onData: (listener) => { + process.stdin.on('data', listener); + }, + once: (event, listener) => { + process.once(event, listener); + }, + off: (event, listener) => { + process.removeListener(event, listener); + }, + }; +} + +/** Prints each key combo until `q`; exits 1 when stdin is not a terminal. */ +export function key({ tty, out, err, exit }: Pick<CliDeps, 'tty' | 'out' | 'err' | 'exit'>): void { + if (!tty.isTTY) { + err('webtty key: requires an interactive terminal'); + exit(1); + return; + } + + const restoreTerminal = () => { + try { + tty.setRawMode(false); + } catch {} + }; + for (const event of END_EVENTS) tty.once(event, restoreTerminal); + + tty.setRawMode(true); + tty.resume(); + out('Press any key combo to see its chars value. q to quit.\n'); + out(` ${DIM}received${RESET} → ${BOLD}chars${RESET}`); + out(RULE); + + let buf = Buffer.alloc(0); + let timer: ReturnType<typeof setTimeout> | null = null; + + const flush = () => { + if (buf.length === 0) return; + const display = bytesToDisplay(buf).padEnd(8); + out(` ${DIM}${display}${RESET} → ${BOLD}${bytesToChars(buf)}${RESET}`); + buf = Buffer.alloc(0); + }; + + tty.onData((chunk) => { + if (chunk.length === 1 && chunk[0] === 0x71) { + tty.setRawMode(false); + for (const event of END_EVENTS) tty.off(event, restoreTerminal); + out(`${RULE}\n`); + exit(0); + return; + } + buf = Buffer.concat([buf, chunk]); + if (timer) clearTimeout(timer); + timer = setTimeout(flush, COMBO_GAP_MS); + }); +} diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts new file mode 100644 index 0000000..52f9de5 --- /dev/null +++ b/src/cli/commands/list.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps, STOPPED } from '../test-support/fake-cli-deps'; +import { json } from '../test-support/scripted-fetch'; +import { list } from './list'; + +const CREATED = 1700000000000; +const SESSIONS = [ + { id: 'main', connected: true, createdAt: CREATED }, + { id: 'other', connected: false, createdAt: CREATED }, +]; +const created = new Date(CREATED).toLocaleString(); +const listing = { 'GET /api/sessions': () => json(SESSIONS) }; + +describe('list', () => { + test('prints the header and one row per session', async () => { + const fake = fakeCliDeps(listing); + await list({}, fake.deps); + expect(fake.out).toEqual([ + 'id\t\t\tconnected\tcreated', + `main\t\t\ttrue\t\t${created}`, + `other\t\t\tfalse\t\t${created}`, + ]); + expect(fake.exits).toEqual([]); + }); + + test('a filter keeps the sessions whose id contains it', async () => { + const fake = fakeCliDeps(listing); + await list({ filter: 'th' }, fake.deps); + expect(fake.out).toEqual(['id\t\t\tconnected\tcreated', `other\t\t\tfalse\t\t${created}`]); + }); + + test('prints `no sessions` when none match', async () => { + const fake = fakeCliDeps(listing); + await list({ filter: 'zzz' }, fake.deps); + expect(fake.out).toEqual(['no sessions']); + const empty = fakeCliDeps({ 'GET /api/sessions': () => json([]) }); + await list({}, empty.deps); + expect(empty.out).toEqual(['no sessions']); + }); + + test('prints `webtty is not running` to stdout and exits 1 when the server is down', async () => { + const fake = fakeCliDeps(STOPPED); + await list({}, fake.deps); + expect(fake.out).toEqual(['webtty is not running']); + expect(fake.err).toEqual([]); + expect(fake.exits).toEqual([1]); + }); + + test('a reply that is not JSON is an error, not "not running"', async () => { + const fake = fakeCliDeps({ 'GET /api/sessions': () => new Response('<html>') }); + await expect(list({}, fake.deps)).rejects.toThrow(SyntaxError); + expect(fake.out).toEqual([]); + }); +}); diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts new file mode 100644 index 0000000..acbb5f0 --- /dev/null +++ b/src/cli/commands/list.ts @@ -0,0 +1,30 @@ +// `webtty ls [filter]`: print the sessions as a tab-separated table, optionally only those whose +// id contains `filter`. A command (ADR 063). +import { ServerUnreachableError, type SessionSummary } from '../api-client'; +import type { CliDeps } from '../cli-deps'; + +/** Prints the session table, or `no sessions`; exits 1 when the server is not running. */ +export async function list( + { filter }: { filter?: string }, + deps: Pick<CliDeps, 'client' | 'out' | 'exit'>, +): Promise<void> { + let all: SessionSummary[]; + try { + all = await deps.client.listSessions(); + } catch (err) { + if (!(err instanceof ServerUnreachableError)) throw err; + deps.out('webtty is not running'); + deps.exit(1); + return; + } + const sessions = filter ? all.filter((s) => s.id.includes(filter)) : all; + if (sessions.length === 0) { + deps.out('no sessions'); + return; + } + deps.out('id\t\t\tconnected\tcreated'); + for (const s of sessions) { + const created = new Date(s.createdAt).toLocaleString(); + deps.out(`${s.id}\t\t\t${s.connected}\t\t${created}`); + } +} diff --git a/src/cli/commands/remove.test.ts b/src/cli/commands/remove.test.ts new file mode 100644 index 0000000..496bca9 --- /dev/null +++ b/src/cli/commands/remove.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps } from '../test-support/fake-cli-deps'; +import { json, refused } from '../test-support/scripted-fetch'; +import { remove } from './remove'; + +const removed = (remaining: string) => () => + new Response(null, { status: 204, headers: { 'X-Sessions-Remaining': remaining } }); + +describe('remove', () => { + test('without an id prints usage and exits 1 without a request', async () => { + const fake = fakeCliDeps(); + await remove({}, fake.deps); + expect(fake.err).toEqual(['webtty: rm requires a session id']); + expect(fake.exits).toEqual([1]); + expect(fake.requests).toEqual([]); + }); + + test('removes the session and leaves the server running while sessions remain', async () => { + const fake = fakeCliDeps({ 'DELETE /api/sessions/a%2Fb': removed('2') }); + await remove({ id: 'a/b' }, fake.deps); + expect(fake.out).toEqual(['removed a/b']); + expect(fake.server).toEqual([]); + expect(fake.exits).toEqual([]); + }); + + test('stops the server after removing the last session', async () => { + const fake = fakeCliDeps({ 'DELETE /api/sessions/last': removed('0') }, { stopResult: false }); + await remove({ id: 'last' }, fake.deps); + expect(fake.out).toEqual(['removed last', 'no sessions remaining — webtty stopped']); + expect(fake.server).toEqual(['stop']); + expect(fake.exits).toEqual([]); + }); + + test('a missing session prints not found and exits 1', async () => { + const fake = fakeCliDeps({ 'DELETE /api/sessions/ghost': () => json({}, 404) }); + await remove({ id: 'ghost' }, fake.deps); + expect(fake.err).toEqual(['session ghost not found']); + expect(fake.exits).toEqual([1]); + }); + + test('any other status prints it and exits 1', async () => { + const fake = fakeCliDeps({ 'DELETE /api/sessions/bad': () => json({}, 500) }); + await remove({ id: 'bad' }, fake.deps); + expect(fake.err).toEqual(['webtty rm failed (status: 500)']); + expect(fake.exits).toEqual([1]); + }); + + test('prints `webtty is not running` to stdout and exits 1 when the server is down', async () => { + const fake = fakeCliDeps({ 'DELETE /api/sessions/any': refused }); + await remove({ id: 'any' }, fake.deps); + expect(fake.out).toEqual(['webtty is not running']); + expect(fake.exits).toEqual([1]); + }); + + test('an error that is not about reaching the server propagates', async () => { + const fake = fakeCliDeps(); + fake.deps.client.removeSession = async () => { + throw new RangeError('boom'); + }; + await expect(remove({ id: 'x' }, fake.deps)).rejects.toThrow('boom'); + }); +}); diff --git a/src/cli/commands/remove.ts b/src/cli/commands/remove.ts new file mode 100644 index 0000000..462f109 --- /dev/null +++ b/src/cli/commands/remove.ts @@ -0,0 +1,38 @@ +// `webtty rm <id>`: remove a session, and stop the server when it was the last one. A command +// (ADR 063). +import { type RemoveSessionResult, ServerUnreachableError } from '../api-client'; +import type { CliDeps } from '../cli-deps'; + +/** Removes session `id`; exits 1 without an id, when it is not found or the server refuses. */ +export async function remove( + { id }: { id?: string }, + deps: Pick<CliDeps, 'client' | 'server' | 'out' | 'err' | 'exit'>, +): Promise<void> { + if (!id) { + deps.err('webtty: rm requires a session id'); + deps.exit(1); + return; + } + let result: RemoveSessionResult; + try { + result = await deps.client.removeSession(id); + } catch (err) { + if (!(err instanceof ServerUnreachableError)) throw err; + deps.out('webtty is not running'); + deps.exit(1); + return; + } + if (result.status === 204) { + deps.out(`removed ${id}`); + if (result.sessionsRemaining === '0') { + await deps.server.stop(); + deps.out('no sessions remaining — webtty stopped'); + } + } else if (result.status === 404) { + deps.err(`session ${id} not found`); + deps.exit(1); + } else { + deps.err(`webtty rm failed (status: ${result.status})`); + deps.exit(1); + } +} diff --git a/src/cli/commands/rename.test.ts b/src/cli/commands/rename.test.ts new file mode 100644 index 0000000..7832252 --- /dev/null +++ b/src/cli/commands/rename.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps } from '../test-support/fake-cli-deps'; +import { json, refused } from '../test-support/scripted-fetch'; +import { rename } from './rename'; + +describe('rename', () => { + for (const args of [{}, { id: 'a' }, { newId: 'b' }]) { + test(`${JSON.stringify(args)} prints usage and exits 1 without a request`, async () => { + const fake = fakeCliDeps(); + await rename(args, fake.deps); + expect(fake.err).toEqual(['webtty: rename requires two arguments: [id] [new-id]']); + expect(fake.exits).toEqual([1]); + expect(fake.requests).toEqual([]); + }); + } + + test('renames and prints old → new', async () => { + const fake = fakeCliDeps({ 'PATCH /api/sessions/old': () => json({ id: 'new' }) }); + await rename({ id: 'old', newId: 'new' }, fake.deps); + expect(fake.requests[0]?.body).toEqual({ id: 'new' }); + expect(fake.out).toEqual(['renamed old → new']); + expect(fake.exits).toEqual([]); + }); + + test('a missing session prints not found and exits 1', async () => { + const fake = fakeCliDeps({ + 'PATCH /api/sessions/x': () => new Response(null, { status: 404 }), + }); + await rename({ id: 'x', newId: 'y' }, fake.deps); + expect(fake.err).toEqual(['session x not found']); + expect(fake.exits).toEqual([1]); + }); + + test("another refusal prints the server's error", async () => { + const fake = fakeCliDeps({ 'PATCH /api/sessions/x': () => json({ error: 'conflict' }, 409) }); + await rename({ id: 'x', newId: 'y' }, fake.deps); + expect(fake.err).toEqual(['webtty: conflict']); + expect(fake.exits).toEqual([1]); + }); + + test('a refusal without an error names the status', async () => { + const fake = fakeCliDeps({ 'PATCH /api/sessions/x': () => json({}, 400) }); + await rename({ id: 'x', newId: 'y' }, fake.deps); + expect(fake.err).toEqual(['webtty: rename failed (400)']); + expect(fake.exits).toEqual([1]); + }); + + test('prints `webtty is not running` to stdout and exits 1 when the server is down', async () => { + const fake = fakeCliDeps({ 'PATCH /api/sessions/x': refused }); + await rename({ id: 'x', newId: 'y' }, fake.deps); + expect(fake.out).toEqual(['webtty is not running']); + expect(fake.exits).toEqual([1]); + }); + + test('an error that is not about reaching the server propagates', async () => { + const fake = fakeCliDeps(); + fake.deps.client.renameSession = async () => { + throw new RangeError('boom'); + }; + await expect(rename({ id: 'x', newId: 'y' }, fake.deps)).rejects.toThrow('boom'); + }); +}); diff --git a/src/cli/commands/rename.ts b/src/cli/commands/rename.ts new file mode 100644 index 0000000..23820a2 --- /dev/null +++ b/src/cli/commands/rename.ts @@ -0,0 +1,34 @@ +// `webtty mv <id> <new-id>` (and `move`, `rename`): rename a session. A command (ADR 063). +import { type RenameSessionResult, ServerUnreachableError } from '../api-client'; +import type { CliDeps } from '../cli-deps'; + +/** Renames session `id` to `newId`; exits 1 on missing arguments or when the server refuses. */ +export async function rename( + { id, newId }: { id?: string; newId?: string }, + deps: Pick<CliDeps, 'client' | 'out' | 'err' | 'exit'>, +): Promise<void> { + if (!id || !newId) { + deps.err('webtty: rename requires two arguments: [id] [new-id]'); + deps.exit(1); + return; + } + let result: RenameSessionResult; + try { + result = await deps.client.renameSession(id, newId); + } catch (err) { + if (!(err instanceof ServerUnreachableError)) throw err; + deps.out('webtty is not running'); + deps.exit(1); + return; + } + if (result.ok) { + deps.out(`renamed ${id} → ${newId}`); + } else if (result.status === 404) { + deps.err(`session ${id} not found`); + deps.exit(1); + } else { + const error = await result.error(); + deps.err(`webtty: ${error ?? `rename failed (${result.status})`}`); + deps.exit(1); + } +} diff --git a/src/cli/commands/run.test.ts b/src/cli/commands/run.test.ts new file mode 100644 index 0000000..bb0e63e --- /dev/null +++ b/src/cli/commands/run.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps, RUNNING, STOPPED } from '../test-support/fake-cli-deps'; +import { json, lines, ndjson, type Responder, refused } from '../test-support/scripted-fetch'; +import { run } from './run'; + +const EXECUTE = 'POST /s/sess/execute'; + +describe('run — exec', () => { + async function exec(routes: Record<string, Responder>) { + const fake = fakeCliDeps({ ...RUNNING, ...routes }); + await run({ id: 'sess', cmd: 'echo', args: ['hi'] }, fake.deps); + return fake; + } + + test('streams stdout and stderr and exits with the exit code', async () => { + const fake = await exec({ + [EXECUTE]: () => + ndjson( + lines({ stream: 'stdout', data: 'hello\n' }, { stream: 'stderr', data: 'warn\n' }), + lines({ exit: 3 }, { stream: 'stdout', data: 'after exit' }), + ), + }); + expect(fake.requests.at(-1)?.body).toEqual({ cmd: 'echo', args: ['hi'] }); + expect(fake.stdout).toEqual(['hello\n']); + expect(fake.stderr).toEqual(['warn\n']); + expect(fake.err).toEqual([]); + expect(fake.exits).toEqual([3]); + }); + + test('an exit with an error prints it', async () => { + const fake = await exec({ + [EXECUTE]: () => ndjson(lines({ exit: 1, error: 'command failed' })), + }); + expect(fake.err).toEqual(['webtty: command failed']); + expect(fake.exits).toEqual([1]); + }); + + test('a null exit code exits 1', async () => { + const fake = await exec({ [EXECUTE]: () => ndjson(lines({ exit: null })) }); + expect(fake.exits).toEqual([1]); + }); + + test('a stream that ends without an exit message exits 1', async () => { + const fake = await exec({ + [EXECUTE]: () => ndjson(lines({ stream: 'stdout', data: 'partial\n' }, { other: true })), + }); + expect(fake.stdout).toEqual(['partial\n']); + expect(fake.err).toEqual(['webtty: connection closed unexpectedly']); + expect(fake.exits).toEqual([1]); + }); + + const refusals: Array<[number, string]> = [ + [404, 'webtty: session not found: sess'], + [409, 'webtty: PTY not running for session: sess'], + [500, 'webtty: run failed (500)'], + ]; + for (const [status, message] of refusals) { + test(`status ${status} prints "${message}" and exits 1`, async () => { + const fake = await exec({ [EXECUTE]: () => json({}, status) }); + expect(fake.err).toEqual([message]); + expect(fake.exits).toEqual([1]); + }); + } + + test('a 200 without a body is a failed run', async () => { + const fake = await exec({ [EXECUTE]: () => new Response(null, { status: 200 }) }); + expect(fake.err).toEqual(['webtty: run failed (200)']); + expect(fake.exits).toEqual([1]); + }); + + test('a stopped server prints `server is not running` and sends no command', async () => { + const fake = fakeCliDeps(STOPPED); + await run({ id: 'sess', cmd: 'echo' }, fake.deps); + expect(fake.err).toEqual(['webtty: server is not running']); + expect(fake.exits).toEqual([1]); + expect(fake.server).toEqual([]); + expect(fake.requests).toHaveLength(1); + }); + + test('a server that goes away before the command prints `server is not running`', async () => { + const fake = await exec({ [EXECUTE]: refused }); + expect(fake.err).toEqual(['webtty: server is not running']); + expect(fake.exits).toEqual([1]); + }); + + test('an error that is not about reaching the server propagates', async () => { + const fake = fakeCliDeps(RUNNING); + fake.deps.client.execute = async () => { + throw new RangeError('boom'); + }; + await expect(run({ id: 'sess', cmd: 'x' }, fake.deps)).rejects.toThrow('boom'); + }); +}); + +describe('run — warm-up', () => { + const existing = { 'GET /api/sessions/warm': () => json({ id: 'warm' }) }; + + test('on a running server with the session: opens the PTY and prints the URL', async () => { + const fake = fakeCliDeps({ ...RUNNING, ...existing }); + await run({ id: 'warm' }, fake.deps); + expect(fake.sockets.urls).toEqual(['ws://127.0.0.1:7681/ws/warm/pty?cols=80&rows=24']); + expect(fake.out).toEqual(['http://localhost:7681/s/warm']); + expect(fake.opened).toEqual([]); + expect(fake.server).toEqual([]); + }); + + test('starts the server and creates the session first', async () => { + const fake = fakeCliDeps({ + ...STOPPED, + 'GET /api/sessions/warm': () => json({}, 404), + 'POST /api/sessions': () => json({ id: 'warm' }, 201), + }); + await run({ id: 'warm' }, fake.deps); + expect(fake.server).toEqual(['start']); + expect(fake.requests.at(-1)?.body).toEqual({ id: 'warm' }); + expect(fake.out).toEqual(['http://localhost:7681/s/warm']); + }); + + test('a session that cannot be created prints the error and exits 1', async () => { + const fake = fakeCliDeps({ + ...RUNNING, + 'GET /api/sessions/warm': () => json({}, 404), + 'POST /api/sessions': () => json({ error: 'bad' }, 500), + }); + await run({ id: 'warm' }, fake.deps); + expect(fake.err).toEqual(['webtty: bad']); + expect(fake.exits).toEqual([1]); + expect(fake.sockets.urls).toEqual([]); + }); + + const socketFailures: Array<['error' | 'close-without-open', string]> = [ + ['error', 'webtty: failed to start PTY: failed to connect to PTY'], + ['close-without-open', 'webtty: failed to start PTY: failed to start PTY (code 1006)'], + ]; + for (const [socket, message] of socketFailures) { + test(`a socket that ${socket}s prints "${message}" and exits 1`, async () => { + const fake = fakeCliDeps({ ...RUNNING, ...existing }, { socket }); + await run({ id: 'warm' }, fake.deps); + expect(fake.err).toEqual([message]); + expect(fake.exits).toEqual([1]); + expect(fake.out).toEqual([]); + }); + } +}); diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts new file mode 100644 index 0000000..c8df1e8 --- /dev/null +++ b/src/cli/commands/run.ts @@ -0,0 +1,106 @@ +// `webtty run <id> [cmd [args...]]`: without a command, warm a session up (server, session and +// PTY, no browser); with one, run it headless in the session and relay its output and exit code. +// A command in two functions, warmUp and exec (ADR 063). +import { type ExecuteResponse, ServerUnreachableError } from '../api-client'; +import { sessionPageUrl } from '../browser-opener'; +import type { CliDeps } from '../cli-deps'; + +/** The arguments of `run`; `cmd` undefined means warm-up. */ +export interface RunArgs { + id: string; + cmd?: string; + args?: string[]; +} + +/** Dispatches to {@link warmUp} or {@link exec}. */ +export async function run({ id, cmd, args = [] }: RunArgs, deps: CliDeps): Promise<void> { + if (!cmd) { + await warmUp(id, deps); + return; + } + await exec(id, cmd, args, deps); +} + +/** + * Starts the server if needed, ensures session `id`, starts its PTY by opening and closing its + * socket, then prints the session URL. Exits 1 when the session or the PTY cannot start. + */ +export async function warmUp( + id: string, + deps: Pick<CliDeps, 'client' | 'server' | 'config' | 'out' | 'err' | 'exit'>, +): Promise<void> { + if (!(await deps.client.isRunning())) { + await deps.server.start(); + } + + const session = await deps.client.ensureSession(id); + if ('error' in session) { + deps.err(`webtty: ${session.error}`); + deps.exit(1); + return; + } + + try { + await deps.client.openPty(id); + } catch (err) { + deps.err(`webtty: failed to start PTY: ${(err as Error).message}`); + deps.exit(1); + return; + } + + deps.out(sessionPageUrl(deps.config(), id)); +} + +/** + * Runs `cmd args` in session `id`'s PTY environment, writing its stdout and stderr as they + * stream in and exiting with its exit code. Exits 1 when the server, session or PTY is missing + * or the stream ends without an exit message. + */ +export async function exec( + id: string, + cmd: string, + args: string[], + deps: Pick<CliDeps, 'client' | 'err' | 'write' | 'exit'>, +): Promise<void> { + if (!(await deps.client.isRunning())) { + deps.err('webtty: server is not running'); + deps.exit(1); + return; + } + + let response: ExecuteResponse; + try { + response = await deps.client.execute(id, cmd, args); + } catch (err) { + if (!(err instanceof ServerUnreachableError)) throw err; + deps.err('webtty: server is not running'); + deps.exit(1); + return; + } + + const failure = executeFailure(response.status, id); + if (failure || !response.ok || !response.messages) { + deps.err(failure ?? `webtty: run failed (${response.status})`); + deps.exit(1); + return; + } + + for await (const msg of response.messages) { + if (msg.stream === 'stdout') deps.write.stdout(String(msg.data)); + else if (msg.stream === 'stderr') deps.write.stderr(String(msg.data)); + else if ('exit' in msg) { + if (msg.error) deps.err(`webtty: ${msg.error}`); + deps.exit((msg.exit as number) ?? 1); + return; + } + } + deps.err('webtty: connection closed unexpectedly'); + deps.exit(1); +} + +/** The message for a status the server uses to refuse a run, or undefined. */ +function executeFailure(status: number, id: string): string | undefined { + if (status === 404) return `webtty: session not found: ${id}`; + if (status === 409) return `webtty: PTY not running for session: ${id}`; + return undefined; +} diff --git a/src/cli/commands/start-stop.test.ts b/src/cli/commands/start-stop.test.ts new file mode 100644 index 0000000..93610d3 --- /dev/null +++ b/src/cli/commands/start-stop.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps, RUNNING, STOPPED } from '../test-support/fake-cli-deps'; +import { start, stop } from './start-stop'; + +describe('start', () => { + test('starts a stopped server', async () => { + const fake = fakeCliDeps(STOPPED); + await start(fake.deps); + expect(fake.server).toEqual(['start']); + expect(fake.out).toEqual(['webtty started']); + }); + + test('reports a running server and leaves it alone', async () => { + const fake = fakeCliDeps(RUNNING); + await start(fake.deps); + expect(fake.server).toEqual([]); + expect(fake.out).toEqual(['webtty is already running']); + }); +}); + +describe('stop', () => { + test('stops a running server', async () => { + const fake = fakeCliDeps(RUNNING); + await stop(fake.deps); + expect(fake.server).toEqual(['stop']); + expect(fake.out).toEqual(['webtty stopped']); + expect(fake.exits).toEqual([]); + }); + + test('exits 1 when the server does not stop', async () => { + const fake = fakeCliDeps(RUNNING, { stopResult: false }); + await stop(fake.deps); + expect(fake.err).toEqual(['webtty stop failed']); + expect(fake.out).toEqual([]); + expect(fake.exits).toEqual([1]); + }); + + test('reports a stopped server with exit 0', async () => { + const fake = fakeCliDeps(STOPPED); + await stop(fake.deps); + expect(fake.server).toEqual([]); + expect(fake.out).toEqual(['webtty is not running']); + expect(fake.exits).toEqual([]); + }); +}); diff --git a/src/cli/commands/start-stop.ts b/src/cli/commands/start-stop.ts new file mode 100644 index 0000000..1df3f86 --- /dev/null +++ b/src/cli/commands/start-stop.ts @@ -0,0 +1,29 @@ +// `webtty start` and `webtty stop`: the background server's lifecycle, each a no-op with a +// message when the server is already in that state. Commands (ADR 063). +import type { CliDeps } from '../cli-deps'; + +/** Starts the server unless it is already running. */ +export async function start(deps: Pick<CliDeps, 'client' | 'server' | 'out'>): Promise<void> { + if (await deps.client.isRunning()) { + deps.out('webtty is already running'); + return; + } + await deps.server.start(); + deps.out('webtty started'); +} + +/** Stops the server if it is running; exits 1 when it does not stop. */ +export async function stop( + deps: Pick<CliDeps, 'client' | 'server' | 'out' | 'err' | 'exit'>, +): Promise<void> { + if (!(await deps.client.isRunning())) { + deps.out('webtty is not running'); + return; + } + if (await deps.server.stop()) { + deps.out('webtty stopped'); + } else { + deps.err('webtty stop failed'); + deps.exit(1); + } +} diff --git a/src/cli/http.test.ts b/src/cli/http.test.ts deleted file mode 100644 index 40a8f3b..0000000 --- a/src/cli/http.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test'; -import { getBaseUrl, getPort, isServerRunning } from './http'; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -describe('getBaseUrl', () => { - test('is always 127.0.0.1 on the configured port', () => { - expect(getBaseUrl()).toBe(`http://127.0.0.1:${getPort()}`); - }); -}); - -describe('isServerRunning', () => { - test('returns true when GET /api/sessions answers a JSON array', async () => { - const fetchMock = mock(async () => new Response('[]', { status: 200 })); - globalThis.fetch = fetchMock as unknown as typeof fetch; - expect(await isServerRunning()).toBe(true); - expect(fetchMock).toHaveBeenCalledWith(`${getBaseUrl()}/api/sessions`); - }); - - test('returns false when the answer is not an array', async () => { - globalThis.fetch = mock(async () => new Response('{}')) as unknown as typeof fetch; - expect(await isServerRunning()).toBe(false); - }); - - test('returns false on a non-2xx status', async () => { - globalThis.fetch = mock( - async () => new Response('[]', { status: 500 }), - ) as unknown as typeof fetch; - expect(await isServerRunning()).toBe(false); - }); - - test('returns false when fetch throws', async () => { - globalThis.fetch = mock(async () => { - throw new Error('connection refused'); - }) as unknown as typeof fetch; - expect(await isServerRunning()).toBe(false); - }); -}); diff --git a/src/cli/http.ts b/src/cli/http.ts deleted file mode 100644 index 071e9fa..0000000 --- a/src/cli/http.ts +++ /dev/null @@ -1,26 +0,0 @@ -// The CLI's view of where the server listens and whether it answers: the config-derived base -// URL and the reachability probe. A small service; the session calls move to WebttyClient in -// api-client.ts (ADR 063). -import { loadConfig } from '../config'; - -/** Active server port, read from config on first call (env PORT is intentionally ignored). */ -export function getPort(): number { - return loadConfig().port; -} - -/** Base URL for internal CLI↔server API calls (always 127.0.0.1, avoids IPv6 lookup). */ -export function getBaseUrl(): string { - return `http://127.0.0.1:${getPort()}`; -} - -/** Returns `true` if the webtty server is reachable and responding to API requests. */ -export async function isServerRunning(): Promise<boolean> { - try { - const res = await fetch(`${getBaseUrl()}/api/sessions`); - if (!res.ok) return false; - const body = await res.json(); - return Array.isArray(body); - } catch { - return false; - } -} diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts new file mode 100644 index 0000000..893246d --- /dev/null +++ b/src/cli/index.test.ts @@ -0,0 +1,257 @@ +// End-to-end tests of the CLI entry: each runs `src/cli/index.ts` as a subprocess against a real +// server on a free port, with a temporary HOME (ADR 063, ADR 064). Command logic is covered +// in-process by commands/*.test.ts. +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { type ChildProcess, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + cleanupTmpHome, + getFreePort, + makeTmpHome, + waitForServerDown, + waitForServerReady, +} from '../test-support/server-harness'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI_ENTRY = path.resolve(__dirname, 'index.ts'); +const SERVER_ENTRY = path.resolve(__dirname, '../server/index.ts'); + +const tmpHome = makeTmpHome('cli-test'); + +afterAll(() => { + cleanupTmpHome(tmpHome); +}); + +async function runCli( + port: number, + ...args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + // Write config with the test port so loadConfig() in the CLI subprocess returns the right port. + const cfgDir = path.join(tmpHome, '.config', 'webtty'); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync(path.join(cfgDir, 'config.json'), JSON.stringify({ port })); + + const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], { + env: { ...process.env, WEBTTY_NO_OPEN: '1', HOME: tmpHome }, + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +describe('cli — lifecycle', () => { + let port: number; + let baseUrl: string; + + beforeAll(async () => { + port = await getFreePort(); + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterAll(async () => { + await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }).catch(() => {}); + }); + + test('unknown command exits with error', async () => { + const { stderr, exitCode } = await runCli(port, 'unknown'); + expect(exitCode).toBe(1); + expect(stderr).toContain('unknown command'); + }); + + test('start launches the server', async () => { + const { stdout, exitCode } = await runCli(port, 'start'); + expect(exitCode).toBe(0); + expect(stdout).toBe('webtty started'); + expect(await waitForServerReady(baseUrl)).toBe(true); + }); + + test('start when already running reports already running', async () => { + const { stdout, exitCode } = await runCli(port, 'start'); + expect(exitCode).toBe(0); + expect(stdout).toBe('webtty is already running'); + }); + + test('stop shuts down the server', async () => { + const { stdout, exitCode } = await runCli(port, 'stop'); + expect(exitCode).toBe(0); + expect(stdout).toBe('webtty stopped'); + await waitForServerDown(baseUrl); + await expect(fetch(`${baseUrl}/api/sessions`)).rejects.toThrow(); + }); + + test('stop when not running reports not running', async () => { + const { stdout, exitCode } = await runCli(port, 'stop'); + expect(exitCode).toBe(0); + expect(stdout).toBe('webtty is not running'); + }); + + test('start launches server after stop', async () => { + const { stdout, exitCode } = await runCli(port, 'start'); + expect(exitCode).toBe(0); + expect(stdout).toBe('webtty started'); + expect(await waitForServerReady(baseUrl)).toBe(true); + }); +}); + +describe('cli — session management', () => { + let port: number; + let baseUrl: string; + let serverProc: ChildProcess; + + beforeAll(async () => { + port = await getFreePort(); + baseUrl = `http://127.0.0.1:${port}`; + serverProc = spawn(process.execPath, [SERVER_ENTRY], { + env: { ...process.env, PORT: String(port), HOME: tmpHome }, + stdio: 'ignore', + }); + await waitForServerReady(baseUrl); + }); + + afterAll(() => { + serverProc.kill(); + }); + + test('ls prints no sessions on empty server', async () => { + const { stdout, exitCode } = await runCli(port, 'ls'); + expect(exitCode).toBe(0); + expect(stdout).toContain('no sessions'); + }); + + test('go creates a session and prints url', async () => { + const { stdout, exitCode } = await runCli(port, 'go', 'my-session'); + expect(exitCode).toBe(0); + expect(stdout).toContain(`/s/my-session`); + + const res = await fetch(`${baseUrl}/api/sessions/my-session`); + expect(res.status).toBe(200); + }); + + test('go with existing id reuses session without error', async () => { + const { stdout, exitCode } = await runCli(port, 'go', 'my-session'); + expect(exitCode).toBe(0); + expect(stdout).toContain(`/s/my-session`); + }); + + test('go without id opens main session', async () => { + const { stdout, exitCode } = await runCli(port, 'go'); + expect(exitCode).toBe(0); + expect(stdout).toContain('/s/main'); + }); + + test('ls shows created sessions', async () => { + const { stdout, exitCode } = await runCli(port, 'ls'); + expect(exitCode).toBe(0); + expect(stdout).toContain('my-session'); + }); + + test('rename renames a session', async () => { + const { stdout, exitCode } = await runCli(port, 'mv', 'my-session', 'renamed-session'); + expect(exitCode).toBe(0); + expect(stdout).toContain('renamed'); + expect(stdout).toContain('renamed-session'); + + expect((await fetch(`${baseUrl}/api/sessions/my-session`)).status).toBe(404); + expect((await fetch(`${baseUrl}/api/sessions/renamed-session`)).status).toBe(200); + }); + + test('rename non-existent session exits with error', async () => { + const { exitCode, stderr } = await runCli(port, 'mv', 'does-not-exist', 'new-name'); + expect(exitCode).toBe(1); + expect(stderr).toContain('not found'); + }); + + test('rm removes a session', async () => { + const { stdout, exitCode } = await runCli(port, 'rm', 'renamed-session'); + expect(exitCode).toBe(0); + expect(stdout).toContain('removed renamed-session'); + expect((await fetch(`${baseUrl}/api/sessions/renamed-session`)).status).toBe(404); + }); + + test('rm non-existent session exits with error', async () => { + const { exitCode, stderr } = await runCli(port, 'rm', 'does-not-exist'); + expect(exitCode).toBe(1); + expect(stderr).toContain('not found'); + }); + + test('ls when server not running exits with error', async () => { + const { stdout, exitCode } = await runCli(port + 1, 'ls'); + expect(exitCode).toBe(1); + expect(stdout).toContain('not running'); + }); +}); + +describe('cli — no-arg, help, config', () => { + let port: number; + let baseUrl: string; + let serverProc: ChildProcess; + + beforeAll(async () => { + port = await getFreePort(); + baseUrl = `http://127.0.0.1:${port}`; + serverProc = spawn(process.execPath, [SERVER_ENTRY], { + env: { ...process.env, PORT: String(port), HOME: tmpHome }, + stdio: 'ignore', + }); + await waitForServerReady(baseUrl); + }); + + afterAll(() => { + serverProc.kill(); + }); + + test('no-arg creates main session and prints url', async () => { + const { stdout, exitCode } = await runCli(port); + expect(exitCode).toBe(0); + expect(stdout).toContain('/s/main'); + }); + + test('no-arg reuses main session without error', async () => { + const { stdout, exitCode } = await runCli(port); + expect(exitCode).toBe(0); + expect(stdout).toContain('/s/main'); + }); + + test('key exits with error when not a TTY', async () => { + const { stderr, exitCode } = await runCli(port, 'key'); + expect(exitCode).toBe(1); + expect(stderr).toContain('requires an interactive terminal'); + }); + + test('help prints usage', async () => { + const { stdout, exitCode } = await runCli(port, 'help'); + expect(exitCode).toBe(0); + expect(stdout).toContain('USAGE'); + expect(stdout).toContain('webtty'); + }); + + test('config opens config path in $VISUAL', async () => { + const expectedPath = path.join(tmpHome, '.config', 'webtty', 'config.json'); + const env: Record<string, string> = { + ...process.env, + PORT: String(port), + HOME: tmpHome, + VISUAL: 'echo', + }; + delete env.EDITOR; + const proc = Bun.spawn([process.execPath, CLI_ENTRY, 'config'], { + env, + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + expect(stdout.trim()).toContain(expectedPath); + }); +}); diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts index 3a2b5d7..a2e0cb5 100644 --- a/src/cli/main.test.ts +++ b/src/cli/main.test.ts @@ -1,24 +1,24 @@ import { describe, expect, spyOn, test } from 'bun:test'; import path from 'node:path'; -import { type CliCommands, type CliDeps, createCliDeps } from './cli-deps'; +import { WebttyClient } from './api-client'; +import { createCliDeps } from './cli-deps'; import { helpText } from './help-text'; -import { main } from './main'; +import { CLI_COMMANDS, type CliCommands, main } from './main'; +import { fakeCliDeps } from './test-support/fake-cli-deps'; -/** Deps whose commands record `name(args)` and whose streams and exit record into arrays. */ -function fakeDeps() { +/** A command table whose entries record `name(args)`, over recording fake deps. */ +function fakeCommands() { const calls: Array<[string, ...unknown[]]> = []; - const out: string[] = []; - const err: string[] = []; - const exits: number[] = []; const record = (name: string) => (...args: unknown[]) => { - calls.push([name, ...args]); + // Every command's last argument is the deps; only the parsed args are recorded. + calls.push([name, ...args.slice(0, -1)]); }; const asyncRecord = (name: string) => async (...args: unknown[]) => { - calls.push([name, ...args]); + record(name)(...args); }; const commands: CliCommands = { go: asyncRecord('go'), @@ -27,49 +27,43 @@ function fakeDeps() { rename: asyncRecord('rename'), stop: asyncRecord('stop'), start: asyncRecord('start'), - config: record('config'), + editConfig: record('editConfig'), run: asyncRecord('run'), key: record('key'), }; - const deps: CliDeps = { - out: (text) => out.push(text), - err: (text) => err.push(text), - exit: (code) => exits.push(code), - version: () => '9.9.9', - commands, - }; - return { deps, calls, out, err, exits }; + return { commands, calls }; } /** Runs main as `webtty ...args` and returns what it recorded. */ async function run(...args: string[]) { - const fake = fakeDeps(); - await main(['bun', 'webtty', ...args], fake.deps); - return fake; + const fake = fakeCliDeps(); + const { commands, calls } = fakeCommands(); + await main(['bun', 'webtty', ...args], fake.deps, commands); + return { ...fake, calls }; } describe('main dispatch', () => { const cases: Array<[string[], Array<[string, ...unknown[]]>]> = [ - [[], [['go', 'main', undefined]]], - [['go'], [['go', 'main', undefined]]], - [['go', 'x'], [['go', 'x', undefined]]], - [['a', 'x'], [['go', 'x', undefined]]], - [['attach', 'x'], [['go', 'x', undefined]]], - [['open'], [['go', 'main', undefined]]], - [['--dir', '/tmp/w'], [['go', 'main', path.resolve('/tmp/w')]]], - [['go', 'x', '--dir', '/tmp/w'], [['go', 'x', path.resolve('/tmp/w')]]], - [['ls'], [['list', undefined]]], - [['list', 'ab'], [['list', 'ab']]], - [['rm', 'x'], [['remove', 'x']]], - [['remove'], [['remove', undefined]]], - [['mv', 'a', 'b'], [['rename', 'a', 'b']]], - [['move', 'a'], [['rename', 'a', undefined]]], - [['rename', 'a', 'b'], [['rename', 'a', 'b']]], + [[], [['go', { id: undefined, baseDir: undefined }]]], + [['go'], [['go', { id: undefined, baseDir: undefined }]]], + [['go', 'x'], [['go', { id: 'x', baseDir: undefined }]]], + [['a', 'x'], [['go', { id: 'x', baseDir: undefined }]]], + [['attach', 'x'], [['go', { id: 'x', baseDir: undefined }]]], + [['open'], [['go', { id: undefined, baseDir: undefined }]]], + [['--dir', '/tmp/w'], [['go', { id: undefined, baseDir: path.resolve('/tmp/w') }]]], + [['go', 'x', '--dir', '/tmp/w'], [['go', { id: 'x', baseDir: path.resolve('/tmp/w') }]]], + [['ls'], [['list', { filter: undefined }]]], + [['list', 'ab'], [['list', { filter: 'ab' }]]], + [['rm', 'x'], [['remove', { id: 'x' }]]], + [['remove'], [['remove', { id: undefined }]]], + [['mv', 'a', 'b'], [['rename', { id: 'a', newId: 'b' }]]], + [['move', 'a'], [['rename', { id: 'a', newId: undefined }]]], + [['rename', 'a', 'b'], [['rename', { id: 'a', newId: 'b' }]]], [['stop'], [['stop']]], [['start'], [['start']]], - [['config'], [['config']]], - [['run', 'x'], [['run', 'x', undefined, []]]], - [['run', 'x', 'ls', '-la', 'dir'], [['run', 'x', 'ls', ['-la', 'dir']]]], + [['config'], [['editConfig']]], + [['run', 'x'], [['run', { id: 'x', cmd: undefined, args: [] }]]], + [['run', 'x', 'ls', '-la', 'dir'], [['run', { id: 'x', cmd: 'ls', args: ['-la', 'dir'] }]]], [['key'], [['key']]], ]; for (const [args, expected] of cases) { @@ -114,18 +108,27 @@ describe('main dispatch', () => { }); }); -describe('createCliDeps', () => { - test('wires every command and the package version', async () => { +describe('CLI_COMMANDS and createCliDeps', () => { + test('CLI_COMMANDS holds one command per module', async () => { + const { go } = await import('./commands/go'); + const { key } = await import('./commands/key'); + const { run } = await import('./commands/run'); + expect(CLI_COMMANDS.go).toBe(go); + expect(CLI_COMMANDS.key).toBe(key); + expect(CLI_COMMANDS.run).toBe(run); + expect(Object.keys(CLI_COMMANDS).sort()).toEqual( + ['editConfig', 'go', 'key', 'list', 'remove', 'rename', 'run', 'start', 'stop'].sort(), + ); + }); + + test('wires the configured client, the config loader and the package version', async () => { const deps = createCliDeps(); - const { cmdGo, cmdKey, cmdRun } = await import('./commands'); + const { loadConfig } = await import('../config'); const { getVersion } = await import('../version'); - expect(deps.commands.go).toBe(cmdGo); - expect(deps.commands.key).toBe(cmdKey); - expect(deps.commands.run).toBe(cmdRun); - expect(Object.keys(deps.commands).sort()).toEqual( - ['config', 'go', 'key', 'list', 'remove', 'rename', 'run', 'start', 'stop'].sort(), - ); + expect(deps.client).toBeInstanceOf(WebttyClient); + expect(deps.config).toBe(loadConfig); expect(deps.version).toBe(getVersion); + expect(deps.editor.env).toBe(process.env); }); }); @@ -148,4 +151,39 @@ describe('createCliDeps streams', () => { exit.mockRestore(); } }); + + test('write goes to process.stdout and process.stderr without a newline', () => { + const stdout = spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const deps = createCliDeps(); + deps.write.stdout('out'); + deps.write.stderr('err'); + expect(stdout).toHaveBeenCalledWith('out'); + expect(stderr).toHaveBeenCalledWith('err'); + } finally { + stdout.mockRestore(); + stderr.mockRestore(); + } + }); + + test('server.stop stops the given client and openBrowser honours WEBTTY_NO_OPEN', async () => { + const requests: string[] = []; + const client = new WebttyClient('http://127.0.0.1:1', async (url, init) => { + requests.push(`${init?.method ?? 'GET'} ${url}`); + return new Response('no', { status: 500 }); + }); + const deps = createCliDeps(client); + expect(await deps.server.stop()).toBe(false); + expect(requests).toEqual(['POST http://127.0.0.1:1/api/server/stop']); + + const saved = process.env.WEBTTY_NO_OPEN; + process.env.WEBTTY_NO_OPEN = '1'; + try { + expect(() => deps.openBrowser('http://localhost:1/s/x')).not.toThrow(); + } finally { + if (saved === undefined) delete process.env.WEBTTY_NO_OPEN; + else process.env.WEBTTY_NO_OPEN = saved; + } + }); }); diff --git a/src/cli/main.ts b/src/cli/main.ts index 4a70d45..bef4bc9 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,67 +1,98 @@ -// The CLI's composition root: parse argv, then dispatch to one command handler with the deps it -// was given. Aliases and usage errors live here; what each command does lives in commands.ts +// The CLI's composition root: parse argv, then dispatch to one command with the deps it was +// given. Aliases and usage errors live here; what each command does lives in commands/ // (ADR 063). import { GO_ALIASES, parseArgs } from './cli-args'; import { type CliDeps, createCliDeps } from './cli-deps'; +import { editConfig } from './commands/edit-config'; +import { go } from './commands/go'; +import { key } from './commands/key'; +import { list } from './commands/list'; +import { remove } from './commands/remove'; +import { rename } from './commands/rename'; +import { run } from './commands/run'; +import { start, stop } from './commands/start-stop'; import { helpText } from './help-text'; +/** The commands main() dispatches to (aliases resolve before the call); tests pass recorders. */ +export interface CliCommands { + go: typeof go; + list: typeof list; + remove: typeof remove; + rename: typeof rename; + start: typeof start; + stop: typeof stop; + editConfig: typeof editConfig; + run: typeof run; + key: typeof key; +} + +/** The real commands, one module each under commands/. */ +export const CLI_COMMANDS: CliCommands = { + go, + list, + remove, + rename, + start, + stop, + editConfig, + run, + key, +}; + /** * Runs one CLI invocation. With no command it opens the `main` session, like `webtty go`. * * @param argv - The full `process.argv` (runtime and script path first). - * @param deps - The process and the command handlers; the real ones by default. + * @param deps - The process, the API client and the server control; the real ones by default. + * @param commands - The command implementations; the real ones by default. */ export async function main( argv: readonly string[], deps: CliDeps = createCliDeps(), + commands: CliCommands = CLI_COMMANDS, ): Promise<void> { const { command, rest, baseDir } = parseArgs(argv.slice(2)); - const { commands } = deps; - if (!command) { - await commands.go('main', baseDir); - return; - } - if (GO_ALIASES.has(command)) { - await commands.go(rest[0] ?? 'main', baseDir); + if (!command || GO_ALIASES.has(command)) { + await commands.go({ id: rest[0], baseDir }, deps); return; } switch (command) { case 'ls': case 'list': - await commands.list(rest[0]); + await commands.list({ filter: rest[0] }, deps); return; case 'rm': case 'remove': - await commands.remove(rest[0]); + await commands.remove({ id: rest[0] }, deps); return; case 'mv': case 'move': case 'rename': - await commands.rename(rest[0], rest[1]); + await commands.rename({ id: rest[0], newId: rest[1] }, deps); return; case 'stop': - await commands.stop(); + await commands.stop(deps); return; case 'start': - await commands.start(); + await commands.start(deps); return; case 'config': - commands.config(); + commands.editConfig(deps); return; case 'run': { - const [id, runCmd, ...runArgs] = rest; + const [id, cmd, ...args] = rest; if (!id) { deps.err('usage: webtty run <id> [cmd [args...]]'); deps.exit(1); return; } - await commands.run(id, runCmd, runArgs); + await commands.run({ id, cmd, args }, deps); return; } case 'key': - commands.key(); + commands.key(deps); return; case '-v': case '--version': diff --git a/src/cli/server-process.test.ts b/src/cli/server-process.test.ts index 4b54db8..5198cc2 100644 --- a/src/cli/server-process.test.ts +++ b/src/cli/server-process.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { describe, expect, mock, spyOn, test } from 'bun:test'; import os from 'node:os'; import path from 'node:path'; import { DEFAULT_CONFIG } from '../config'; +import { type FetchLike, WebttyClient } from './api-client'; import { defaultServerProcessDeps, logPath, @@ -11,12 +12,6 @@ import { stopServer, } from './server-process'; -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - describe('resolveServerEntry', () => { const cases: Array<[string, string, boolean, boolean, string]> = [ ['src under bun', '/pkg/src/cli', true, false, '/pkg/src/server/index.ts'], @@ -193,40 +188,44 @@ describe('startServer', () => { }); describe('stopServer', () => { + const client = (fetchFn: FetchLike) => new WebttyClient('http://127.0.0.1:1', fetchFn); + test('returns true when server stops after POST succeeds', async () => { - let callCount = 0; - globalThis.fetch = mock(async (_url: string, init?: RequestInit) => { + let probes = 0; + const calls: string[] = []; + const stopped = client(async (url, init) => { + calls.push(`${init?.method ?? 'GET'} ${url}`); if (init?.method === 'POST') return new Response('stopping', { status: 200 }); - callCount++; - if (callCount >= 2) throw new Error('connection refused'); + if (++probes >= 2) throw new Error('connection refused'); return new Response('[]', { status: 200 }); - }) as unknown as typeof fetch; + }); - expect(await stopServer()).toBe(true); + expect(await stopServer(stopped)).toBe(true); + expect(calls).toEqual([ + 'POST http://127.0.0.1:1/api/server/stop', + 'GET http://127.0.0.1:1/api/sessions', + 'GET http://127.0.0.1:1/api/sessions', + ]); }); test('returns false when POST to stop endpoint fails', async () => { - globalThis.fetch = mock(async (_url: string, init?: RequestInit) => { - if (init?.method === 'POST') return new Response('error', { status: 500 }); - return new Response('[]', { status: 200 }); - }) as unknown as typeof fetch; - - expect(await stopServer()).toBe(false); + const refused = client(async (_url, init) => + init?.method === 'POST' + ? new Response('error', { status: 500 }) + : new Response('[]', { status: 200 }), + ); + expect(await stopServer(refused)).toBe(false); }); test('returns false when fetch throws', async () => { - globalThis.fetch = mock(async () => { + const down = client(async () => { throw new Error('connection refused'); - }) as unknown as typeof fetch; - - expect(await stopServer()).toBe(false); + }); + expect(await stopServer(down)).toBe(false); }); test('returns false when server does not come down within timeout', async () => { - globalThis.fetch = mock( - async () => new Response('[]', { status: 200 }), - ) as unknown as typeof fetch; - - expect(await stopServer('http://127.0.0.1:1', 100)).toBe(false); + const stuck = client(async () => new Response('[]', { status: 200 })); + expect(await stopServer(stuck, 100)).toBe(false); }); }); diff --git a/src/cli/server-process.ts b/src/cli/server-process.ts index c64d9e8..46d75a1 100644 --- a/src/cli/server-process.ts +++ b/src/cli/server-process.ts @@ -1,13 +1,13 @@ // Starting and stopping the webtty server as a detached background process. A service over -// injected deps (spawn, filesystem, config, reachability probe), with the src/dist entry -// arithmetic split out as the pure resolveServerEntry (ADR 063). +// injected deps (spawn, filesystem, config, the API client's reachability probe), with the +// src/dist entry arithmetic split out as the pure resolveServerEntry (ADR 063). import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { type Config, configDir, loadConfig } from '../config'; import { isWindows, runningUnderBun } from '../runtime'; -import { getBaseUrl, isServerRunning } from './http'; +import { configuredClient, type WebttyClient } from './api-client'; /** Where the CLI module runs from, and with which executor the server will run. */ export interface ServerEntryOptions { @@ -93,7 +93,7 @@ export function defaultServerProcessDeps(): ServerProcessDeps { return fs.openSync(file, 'a'); }, closeFd: fs.closeSync, - isRunning: isServerRunning, + isRunning: () => configuredClient().isRunning(), err: (text) => console.error(text), exit: (code) => process.exit(code), sleep: (ms) => new Promise((r) => setTimeout(r, ms)), @@ -160,20 +160,19 @@ export async function startServer( /** * Sends `POST /api/server/stop`, then polls until the server is no longer reachable. * - * @param baseUrl - The server base URL (default: getBaseUrl()). + * @param client - The server to stop (default: the configured one). * @param timeoutMs - Maximum time to wait for the server to stop (default: 5000 ms). * @returns `true` if the server stopped successfully, `false` otherwise. */ export async function stopServer( - baseUrl: string = getBaseUrl(), + client: WebttyClient = configuredClient(), timeoutMs = 5000, ): Promise<boolean> { try { - const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); - if (!res.ok) return false; + if (!(await client.stop())) return false; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (!(await isServerRunning())) return true; + if (!(await client.isRunning())) return true; await new Promise((r) => setTimeout(r, 100)); } return false; diff --git a/src/cli/test-support/fake-cli-deps.ts b/src/cli/test-support/fake-cli-deps.ts new file mode 100644 index 0000000..a6973f2 --- /dev/null +++ b/src/cli/test-support/fake-cli-deps.ts @@ -0,0 +1,89 @@ +// Test helper: CliDeps whose client talks to a scripted fetch and whose streams, exit, server +// control and browser record into arrays, so a command's output and exit code are asserted +// exactly (ADR 063, ADR 064). +import { DEFAULT_CONFIG } from '../../config'; +import { WebttyClient } from '../api-client'; +import type { CliDeps } from '../cli-deps'; +import type { EditorHost } from '../commands/edit-config'; +import type { KeyTty } from '../commands/key'; +import { + FAKE_BASE_URL, + fakeSockets, + type Responder, + refused, + type SocketBehaviour, + scriptedFetch, +} from './scripted-fetch'; + +/** What a fake run should do beyond the routes. */ +export interface FakeCliOptions { + /** What the fake PTY sockets do; `open` by default. */ + socket?: SocketBehaviour; + /** What `server.stop()` resolves to; true by default. */ + stopResult?: boolean; + editor?: EditorHost; + tty?: KeyTty; +} + +/** Fails the test if a command touches a host it was not given. */ +function unexpected(name: string): never { + throw new Error(`unexpected use of ${name}`); +} + +/** Recording CliDeps over the route table; the config is the default with host 0.0.0.0. */ +export function fakeCliDeps(routes: Record<string, Responder> = {}, options: FakeCliOptions = {}) { + const { fetchFn, requests } = scriptedFetch(routes); + const sockets = fakeSockets(options.socket); + const out: string[] = []; + const err: string[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const exits: number[] = []; + const server: string[] = []; + const opened: string[] = []; + const deps: CliDeps = { + client: new WebttyClient(FAKE_BASE_URL, fetchFn, sockets.openSocket), + config: () => ({ ...DEFAULT_CONFIG, host: '0.0.0.0', port: 7681 }), + out: (text) => out.push(text), + err: (text) => err.push(text), + write: { stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text) }, + exit: (code) => exits.push(code), + version: () => '9.9.9', + server: { + start: async () => { + server.push('start'); + }, + stop: async () => { + server.push('stop'); + return options.stopResult ?? true; + }, + }, + openBrowser: (url) => opened.push(url), + editor: options.editor ?? { + configDir: () => unexpected('editor'), + mkdirp: () => unexpected('editor'), + exists: () => unexpected('editor'), + writeFile: () => unexpected('editor'), + env: {}, + platform: 'linux', + runAttached: () => unexpected('editor'), + }, + tty: options.tty ?? { + isTTY: false, + setRawMode: () => unexpected('tty'), + resume: () => unexpected('tty'), + onData: () => unexpected('tty'), + once: () => unexpected('tty'), + off: () => unexpected('tty'), + }, + }; + return { deps, requests, sockets, out, err, stdout, stderr, exits, server, opened }; +} + +/** Routes for a running server with no sessions: the reachability probe answers `[]`. */ +export const RUNNING: Record<string, Responder> = { + 'GET /api/sessions': () => new Response('[]'), +}; + +/** Routes for a server that is not running: the probe is refused. */ +export const STOPPED: Record<string, Responder> = { 'GET /api/sessions': refused }; diff --git a/src/cli/test-support/scripted-fetch.ts b/src/cli/test-support/scripted-fetch.ts new file mode 100644 index 0000000..8742c39 --- /dev/null +++ b/src/cli/test-support/scripted-fetch.ts @@ -0,0 +1,91 @@ +// Test helper: a fetch that answers from a route table and records every request, and a fake +// PTY socket, so WebttyClient and the commands run without a server (ADR 063, ADR 064). +import type { FetchLike, OpenSocket, PtySocket } from '../api-client'; + +/** The base URL the fakes pretend to serve. */ +export const FAKE_BASE_URL = 'http://127.0.0.1:7681'; + +/** One request the fake fetch received; `body` is the parsed JSON body, if any. */ +export interface RecordedRequest { + method: string; + path: string; + body: unknown; +} + +/** Answers one request; throwing simulates a server that cannot be reached. */ +export type Responder = (request: RecordedRequest) => Response | Promise<Response>; + +/** + * A fetch over a route table keyed by `"METHOD /path"`. A request with no route fails the test + * with a thrown Error, which the client reports as unreachable, so assert on `requests` too. + */ +export function scriptedFetch(routes: Record<string, Responder>) { + const requests: RecordedRequest[] = []; + const fetchFn: FetchLike = async (url, init) => { + const request: RecordedRequest = { + method: init?.method ?? 'GET', + path: url.slice(FAKE_BASE_URL.length), + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + }; + requests.push(request); + const respond = routes[`${request.method} ${request.path}`]; + if (!respond) throw new Error(`no route for ${request.method} ${request.path}`); + return respond(request); + }; + return { fetchFn, requests }; +} + +/** A responder for a server that is down: fetch rejects as it does on ECONNREFUSED. */ +export const refused: Responder = () => { + throw new TypeError('fetch failed'); +}; + +/** A JSON response. */ +export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response { + return new Response(JSON.stringify(body), { status, headers }); +} + +/** An NDJSON response streaming `chunks` as given, so a test controls the line splits. */ +export function ndjson(...chunks: string[]): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream<Uint8Array>({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); + return new Response(body, { status: 200 }); +} + +/** One NDJSON line per message. */ +export function lines(...messages: unknown[]): string { + return messages.map((m) => `${JSON.stringify(m)}\n`).join(''); +} + +/** How a fake PTY socket behaves once created. */ +export type SocketBehaviour = 'open' | 'error' | 'close-without-open'; + +/** A socket factory whose sockets open, error or close with 1006, recording the URLs. */ +export function fakeSockets(behaviour: SocketBehaviour = 'open') { + const urls: string[] = []; + const closeCodes: Array<number | undefined> = []; + const openSocket: OpenSocket = (url) => { + urls.push(url); + const socket: PtySocket = { + onopen: null, + onerror: null, + onclose: null, + close(code) { + closeCodes.push(code); + queueMicrotask(() => socket.onclose?.(new CloseEvent('close', { code: 1000 }))); + }, + }; + queueMicrotask(() => { + if (behaviour === 'open') socket.onopen?.(new Event('open')); + else if (behaviour === 'error') socket.onerror?.(new Event('error')); + else socket.onclose?.(new CloseEvent('close', { code: 1006 })); + }); + return socket; + }; + return { openSocket, urls, closeCodes }; +} From 573194fb1ae58f1eccd4aee7bce9d2302cd0fe94 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:43:47 -0400 Subject: [PATCH 209/295] test(client): FakeTerminal records open, clearScreen, resyncFocus and dispose, and keeps the wheel hooks --- src/client/test-support.test.ts | 29 +++++++++++++++ src/client/test-support/fake-terminal.ts | 46 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/client/test-support.test.ts b/src/client/test-support.test.ts index 25b8342..0005136 100644 --- a/src/client/test-support.test.ts +++ b/src/client/test-support.test.ts @@ -10,6 +10,7 @@ import { FakeTerminal } from './test-support/fake-terminal'; import { FakeWindow } from './test-support/fake-window'; import { ManualClock } from './test-support/manual-clock'; import { RecordingTarget, recordListeners } from './test-support/recording-target'; +import { AT_ONCE } from './vendor/ghostty-web'; describe('ManualClock', () => { test('runs timeouts in due order when time advances, with now() at each due time', () => { @@ -260,6 +261,34 @@ describe('FakeTerminal', () => { 'remeasureFont', ]); }); + + test('records open, clear, focus resync and dispose, and keeps the attached hooks', async () => { + const term = new FakeTerminal(); + const parent = { id: 'terminal' } as HTMLElement; + await term.open(parent); + expect(term.clearScreen()).toBe(true); + term.clearable = false; + expect(term.clearScreen()).toBe(false); + term.resyncFocus(); + term.dispose(); + expect(term.calls).toEqual([ + 'open:#terminal', + 'clearScreen', + 'clearScreen', + 'resyncFocus', + 'dispose', + ]); + expect(term.hasMouseTracking()).toBe(false); + const handler = () => true; + const pace = () => AT_ONCE; + const hook = () => {}; + term.attachCustomWheelEventHandler(handler); + term.attachWheelPace(pace); + term.attachBeforeRender(hook); + expect([term.wheelHandler, term.wheelPace, term.beforeRender]).toEqual([handler, pace, hook]); + term.attachCustomWheelEventHandler(); + expect(term.wheelHandler).toBeUndefined(); + }); }); describe('FakeWindow', () => { diff --git a/src/client/test-support/fake-terminal.ts b/src/client/test-support/fake-terminal.ts index 1010a6b..96e01ef 100644 --- a/src/client/test-support/fake-terminal.ts +++ b/src/client/test-support/fake-terminal.ts @@ -6,6 +6,7 @@ import { type DesktopNotification, EventEmitter, type IEvent, + type Pace, type Terminal, } from '../vendor/ghostty-web'; @@ -45,6 +46,16 @@ export class FakeTerminal { wasmTerm: object | undefined; /** Whether there is a selection, for `copySelection`. */ hasSelection = false; + /** What `clearScreen` returns: false stands for the alternate screen (ADR 054). */ + clearable = true; + /** What `hasMouseTracking` returns. */ + mouseTracking = false; + /** The cursor, as kitty graphics reads it. */ + readonly buffer = { active: { cursorX: 0, cursorY: 0 } }; + /** The wheel handler, wheel pace and before-render hook last attached; the test calls them. */ + wheelHandler: ((event: WheelEvent) => boolean) | undefined; + wheelPace: ((event: WheelEvent) => Pace) | undefined; + beforeRender: ((now: number) => void) | undefined; /** Everything passed to `write`, in order. */ readonly written: (string | Uint8Array)[] = []; /** Every command called, as `name` or `name:arg`, in order. */ @@ -139,6 +150,41 @@ export class FakeTerminal { return this.renderer; } + /** Records the open; the real one builds the canvas and the input handler in `parent`. */ + async open(parent: HTMLElement): Promise<void> { + this.calls.push(`open:#${parent.id}`); + } + + dispose(): void { + this.calls.push('dispose'); + } + + /** Records the clear; returns {@link clearable}. */ + clearScreen(): boolean { + this.calls.push('clearScreen'); + return this.clearable; + } + + resyncFocus(): void { + this.calls.push('resyncFocus'); + } + + hasMouseTracking(): boolean { + return this.mouseTracking; + } + + attachCustomWheelEventHandler(handler?: (event: WheelEvent) => boolean): void { + this.wheelHandler = handler; + } + + attachWheelPace(pace?: (event: WheelEvent) => Pace): void { + this.wheelPace = pace; + } + + attachBeforeRender(callback?: (now: number) => void): void { + this.beforeRender = callback; + } + jumpToPrompt(delta: number): void { this.calls.push(`jumpToPrompt:${delta}`); } From b5fc01c8873349bcdaef7cd3248daa5f84fb9d7c Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:43:47 -0400 Subject: [PATCH 210/295] refactor(client): scroll and kitty feature installers, and SmoothScroll releases its subscriptions installScroll attaches the wheel handler, the wheel pace and the before-render hook (SPEC step 8) and detaches them on dispose. installKitty lays the overlay over the terminal and returns the KittyGraphics itself, so the per-message feed() stays one direct call. Not wired in yet: index.ts moves to the composition root in the next commit. --- .../features/kitty/kitty-feature.test.ts | 72 ++++++++++++++++++ src/client/features/kitty/kitty-feature.ts | 50 +++++++++++++ .../features/scroll/scroll-feature.test.ts | 74 +++++++++++++++++++ src/client/features/scroll/scroll-feature.ts | 38 ++++++++++ .../features/scroll/smooth-scroll.test.ts | 11 +++ src/client/features/scroll/smooth-scroll.ts | 20 +++-- 6 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 src/client/features/kitty/kitty-feature.test.ts create mode 100644 src/client/features/kitty/kitty-feature.ts create mode 100644 src/client/features/scroll/scroll-feature.test.ts create mode 100644 src/client/features/scroll/scroll-feature.ts diff --git a/src/client/features/kitty/kitty-feature.test.ts b/src/client/features/kitty/kitty-feature.test.ts new file mode 100644 index 0000000..bd9dadc --- /dev/null +++ b/src/client/features/kitty/kitty-feature.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { ManualClock } from '../../test-support/manual-clock'; +import { recordListeners } from '../../test-support/recording-target'; +import { FakeDecoder } from './graphics/test-support/graphics-harness'; +import { installKitty } from './kitty-feature'; + +let fake: FakeContext; +let clock: ManualClock; +let frames: { devicePixelRatio: number } & Pick< + ManualClock, + 'requestAnimationFrame' | 'cancelAnimationFrame' +>; + +beforeEach(() => { + clock = new ManualClock(); + frames = { + devicePixelRatio: 1, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + }; +}); + +afterEach(async () => { + await fake.dom.close(); +}); + +const ESC = '\x1b'; + +describe('installKitty', () => { + test('lays the overlay over the container and listens for the tab coming back', () => { + fake = fakeContext(); + const doc = recordListeners(fake.ctx.doc, 'document'); + installKitty(fake.ctx, { send: () => {}, window: frames, decoder: new FakeDecoder() }); + expect(fake.container.querySelector('canvas[data-kitty]')).not.toBeNull(); + expect(doc.active.map((r) => r.type)).toEqual(['visibilitychange']); + }); + + test('feed writes the text to the terminal and answers a query through send', () => { + fake = fakeContext(); + const sent: string[] = []; + const kitty = installKitty(fake.ctx, { + send: (data) => sent.push(data), + window: frames, + decoder: new FakeDecoder(), + }); + kitty.feed(`a${ESC}_Ga=q,i=31,s=1,v=1,f=24;AAAA${ESC}\\b`); + expect(fake.term.output).toBe('ab'); + expect(sent).toEqual([`${ESC}_Gi=31;OK${ESC}\\`]); + kitty.reset(); + kitty.clear(); + }); + + test('the browser decoder is the default', () => { + fake = fakeContext(); + const kitty = installKitty(fake.ctx, { send: () => {}, window: frames }); + kitty.feed('x'); + expect(fake.term.output).toBe('x'); + }); + + test('dispose removes the overlay, its listener and its frame; twice is a no-op', () => { + fake = fakeContext(); + const doc = recordListeners(fake.ctx.doc, 'document'); + const kitty = installKitty(fake.ctx, { send: () => {}, window: frames }); + expect(clock.pending.frames).toBe(1); + kitty.dispose(); + kitty.dispose(); + expect(fake.container.querySelector('canvas[data-kitty]')).toBeNull(); + expect(doc.active).toEqual([]); + expect(clock.pending.frames).toBe(0); + }); +}); diff --git a/src/client/features/kitty/kitty-feature.ts b/src/client/features/kitty/kitty-feature.ts new file mode 100644 index 0000000..b046e01 --- /dev/null +++ b/src/client/features/kitty/kitty-feature.ts @@ -0,0 +1,50 @@ +// Kitty graphics for the tab (feature, ADR 032, ADR 041, ADR 060). The wasm32 +// libghostty-vt build leaves kitty graphics out and discards the protocol's APC +// sequences, so PTY output goes through this feature's feed() instead of term.write(). + +import type { ClientContext, Feature } from '../../app/client-context'; +import { BROWSER_DECODER, type ImageDecoderDeps } from './graphics/image-decoder'; +import { KittyGraphics } from './graphics/kitty-graphics'; +import type { OverlayDeps } from './graphics/overlay-canvas'; + +/** What kitty graphics needs besides the context. */ +export interface KittyDeps { + /** + * Sends a reply to the PTY as if typed. The root's connection is created after this + * feature, so the root passes a function that reads it only when a reply is sent. + */ + send(data: string): void; + /** The pixel ratio and the frames the overlay repaints on. */ + window: OverlayDeps['window']; + /** The browser's image constructors by default. */ + decoder?: ImageDecoderDeps; +} + +/** The graphics API the output pipeline, the connection and Cmd+K use. */ +export interface KittyFeature extends Feature { + /** Writes PTY output to the terminal, taking the graphics commands out (hot path). */ + feed(data: string): void; + /** Forgets the previous PTY stream's images and partial commands, on every (re)open. */ + reset(): void; + /** Drops the placements, as the screen they were on is gone (ADR 054). */ + clear(): void; +} + +/** + * Lays the overlay canvas over the terminal (into the container, with a document + * `visibilitychange` listener). The KittyGraphics itself is returned, not a wrapper, so + * the per-message feed() stays one direct call. + */ +export function installKitty(ctx: ClientContext, deps: KittyDeps): KittyFeature { + return new KittyGraphics( + ctx.term, + ctx.container, + deps.send, + {}, + { + document: ctx.doc, + window: deps.window, + decoder: deps.decoder ?? BROWSER_DECODER, + }, + ); +} diff --git a/src/client/features/scroll/scroll-feature.test.ts b/src/client/features/scroll/scroll-feature.test.ts new file mode 100644 index 0000000..064fe02 --- /dev/null +++ b/src/client/features/scroll/scroll-feature.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeContext, fakeContext } from '../../test-support/fake-context'; +import { AT_ONCE, type Pace } from '../../vendor/ghostty-web'; +import { installScroll, type ScrollDeps } from './scroll-feature'; + +let fake: FakeContext; + +afterEach(async () => { + await fake.dom.close(); +}); + +/** A SmoothScroll and WheelStream stand-in that log what the hooks ask of them. */ +function scrollParts(takes = true) { + const log: string[] = []; + const pace: Pace = AT_ONCE; + const smoothScroll: ScrollDeps['smoothScroll'] = { + handleWheel: () => { + log.push('handleWheel'); + return takes; + }, + frame: (now) => { + log.push(`frame@${now}`); + }, + }; + const wheelStream: ScrollDeps['wheelStream'] = { + forApp: () => { + log.push('forApp'); + return pace; + }, + }; + return { log, pace, smoothScroll, wheelStream }; +} + +const wheel = { deltaY: -10 } as WheelEvent; + +describe('installScroll', () => { + test('the wheel handler scrolls the scrollback unless the app tracks the mouse', () => { + fake = fakeContext(); + const parts = scrollParts(); + installScroll(fake.ctx, parts); + expect(fake.term.wheelHandler?.(wheel)).toBe(true); + fake.term.mouseTracking = true; + expect(fake.term.wheelHandler?.(wheel)).toBe(false); + expect(parts.log).toEqual(['handleWheel']); + }); + + test('the handler leaves to ghostty-web what SmoothScroll does not take', () => { + fake = fakeContext(); + const parts = scrollParts(false); + installScroll(fake.ctx, parts); + expect(fake.term.wheelHandler?.(wheel)).toBe(false); + }); + + test('the app wheel is paced by the stream, and each render advances the glide first', () => { + fake = fakeContext(); + const parts = scrollParts(); + installScroll(fake.ctx, parts); + expect(fake.term.wheelPace?.(wheel)).toBe(parts.pace); + fake.term.beforeRender?.(42); + expect(parts.log).toEqual(['forApp', 'frame@42']); + }); + + test('dispose detaches the three hooks; twice is a no-op', () => { + fake = fakeContext(); + const feature = installScroll(fake.ctx, scrollParts()); + feature.dispose(); + feature.dispose(); + expect([fake.term.wheelHandler, fake.term.wheelPace, fake.term.beforeRender]).toEqual([ + undefined, + undefined, + undefined, + ]); + }); +}); diff --git a/src/client/features/scroll/scroll-feature.ts b/src/client/features/scroll/scroll-feature.ts new file mode 100644 index 0000000..6266180 --- /dev/null +++ b/src/client/features/scroll/scroll-feature.ts @@ -0,0 +1,38 @@ +// Hooks wheel scrolling into the terminal (feature, ADR 017, ADR 040, ADR 045, ADR 060). +// WheelStream tells a mouse wheel, a trackpad and a TrackPoint apart and picks how each +// event moves: on the normal screen, unless the app tracks the mouse, SmoothScroll glides +// the scrollback pixel by pixel, advanced just before each render; otherwise the terminal +// sends the app mouse wheel reports in the app's mouse format (ADR 049), or arrow keys on +// the alternate screen, paced the same way. + +import type { ClientContext, Feature } from '../../app/client-context'; +import { toDisposable } from '../../vendor/ghostty-web'; +import type { SmoothScroll } from './smooth-scroll'; +import type { WheelStream } from './wheel-stream'; + +/** What the scroll hooks need besides the context: the objects the root built at the layout step. */ +export interface ScrollDeps { + /** Built before the layout, which re-reads its row height after every fit. */ + smoothScroll: Pick<SmoothScroll, 'handleWheel' | 'frame'>; + wheelStream: Pick<WheelStream, 'forApp'>; +} + +/** The installed scroll hooks; nothing to call, only to dispose. */ +export type ScrollFeature = Feature; + +/** Attaches the custom wheel handler, the wheel pace and the before-render hook. */ +export function installScroll(ctx: ClientContext, deps: ScrollDeps): ScrollFeature { + const { term } = ctx; + const { smoothScroll, wheelStream } = deps; + term.attachCustomWheelEventHandler( + (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), + ); + term.attachWheelPace((e: WheelEvent) => wheelStream.forApp(e)); + term.attachBeforeRender((now: number) => smoothScroll.frame(now)); + // The terminal holds one of each, so detaching is setting them back to none. + return toDisposable(() => { + term.attachCustomWheelEventHandler(); + term.attachWheelPace(); + term.attachBeforeRender(); + }); +} diff --git a/src/client/features/scroll/smooth-scroll.test.ts b/src/client/features/scroll/smooth-scroll.test.ts index 0d2c0db..a4ce10a 100644 --- a/src/client/features/scroll/smooth-scroll.test.ts +++ b/src/client/features/scroll/smooth-scroll.test.ts @@ -15,6 +15,7 @@ describe('SmoothScroll', () => { const offsets: number[] = []; let scrolled = (_y: number) => {}; let resized = () => {}; + const unsubscribed: string[] = []; const term = { viewportY: 0, rows: 40, @@ -25,9 +26,11 @@ describe('SmoothScroll', () => { }, onScroll: (cb: (y: number) => void) => { scrolled = cb; + return { dispose: () => unsubscribed.push('onScroll') }; }, onResize: (cb: () => void) => { resized = cb; + return { dispose: () => unsubscribed.push('onResize') }; }, getScrollbackLength: () => 100, scrollToLine(line: number) { @@ -53,6 +56,7 @@ describe('SmoothScroll', () => { metrics, offsets, scroll, + unsubscribed, scrolled: (y: number) => scrolled(y), resized: () => resized(), }; @@ -64,6 +68,13 @@ describe('SmoothScroll', () => { live.length = 0; }); + test('dispose drops its scroll and resize subscriptions; twice is a no-op', () => { + const { scroll, unsubscribed } = fake(10); + scroll.dispose(); + scroll.dispose(); + expect(unsubscribed.sort()).toEqual(['onResize', 'onScroll']); + }); + test('update re-snaps the offset to a new device pixel ratio', () => { const { term, offsets, scroll } = fake(13); // 0.69 x 13 = 8.97 CSS px, snapped to 9 at 1x. diff --git a/src/client/features/scroll/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts index fb9f982..aa5d150 100644 --- a/src/client/features/scroll/smooth-scroll.ts +++ b/src/client/features/scroll/smooth-scroll.ts @@ -19,7 +19,7 @@ * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ -import { Glide, type Terminal } from '../../vendor/ghostty-web'; +import { type Disposable, DisposableStore, Glide, type Terminal } from '../../vendor/ghostty-web'; import { splitScroll, wheelLines } from './scroll-split'; import type { WheelStream } from './wheel-stream'; @@ -30,9 +30,10 @@ export interface SmoothScrollDeps { } /** Scrolls the terminal's scrollback by pixels: a fractional position that glides. */ -export class SmoothScroll { +export class SmoothScroll implements Disposable { /** The position in lines scrolled up from the bottom, fractional between rows. */ private readonly glide = new Glide(); + private readonly store = new DisposableStore(); /** Set while `apply` moves ghostty-web's viewport, whose onScroll is then our own. */ private applying = false; @@ -43,10 +44,17 @@ export class SmoothScroll { ) { // ghostty-web moves the viewport itself on new output (to the bottom), keyboard // scrolling and scrollbar drags. Follow it whenever it lands on a different line. - term.onScroll((y: number) => { - if (!this.applying && y !== Math.floor(this.glide.value)) this.jump(y); - }); - term.onResize(() => this.jump(term.viewportY)); + this.store.add( + term.onScroll((y: number) => { + if (!this.applying && y !== Math.floor(this.glide.value)) this.jump(y); + }), + ); + this.store.add(term.onResize(() => this.jump(term.viewportY))); + } + + /** Stops following the terminal's scroll and resize. Idempotent. */ + dispose(): void { + this.store.dispose(); } /** Handles a wheel event on the normal screen. Returns false to leave it to ghostty-web. */ From 552506e91a4399bb6b57ef88b920713838d23bf0 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:45:43 -0400 Subject: [PATCH 211/295] docs(arch): ADR 063 and cli SPEC describe the CliDeps and WebttyClient S14 built CliDeps lists its real fields (no spawn), WebttyClient takes (baseUrl, fetch, openSocket) with openPty and no getSession, and the ServerUnreachableError convention is recorded. getPort() wording becomes loadConfig().port. --- docs/adrs/063.cli.command-modules.md | 8 ++++---- docs/specs/cli.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/adrs/063.cli.command-modules.md b/docs/adrs/063.cli.command-modules.md index 93d1fce..fb556e3 100644 --- a/docs/adrs/063.cli.command-modules.md +++ b/docs/adrs/063.cli.command-modules.md @@ -16,12 +16,12 @@ ## Decision - **`main.ts`** (`main(argv, deps)`) is the CLI's composition root; `index.ts` stays the entry (`dist/cli/index.js`, the `bin`) and only calls it. `cli-args.ts` (`parseArgs`, pure, including `--dir` at the end of `argv`) and `help-text.ts` are pure. -- **`CliDeps`** (`cli-deps.ts`): `{ client, out, err, exit, spawn, config }`, built once from the real globals in `main.ts` (the default for `main`'s `deps` parameter, so `index.ts` only calls `main(process.argv)`). Every command takes `(args, deps)`, so tests capture output and the exit code without subprocesses or namespace spies. -- **`api-client.ts`**: `WebttyClient(baseUrl, fetch)` with `isRunning`, `getSession`, `ensureSession` (the one copy of ensure-or-create), `listSessions`, `removeSession`, `renameSession`, `stop` and `execute` (an async iterator of NDJSON messages over a `LineSplitter`). -- **`server-process.ts`**: `resolveServerEntry({ runningFromSrc, useNode })` (pure, four cases tested), `startServer`, `stopServer`. **`browser-opener.ts`**: `openBrowser`, `toBrowserHost`. +- **`CliDeps`** (`cli-deps.ts`): `{ client, config, out, err, write, exit, version, server, openBrowser, editor, tty }`, built once from the real globals by `createCliDeps()` (the default for `main`'s `deps` parameter, so `index.ts` only calls `main(process.argv)`). `config` is `loadConfig`, read on every call; `write` is raw stdout/stderr for `run`'s streamed output; `server` is `{ start, stop }` over `server-process.ts`; `editor` (`EditorHost`) and `tty` (`KeyTty`) are the process-facing hosts of `edit-config` and `key`. Nothing is read when the deps are built, so `-v` and `help` never touch the config file. Every command takes `(args, deps)` (or the `Pick<CliDeps, …>` it needs), so tests capture output and the exit code without subprocesses or namespace spies. +- **`api-client.ts`**: `WebttyClient(baseUrl, fetch, openSocket)`, where `baseUrl` is a string or a function read on every request and `fetch`/`openSocket` default to the globals, with `isRunning`, `listSessions`, `ensureSession` (the one copy of ensure-or-create), `removeSession`, `renameSession`, `stop`, `execute` (an async iterator of NDJSON messages over the shared `LineSplitter`) and `openPty` (opens and closes the session's PTY socket, for `run`'s warm-up). `configuredClient()` builds the client for `loadConfig().port` on 127.0.0.1. A request that gets no response throws `ServerUnreachableError`; commands catch only that error to print their "not running" message and let any other error propagate. +- **`server-process.ts`**: `resolveServerEntry({ cliDir, runningFromSrc, useNode })` (pure, four cases tested), `startServer`, `stopServer`. **`browser-opener.ts`**: `openBrowser`, `toBrowserHost`, `sessionPageUrl`. - **`commands/`**: one file per command (`go`, `list`, `remove`, `rename`, `start-stop`, `edit-config`, `run`, `key`) plus `key-format.ts`; `run.ts` is two functions, `warmUp` and `exec`. - `commands.test.ts` and `http.test.ts` split by command and migrate from namespace spies to injected deps in the same commits as the split. -- Output text, exit codes, flags and aliases stay byte-identical. Tests that start a server keep a temporary `HOME` so `getPort()` never falls back to the user's configured port. +- Output text, exit codes, flags and aliases stay byte-identical. Tests that start a server keep a temporary `HOME` so `loadConfig().port` never resolves to the user's configured port. ## Consequences diff --git a/docs/specs/cli.md b/docs/specs/cli.md index 93819f9..7286878 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -56,7 +56,7 @@ The command exits when the editor exits. ## Code structure -`src/cli/index.ts` is the entry and `main.ts` the composition root; each command is a module under `src/cli/commands/` that takes its dependencies (`CliDeps`: the API client, output, exit, spawn, config) as an argument ([ADR 063](../adrs/063.cli.command-modules.md)). See the [architecture SPEC](architecture.md) for the repo-wide layout and conventions. +`src/cli/index.ts` is the entry and `main.ts` the composition root; each command is a module under `src/cli/commands/` that takes its dependencies (`CliDeps`: the API client, config, output, exit, version, server control, browser opener, editor and TTY) as an argument ([ADR 063](../adrs/063.cli.command-modules.md)). See the [architecture SPEC](architecture.md) for the repo-wide layout and conventions. ## Features From a4674a03452583db80c09a3178d15a5437ec51f9 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:49:29 -0400 Subject: [PATCH 212/295] test(client): fakeClientEnv runs the client root on fakes, with one listener timeline across targets --- src/client/test-support.test.ts | 63 +++++++++- src/client/test-support/fake-client-env.ts | 126 ++++++++++++++++++++ src/client/test-support/recording-target.ts | 3 + 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/client/test-support/fake-client-env.ts diff --git a/src/client/test-support.test.ts b/src/client/test-support.test.ts index 0005136..c86de47 100644 --- a/src/client/test-support.test.ts +++ b/src/client/test-support.test.ts @@ -3,13 +3,18 @@ import { describe, expect, test } from 'bun:test'; import { createDomWindow } from './test-support/dom-window'; +import { fakeClientEnv } from './test-support/fake-client-env'; import { FAKE_CONFIG, fakeContext } from './test-support/fake-context'; import { fakeResizeObserver } from './test-support/fake-resize-observer'; import { fakeWebSocket } from './test-support/fake-socket'; import { FakeTerminal } from './test-support/fake-terminal'; import { FakeWindow } from './test-support/fake-window'; import { ManualClock } from './test-support/manual-clock'; -import { RecordingTarget, recordListeners } from './test-support/recording-target'; +import { + type ListenerRecord, + RecordingTarget, + recordListeners, +} from './test-support/recording-target'; import { AT_ONCE } from './vendor/ghostty-web'; describe('ManualClock', () => { @@ -116,6 +121,22 @@ describe('RecordingTarget', () => { expect(log).toEqual(['fn', 'object:focus', 'object:focus']); expect(target.active.map((r) => r.type)).toEqual(['focus', 'blur']); }); + + test('targets sharing a timeline log their adds into it in order', () => { + const timeline: ListenerRecord[] = []; + const first = new RecordingTarget('first'); + const second = new RecordingTarget('second'); + first.timeline = timeline; + second.timeline = timeline; + first.addEventListener('keydown', a); + second.addEventListener('focus', b); + first.addEventListener('mousedown', b); + expect(timeline.map((r) => `${r.target} ${r.type}`)).toEqual([ + 'first keydown', + 'second focus', + 'first mousedown', + ]); + }); }); describe('recordListeners', () => { @@ -352,6 +373,46 @@ describe('fakeContext', () => { }); }); +describe('fakeClientEnv', () => { + test('serves the config, loads a stand-in engine and builds the fake terminal', async () => { + const fake = fakeClientEnv({ config: { fontSize: 20 }, platform: 'MacIntel' }); + const { env } = fake; + expect(await (await env.fetch('/api/config')).json()).toMatchObject({ fontSize: 20 }); + const ghostty = await env.loadEngine('/engine.wasm'); + expect(env.createTerminal({ ghostty }) as object).toBe(fake.term); + expect([fake.fetched, fake.engines, fake.terminalOptions]).toEqual([ + ['/api/config'], + ['/engine.wasm'], + [{ ghostty }], + ]); + expect(env.navigator.platform).toBe('MacIntel'); + expect(env.document.getElementById('terminal')).toBe(fake.container); + expect(env.window.location.pathname).toBe('/s/work'); + await fake.dom.close(); + }); + + test('records the window, document and container listeners into one timeline', async () => { + const fake = fakeClientEnv(); + fake.container.addEventListener('focusin', () => {}); + fake.env.document.addEventListener('visibilitychange', () => {}); + fake.env.window.addEventListener('keydown', () => {}, true); + expect(fake.timeline.map((r) => `${r.target} ${r.type}`)).toEqual([ + 'container focusin', + 'document visibilitychange', + 'window keydown', + ]); + expect(fake.docListeners.active).toHaveLength(1); + expect(fake.containerListeners.active).toHaveLength(1); + let loaded = false; + void fake.env.document.fonts.ready.then(() => { + loaded = true; + }); + await fake.fontsLoaded(); + expect(loaded).toBe(true); + await fake.dom.close(); + }); +}); + describe('createDomWindow', () => { test('gives a document of its own, not a global one', async () => { const dom = createDomWindow(); diff --git a/src/client/test-support/fake-client-env.ts b/src/client/test-support/fake-client-env.ts new file mode 100644 index 0000000..8bded33 --- /dev/null +++ b/src/client/test-support/fake-client-env.ts @@ -0,0 +1,126 @@ +// A ClientEnv on fakes for the client root's tests (test fake, ADR 064): a FakeTerminal +// from createTerminal, FakeSockets, a ManualClock for the timers and frames, a recording +// FakeWindow, and an injected happy-dom document holding `#terminal`, whose own listeners +// and the container's are recorded too, all into one shared timeline. + +import type { ClientEnv } from '../app/browser-env'; +import type { ClientConfig } from '../app/client-config'; +import type { Ghostty, ITerminalOptions } from '../vendor/ghostty-web'; +import { createDomWindow, type DomWindow } from './dom-window'; +import { FAKE_CONFIG } from './fake-context'; +import { type FakeResizeObserverClass, fakeResizeObserver } from './fake-resize-observer'; +import { type FakeWebSocket, fakeWebSocket } from './fake-socket'; +import { FakeTerminal } from './fake-terminal'; +import { FakeWindow } from './fake-window'; +import { ManualClock } from './manual-clock'; +import { type ListenerRecord, type RecordingTarget, recordListeners } from './recording-target'; + +/** What the fake env is built from; each field has a default. */ +export interface FakeClientEnvOptions { + /** The page URL; its path names the session. */ + url?: string; + config?: Partial<ClientConfig>; + /** `navigator.platform`; a Mac one turns on Ghostty's macOS keys. */ + platform?: string; +} + +/** The env and every fake behind it, for the test to drive and inspect. */ +export interface FakeClientEnv { + env: ClientEnv; + term: FakeTerminal; + win: FakeWindow; + dom: DomWindow; + container: HTMLElement; + /** The listeners added to the document and the container. */ + docListeners: RecordingTarget; + containerListeners: RecordingTarget; + /** Every listener added to the window, the document and the container, in order. */ + timeline: ListenerRecord[]; + sockets: FakeWebSocket; + observers: FakeResizeObserverClass; + clock: ManualClock; + /** The URLs fetched, the engine URLs loaded and the options each terminal was built with. */ + fetched: string[]; + engines: string[]; + terminalOptions: ITerminalOptions[]; + /** Resolves `document.fonts.ready`. */ + fontsLoaded(): Promise<void>; +} + +/** Builds a ClientEnv on fakes; close `dom` in the test's teardown. */ +export function fakeClientEnv(options: FakeClientEnvOptions = {}): FakeClientEnv { + const url = options.url ?? 'http://localhost/s/work'; + const dom = createDomWindow(url); + const { document } = dom; + const container = document.createElement('div'); + container.id = 'terminal'; + document.body.append(container); + let loaded: () => void = () => {}; + const ready = new Promise<void>((resolve) => { + loaded = resolve; + }); + Object.defineProperty(document, 'fonts', { value: { ready }, configurable: true }); + + const timeline: ListenerRecord[] = []; + const win = new FakeWindow(url); + const docListeners = recordListeners(document, 'document'); + const containerListeners = recordListeners(container, 'container'); + for (const target of [win, docListeners, containerListeners]) target.timeline = timeline; + + const term = new FakeTerminal(); + const sockets = fakeWebSocket(); + const observers = fakeResizeObserver(); + const clock = new ManualClock(); + const fetched: string[] = []; + const engines: string[] = []; + const terminalOptions: ITerminalOptions[] = []; + const config = { ...FAKE_CONFIG, ...options.config }; + const navigator = { platform: options.platform ?? 'Linux x86_64' } as Navigator; + + const env: ClientEnv = { + window: win.asWindow(), + document, + navigator, + fetch: async (input) => { + fetched.push(input); + return Response.json(config); + }, + WebSocket: sockets.WebSocket, + ResizeObserver: observers.ResizeObserver, + Notification: undefined, + performance: { now: clock.now }, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + requestAnimationFrame: clock.requestAnimationFrame, + cancelAnimationFrame: clock.cancelAnimationFrame, + loadEngine: async (engineUrl) => { + engines.push(engineUrl); + // The fake terminal never reaches the engine; the options only carry it. + return {} as Ghostty; + }, + createTerminal: (terminal) => { + terminalOptions.push(terminal); + return term.asTerminal(); + }, + }; + return { + env, + term, + win, + dom, + container, + docListeners, + containerListeners, + timeline, + sockets, + observers, + clock, + fetched, + engines, + terminalOptions, + fontsLoaded: async () => { + loaded(); + await ready; + }, + }; +} diff --git a/src/client/test-support/recording-target.ts b/src/client/test-support/recording-target.ts index b0a2bdd..9878808 100644 --- a/src/client/test-support/recording-target.ts +++ b/src/client/test-support/recording-target.ts @@ -28,6 +28,8 @@ export class RecordingTarget implements ListenerTarget { readonly added: ListenerRecord[] = []; /** Every remove that matched a live listener, in order. */ readonly removed: ListenerRecord[] = []; + /** When set, every add is appended here too, so several targets share one ordered log. */ + timeline?: ListenerRecord[]; private live: ListenerRecord[] = []; constructor(readonly name = 'target') {} @@ -48,6 +50,7 @@ export class RecordingTarget implements ListenerTarget { if (options.once !== undefined) record.once = options.once; } this.added.push(record); + this.timeline?.push(record); // The DOM adds a listener once per (type, listener, capture). if (!this.live.some((other) => sameListener(other, record))) this.live.push(record); } From 82371ebbfa704de0d0baedb9cdb298e4b87c8013 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:49:29 -0400 Subject: [PATCH 213/295] refactor(client): app/start-client.ts is the composition root; index.ts is an entry startClient(env) wires the terminal and every feature in the SPEC's 13 numbered steps on the globals in ClientEnv and returns one disposer; index.ts is await startClient(browserEnv()). Step 7 is app/connect-pty.ts, so the on-open sequence and the per-message order are tested on their own; each socket's OutputAck now takes the env's timers (the page's own in production). The views that paint on frames get the env's frames through frameWindow(env), the same window calls as before. Cmd+K's clear is screenClearer(). start-client.test.ts runs the root on fakeClientEnv and pins every listener in install order against the SPEC's per-target table, the emitter subscription order, the on-open sequence, output before its ack, input, reconnect, Cmd+K, zoom, and that dispose removes every listener, view, hook, frame and timer. The checker gives src/client/index.ts a 20-line limit. No app file was in the ratchet allowlist, so there was nothing to remove. --- scripts/architecture/size-limits.test.ts | 2 + scripts/architecture/size-limits.ts | 11 +- src/client/app/browser-env.test.ts | 23 ++- src/client/app/browser-env.ts | 17 ++ src/client/app/connect-pty.test.ts | 99 ++++++++++ src/client/app/connect-pty.ts | 77 ++++++++ src/client/app/start-client.test.ts | 242 +++++++++++++++++++++++ src/client/app/start-client.ts | 196 ++++++++++++++++++ src/client/connection/output-ack.ts | 7 +- src/client/index.ts | 202 +------------------ 10 files changed, 673 insertions(+), 203 deletions(-) create mode 100644 src/client/app/connect-pty.test.ts create mode 100644 src/client/app/connect-pty.ts create mode 100644 src/client/app/start-client.test.ts create mode 100644 src/client/app/start-client.ts diff --git a/scripts/architecture/size-limits.test.ts b/scripts/architecture/size-limits.test.ts index 2bbc7c3..f3fb482 100644 --- a/scripts/architecture/size-limits.test.ts +++ b/scripts/architecture/size-limits.test.ts @@ -14,6 +14,8 @@ const V = 'src/client/vendor/ghostty-web/'; describe('limitFor', () => { const cases: [string, number][] = [ ['src/client/output-ack.ts', SOURCE_LIMIT], + ['src/client/index.ts', 20], + ['src/client/app/start-client.ts', SOURCE_LIMIT], ['src/server/app.ts', SOURCE_LIMIT], ['scripts/build.ts', SOURCE_LIMIT], ['src/client/test-support/fake-socket.ts', SOURCE_LIMIT], diff --git a/scripts/architecture/size-limits.ts b/scripts/architecture/size-limits.ts index cd9a402..db2a686 100644 --- a/scripts/architecture/size-limits.ts +++ b/scripts/architecture/size-limits.ts @@ -16,8 +16,12 @@ export const VENDOR_LIMIT = 600; /** Test files (aim 600). */ export const TEST_LIMIT = 800; -/** The vendored facades and the two engine files with limits of their own (ADR 061). */ +/** + * Files with limits of their own: the browser entry, which only starts the composition + * root (ADR 060), and the vendored facades and two engine files (ADR 061). + */ export const OWN_LIMITS: Record<string, number> = { + 'src/client/index.ts': 20, [`${VENDOR_ROOT}ghostty.ts`]: 300, [`${VENDOR_ROOT}terminal.ts`]: 600, [`${VENDOR_ROOT}renderer.ts`]: 500, @@ -42,7 +46,10 @@ export function limitFor(file: string): SizeLimit { if (file in EXEMPT) return { max: Number.POSITIVE_INFINITY, reason: EXEMPT[file] }; if (isTestFile(file)) return { max: TEST_LIMIT, reason: 'test file' }; if (file in OWN_LIMITS) - return { max: OWN_LIMITS[file], reason: 'facade or engine file, limit in the SPEC tree' }; + return { + max: OWN_LIMITS[file], + reason: 'entry, facade or engine file, limit in the SPEC tree', + }; if (file.startsWith(VENDOR_ROOT)) return { max: VENDOR_LIMIT, reason: 'vendored source' }; return { max: SOURCE_LIMIT, reason: 'source' }; } diff --git a/src/client/app/browser-env.test.ts b/src/client/app/browser-env.test.ts index 3996eea..8a71e6a 100644 --- a/src/client/app/browser-env.test.ts +++ b/src/client/app/browser-env.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createDomWindow, type DomWindow } from '../test-support/dom-window'; import { Ghostty, Terminal } from '../vendor/ghostty-web'; -import { browserEnv } from './browser-env'; +import { browserEnv, frameWindow } from './browser-env'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), @@ -81,4 +81,25 @@ describe('browserEnv', () => { expect([term.cols, term.rows]).toEqual([40, 10]); term.dispose(); }); + + test("frameWindow reads the window's pixel ratio live and frames through env", () => { + const { window } = dom; + const env = browserEnv(window); + const frames: string[] = []; + env.requestAnimationFrame = () => { + frames.push('request'); + return 3; + }; + env.cancelAnimationFrame = (id) => { + frames.push(`cancel ${id}`); + }; + const view = frameWindow(env); + Object.defineProperty(window, 'devicePixelRatio', { value: 2, configurable: true }); + expect(view.devicePixelRatio).toBe(2); + Object.defineProperty(window, 'devicePixelRatio', { value: 1.5, configurable: true }); + expect(view.devicePixelRatio).toBe(1.5); + expect(view.requestAnimationFrame(() => {})).toBe(3); + view.cancelAnimationFrame(3); + expect(frames).toEqual(['request', 'cancel 3']); + }); }); diff --git a/src/client/app/browser-env.ts b/src/client/app/browser-env.ts index 8707e9b..e1868af 100644 --- a/src/client/app/browser-env.ts +++ b/src/client/app/browser-env.ts @@ -58,3 +58,20 @@ export function browserEnv(win: Window & typeof globalThis = window): ClientEnv createTerminal: (options) => new Terminal(options), }; } + +/** The window as the views that paint on animation frames use it (edge strips, composer, kitty overlay). */ +export type FrameWindow = Pick< + Window, + 'devicePixelRatio' | 'requestAnimationFrame' | 'cancelAnimationFrame' +>; + +/** `env`'s frames and its window's pixel ratio, read live, as one FrameWindow. */ +export function frameWindow(env: ClientEnv): FrameWindow { + return { + get devicePixelRatio() { + return env.window.devicePixelRatio; + }, + requestAnimationFrame: (callback) => env.requestAnimationFrame(callback), + cancelAnimationFrame: (id) => env.cancelAnimationFrame(id), + }; +} diff --git a/src/client/app/connect-pty.test.ts b/src/client/app/connect-pty.test.ts new file mode 100644 index 0000000..f05df37 --- /dev/null +++ b/src/client/app/connect-pty.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeWebSocket } from '../test-support/fake-socket'; +import { FakeTerminal } from '../test-support/fake-terminal'; +import { FakeWindow } from '../test-support/fake-window'; +import { ManualClock } from '../test-support/manual-clock'; +import { connectPty, type PtyParts } from './connect-pty'; + +/** A connection on fakes whose parts and terminal log each call into one list. */ +function connected() { + const log: string[] = []; + const term = new FakeTerminal(); + const sockets = fakeWebSocket(); + const clock = new ManualClock(); + const win = new FakeWindow('https://example.test/s/work'); + term.resyncFocus = () => log.push(`term.resyncFocus sent=${sockets.last().sent.length}`); + term.write = (data) => log.push(`term.write ${String(data).length}`); + term.reset = () => log.push('term.reset'); + const parts: PtyParts = { + replay: { + connected: () => log.push('replay.connected'), + output: (chars) => log.push(`replay.output ${chars}`), + parsed: () => log.push('replay.parsed'), + }, + graphics: { + reset: () => log.push(`graphics.reset sent=${sockets.last().sent.length}`), + feed: (data) => log.push(`graphics.feed ${data}`), + }, + smoothScroll: { onOutput: () => log.push('smoothScroll.onOutput') }, + connected: () => log.push('clipboard.connected'), + }; + const connection = connectPty( + 'work', + term.asTerminal(), + { + window: win.asWindow(), + WebSocket: sockets.WebSocket, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + performance: { now: clock.now }, + }, + parts, + ); + return { log, sockets, clock, win, connection }; +} + +describe('connectPty', () => { + test('connects at once to the session, telling the clipboard and then the replay', () => { + const { log, sockets } = connected(); + expect(sockets.last().url).toBe('wss://example.test/ws/work/pty?cols=80&rows=24&flow=1'); + expect(log).toEqual(['clipboard.connected', 'replay.connected']); + }); + + test('on open: graphics start over, the size goes out, then the focus is resynced', () => { + const { log, sockets } = connected(); + log.length = 0; + sockets.last().open(); + expect(log).toEqual(['graphics.reset sent=0', 'term.resyncFocus sent=1']); + expect(sockets.last().sent).toEqual([JSON.stringify({ type: 'resize', cols: 80, rows: 24 })]); + }); + + test('each message runs the pipeline in order, and its ack follows on the clock', () => { + const { log, sockets, clock } = connected(); + const socket = sockets.last(); + socket.open(); + log.length = 0; + socket.receive('hello'); + expect(log).toEqual([ + 'replay.output 5', + 'graphics.feed hello', + 'replay.parsed', + 'smoothScroll.onOutput', + ]); + clock.advance(100); + expect(socket.sent.at(-1)).toBe(JSON.stringify({ type: 'ack', chars: 5 })); + }); + + test('a closed socket stops its ack timer before the reconnect', () => { + const { sockets, clock } = connected(); + const socket = sockets.last(); + socket.open(); + socket.receive('hello'); + socket.serverClose(1006); + expect(clock.pending.timers).toBe(1); + clock.advance(2000); + expect(sockets.sockets).toHaveLength(2); + expect(socket.sent).toHaveLength(1); + }); + + test('a fell-behind close resets the terminal before the replay; a gone session closes the tab', () => { + const { log, sockets, clock, win } = connected(); + sockets.last().open(); + log.length = 0; + sockets.last().serverClose(4003); + expect(log.slice(0, 2)).toEqual(['term.reset', expect.stringMatching(/^term\.write/)]); + sockets.last().serverClose(4001); + clock.advance(2000); + expect(win.calls).toEqual(['close']); + }); +}); diff --git a/src/client/app/connect-pty.ts b/src/client/app/connect-pty.ts new file mode 100644 index 0000000..3e67ae2 --- /dev/null +++ b/src/client/app/connect-pty.ts @@ -0,0 +1,77 @@ +// Step 7 of the client root (wiring, ADR 048, ADR 060): the PTY connection and what runs +// around a socket's creation, open, output and close. Kept apart from start-client.ts so +// the on-open sequence and the per-message order are tested on their own. + +import { OutputAck } from '../connection/output-ack'; +import { createOutputHandler, type OutputPipelineParts } from '../connection/output-pipeline'; +import { PtyConnection } from '../connection/pty-connection'; +import type { Terminal } from '../vendor/ghostty-web'; +import type { ClientEnv } from './browser-env'; + +/** The terminal as the connection's host uses it. */ +export type PtyTerminal = Pick<Terminal, 'cols' | 'rows' | 'resyncFocus' | 'write' | 'reset'>; + +/** The globals the connection uses. */ +export type PtyEnv = Pick< + ClientEnv, + 'window' | 'WebSocket' | 'setTimeout' | 'clearTimeout' | 'performance' +>; + +/** The parts of other steps the connection calls into, as structural slices. */ +export interface PtyParts { + /** ReplayWindow: told of each new socket, then fed the output (ADR 053). */ + replay: OutputPipelineParts['replay'] & { connected(): void }; + /** Kitty graphics: fed the output, and started over on every open (ADR 032). */ + graphics: OutputPipelineParts['graphics'] & { reset(): void }; + smoothScroll: OutputPipelineParts['smoothScroll']; + /** The clipboard feature's `connected()`: hold OSC 52 writes until input (ADR 052). */ + connected(): void; +} + +/** Builds the session's PTY connection and starts connecting. */ +export function connectPty( + sessionId: string, + term: PtyTerminal, + env: PtyEnv, + parts: PtyParts, +): PtyConnection { + const { replay, graphics, smoothScroll } = parts; + const connection = new PtyConnection( + sessionId, + { + size: () => ({ cols: term.cols, rows: term.rows }), + created: () => { + // The output starts with the session's replay: hold OSC 52 writes until input + // (ADR 052), and ignore its bells and notifications until its last piece (ADR 053, + // ADR 056) + parts.connected(); + replay.connected(); + }, + opening: () => graphics.reset(), + // Focus changes while the socket was down were dropped: send the state the app's + // mode 1004 now misses (ADR 053). After a reset the mode is off until the replay. + opened: () => term.resyncFocus(), + output: createOutputHandler({ + replay, + graphics, + smoothScroll, + now: () => env.performance.now(), + }), + write: (data) => term.write(data), + reset: () => term.reset(), + closeWindow: () => env.window.close(), + }, + { + ...env, + location: env.window.location, + // Each socket's own ack, on the env's clock (ADR 048) + createAck: (send) => + new OutputAck(send, { + set: (fn, ms) => env.setTimeout(fn, ms), + clear: (handle) => env.clearTimeout(handle as number), + }), + }, + ); + connection.connect(); + return connection; +} diff --git a/src/client/app/start-client.test.ts b/src/client/app/start-client.test.ts new file mode 100644 index 0000000..6bab227 --- /dev/null +++ b/src/client/app/start-client.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type FakeClientEnv, fakeClientEnv } from '../test-support/fake-client-env'; +import type { ListenerRecord } from '../test-support/recording-target'; +import type { Disposable } from '../vendor/ghostty-web'; +import { screenClearer, startClient } from './start-client'; + +let fake: FakeClientEnv; +let client: Disposable | undefined; + +afterEach(async () => { + client?.dispose(); + client = undefined; + await fake.dom.close(); +}); + +async function start(options: Parameters<typeof fakeClientEnv>[0] = {}): Promise<FakeClientEnv> { + fake = fakeClientEnv(options); + client = await startClient(fake.env); + return fake; +} + +const key = (r: ListenerRecord): string => `${r.target}${r.capture ? ' capture' : ''} ${r.type}`; + +/** + * Every app listener the root adds, in the order it adds them, with the step that adds it + * (the SPEC's 13 steps; the find bar's own listeners are on its own elements). + */ +const INSTALL_ORDER: [owner: string, listener: string][] = [ + ['composer', 'container compositionstart'], + ['composer', 'container compositionupdate'], + ['composer', 'container compositionend'], + ['composer', 'container focusout'], + ['layout', 'window resize'], + ['repaint on visible', 'document visibilitychange'], + ['kitty overlay', 'document visibilitychange'], + ['clipboard arming', 'window capture keydown'], + ['clipboard arming', 'window capture mousedown'], + ['clipboard arming', 'window capture paste'], + ['clipboard arming', 'window capture drop'], + ['tab title attended', 'container focusin'], + ['tab title attended', 'window capture keydown'], + ['notifier user input', 'window capture keydown'], + ['notifier user input', 'window capture mousedown'], + ['notifier attended', 'container focusin'], + ['title and notifier attended', 'document visibilitychange'], + ['notifier attended', 'window capture keydown'], + ['key dispatcher', 'container capture keydown'], + ['font zoom', 'window capture keydown'], + ['paste', 'container capture paste'], +]; + +/** The SPEC's per-target listener table ("The client composition root"). */ +const SPEC_TABLE: Record<string, string[]> = { + 'window capture keydown': [ + 'clipboard arming', + 'tab title attended', + 'notifier user input', + 'notifier attended', + 'font zoom', + ], + 'window capture mousedown': ['clipboard arming', 'notifier user input'], + 'container focusin': ['tab title attended', 'notifier attended'], + 'container capture keydown': ['key dispatcher'], + 'document visibilitychange': [ + 'repaint on visible', + 'kitty overlay', + 'title and notifier attended', + ], +}; + +describe('startClient', () => { + test('bootstraps the session, config, engine and terminal in #terminal', async () => { + await start({ config: { theme: { background: '#101010', padding: '#202020' } } }); + expect(fake.fetched).toEqual(['/api/config']); + expect(fake.engines).toEqual(['/ghostty-vt-small.wasm']); + expect(fake.terminalOptions[0]?.theme).toEqual({ background: '#101010' }); + expect(fake.term.calls[0]).toBe('open:#terminal'); + expect(fake.container.style.background).toBe('#202020'); + expect(fake.dom.document.title).toBe('work | webtty'); + expect(fake.sockets.last().url).toBe('ws://localhost/ws/work/pty?cols=80&rows=24&flow=1'); + }); + + test('a page outside /s/ is the main session', async () => { + await start({ url: 'http://localhost/' }); + expect(fake.sockets.last().url).toContain('/ws/main/pty'); + }); + + test('adds the listeners in the documented order, as the SPEC table lists per target', async () => { + await start(); + expect(fake.timeline.map(key)).toEqual(INSTALL_ORDER.map(([, listener]) => listener)); + for (const [target, owners] of Object.entries(SPEC_TABLE)) { + const inOrder = INSTALL_ORDER.filter(([, l]) => l === target).map(([owner]) => owner); + expect(inOrder).toEqual(owners); + } + // The DPR watch listens on its media query, once. + expect(fake.win.queries.map((q) => q.active.map((r) => r.type))).toEqual([['change']]); + }); + + test('subscribes to the terminal emitters in the documented order', async () => { + await start(); + expect(fake.term.subscriptions).toEqual([ + // 2. smooth scroll follows the terminal's own moves and resizes + 'onScroll', + 'onResize', + // 4. clipboard, 5. tab title, 6. notifications + 'onClipboardWrite', + 'onTitleChange', + 'onBell', + 'onDesktopNotification', + // 9. find, 12. input and size, 13. paste + 'onFindChange', + 'onData', + 'onResize', + 'onPasteRequest', + ]); + }); + + test('on open: the size goes out, then the focus state is resynced', async () => { + await start(); + const socket = fake.sockets.last(); + const sentAtResync: string[][] = []; + fake.term.resyncFocus = () => { + sentAtResync.push([...socket.sent]); + }; + socket.open(); + expect(sentAtResync).toEqual([[JSON.stringify({ type: 'resize', cols: 80, rows: 24 })]]); + }); + + test('output is written through the graphics, then acknowledged after the parse', async () => { + await start(); + const socket = fake.sockets.last(); + socket.open(); + const sentAtWrite: number[] = []; + const write = fake.term.write.bind(fake.term); + fake.term.write = (data, callback) => { + sentAtWrite.push(socket.sent.length); + write(data, callback); + }; + // A graphics command is taken out of the text the terminal sees. + socket.receive('a\x1b_Ga=d\x1b\\b'); + expect(fake.term.output).toBe('ab'); + expect(socket.sent).toHaveLength(1); + // The idle ack runs on the env's clock, 100 ms after the output stops. + fake.clock.advance(100); + expect(socket.sent.at(-1)).toBe(JSON.stringify({ type: 'ack', chars: 10 })); + // A full ack window is acknowledged at once, after it was written. + socket.receive('x'.repeat(64 * 1024)); + // Two writes for the text either side of the command, then one before the second ack. + expect(sentAtWrite).toEqual([1, 1, 2]); + expect(socket.sent.at(-1)).toBe(JSON.stringify({ type: 'ack', chars: 10 + 64 * 1024 })); + }); + + test('typed input and grid resizes go to the open socket', async () => { + await start(); + const socket = fake.sockets.last(); + socket.open(); + fake.term.fire('onData', 'ls\r'); + fake.term.resize(100, 30); + expect(socket.sent.slice(1)).toEqual([ + 'ls\r', + JSON.stringify({ type: 'resize', cols: 100, rows: 30 }), + ]); + }); + + test('a lost socket reconnects on the env clock', async () => { + await start(); + fake.sockets.last().open(); + fake.sockets.last().serverClose(1006); + expect(fake.term.output).toContain('Connection lost. Reconnecting in 2s...'); + fake.clock.advance(2000); + expect(fake.sockets.sockets).toHaveLength(2); + }); + + test('Cmd+K on a Mac reaches the clear through the key dispatcher', async () => { + await start({ platform: 'MacIntel' }); + const { KeyboardEvent } = fake.dom.window; + fake.container.dispatchEvent( + new KeyboardEvent('keydown', { code: 'KeyK', key: 'k', metaKey: true, bubbles: true }), + ); + expect(fake.term.calls).toContain('clearScreen'); + }); + + test('the scroll hooks are attached; font zoom fits at once', async () => { + await start(); + expect(fake.term.wheelHandler).toBeDefined(); + expect(fake.term.wheelPace).toBeDefined(); + expect(fake.term.beforeRender).toBeDefined(); + const { KeyboardEvent } = fake.dom.window; + fake.win.dispatch(new KeyboardEvent('keydown', { key: '=', code: 'Equal', ctrlKey: true })); + expect(fake.term.options.fontSize).toBe(15); + }); + + test('dispose removes every listener, view and hook, and closes the socket; twice is a no-op', async () => { + await start(); + const socket = fake.sockets.last(); + socket.open(); + const body = fake.dom.document.body; + expect(body.children.length).toBeGreaterThan(1); + client?.dispose(); + client?.dispose(); + expect(fake.win.active).toEqual([]); + expect(fake.docListeners.active).toEqual([]); + expect(fake.containerListeners.active).toEqual([]); + expect(fake.win.queries.flatMap((q) => q.active)).toEqual([]); + expect(fake.observers.observers.every((o) => o.disconnected)).toBe(true); + expect(socket.closedBy).toBeDefined(); + expect(fake.term.wheelHandler).toBeUndefined(); + expect(fake.term.calls.at(-1)).toBe('dispose'); + expect([...body.children].map((el) => el.id)).toEqual(['terminal']); + expect(fake.container.children).toHaveLength(0); + expect(fake.clock.pending).toEqual({ timers: 0, frames: 0 }); + }); +}); + +describe('screenClearer', () => { + function parts(clears: boolean) { + const log: string[] = []; + const clear = screenClearer( + { + clearScreen: () => { + log.push('term.clearScreen'); + return clears; + }, + }, + { clear: () => log.push('graphics.clear') }, + { onOutput: () => log.push('smoothScroll.onOutput') }, + ); + return { log, clear }; + } + + test('clears the screen, then the placements, then the sub-line offset', () => { + const { log, clear } = parts(true); + expect(clear()).toBe(true); + expect(log).toEqual(['term.clearScreen', 'graphics.clear', 'smoothScroll.onOutput']); + }); + + test('does nothing more when the terminal does not clear (the alternate screen)', () => { + const { log, clear } = parts(false); + expect(clear()).toBe(false); + expect(log).toEqual(['term.clearScreen']); + }); +}); diff --git a/src/client/app/start-client.ts b/src/client/app/start-client.ts new file mode 100644 index 0000000..b42d445 --- /dev/null +++ b/src/client/app/start-client.ts @@ -0,0 +1,196 @@ +// The client's composition root (composition root, ADR 060): builds the terminal and every +// feature on the globals in ClientEnv, in the SPEC's 13 steps (docs/specs/architecture.md, +// "The client composition root"), and returns the one disposer for all of it. The order is +// pinned by start-client.test.ts: listeners on one target and subscribers of one emitter +// run in the order they were added. + +import type { PtyConnection } from '../connection/pty-connection'; +import { ReplayWindow } from '../connection/replay-window'; +import { installClipboard } from '../features/clipboard/clipboard-feature'; +import { Composer } from '../features/compose/composer'; +import { installFind } from '../features/find/find-feature'; +import { installFontZoom } from '../features/keyboard/font-zoom-feature'; +import { installKeyDispatcher } from '../features/keyboard/key-dispatcher'; +import { shortcutFor } from '../features/keyboard/shortcut-map'; +import { installKitty } from '../features/kitty/kitty-feature'; +import { browserNotifications } from '../features/notifications/browser-notifications'; +import { installNotifications } from '../features/notifications/notifications-feature'; +import { installPaste } from '../features/paste/paste-feature'; +import { PasteService } from '../features/paste/paste-service'; +import { installScroll } from '../features/scroll/scroll-feature'; +import { SmoothScroll } from '../features/scroll/smooth-scroll'; +import { WheelStream } from '../features/scroll/wheel-stream'; +import { TabTitle } from '../features/tab-title/tab-title'; +import { installTabTitle } from '../features/tab-title/tab-title-feature'; +import { EdgeExtender } from '../layout/edge-extender'; +import { applyPaddingColor, installLayout, type LayoutFeature } from '../layout/layout-feature'; +import { installRepaintOnVisible } from '../layout/repaint-on-visible'; +import { type Disposable, DisposableStore } from '../vendor/ghostty-web'; +import { type ClientEnv, frameWindow } from './browser-env'; +import { loadClientConfig } from './client-config'; +import type { ClientContext } from './client-context'; +import { connectPty } from './connect-pty'; +import { detectMac } from './platform'; +import { toTerminalOptions } from './terminal-options'; +import { toTerminalTheme } from './terminal-theme'; + +/** What step 1 leaves for the rest: the context, the session and the objects built before the wasm. */ +interface Bootstrap { + ctx: ClientContext; + sessionId: string; + /** Whether the output being parsed is the replay sent on connect (ADR 053). */ + replay: ReplayWindow; + tabTitle: TabTitle; +} + +/** What step 2 builds that later steps use. */ +interface Surface { + smoothScroll: SmoothScroll; + wheelStream: WheelStream; + layout: LayoutFeature; +} + +/** + * Starts the client in the page `env` stands for: the terminal in `#terminal`, connected + * to the session named by the URL. Resolves once everything is installed and the socket + * is connecting; disposing the result removes everything it added. + */ +export async function startClient(env: ClientEnv): Promise<Disposable> { + const store = new DisposableStore(); + // 1. Bootstrap + const { ctx, sessionId, replay, tabTitle } = await bootstrap(env, store); + const { term } = ctx; + // 2. Layout and views + const { smoothScroll, wheelStream, layout } = installSurface(ctx, env, store); + // 3. Kitty graphics. Its replies go out through the connection made at step 7, read + // only when a reply is sent. + let connection: PtyConnection | undefined; + const graphics = store.add( + installKitty(ctx, { send: (data) => connection?.send(data), window: frameWindow(env) }), + ); + // 4. A program's OSC 52 clipboard writes, written once the user has used this tab (ADR 052) + const clipboard = store.add(installClipboard(ctx, { clipboard: env.navigator.clipboard })); + // 5. The title and the bell mark (ADR 053) + store.add(installTabTitle(ctx, { tabTitle })); + // 6. Desktop notifications (OSC 9, OSC 777) while the terminal is not focused (ADR 056) + store.add( + installNotifications(ctx, { + sessionId, + replay, + title: tabTitle, + notifications: browserNotifications(env.Notification), + userActivation: env.navigator.userActivation, + now: () => env.performance.now(), + }), + ); + // 7. The PTY socket (ADR 048) + const pty = store.add( + connectPty(sessionId, term, env, { + replay, + graphics, + smoothScroll, + connected: () => clipboard.connected(), + }), + ); + connection = pty; + // 8. Wheel scrolling: the custom wheel handler, the app's wheel pace, the glide's frame + store.add(installScroll(ctx, { smoothScroll, wheelStream })); + const clearScreen = screenClearer(term, graphics, smoothScroll); + // 9. Find in the scrollback (ADR 055), before the dispatcher that drives it + const { mac } = ctx; + const find = store.add( + installFind(ctx, { ...env, shortcutActionOf: (e) => shortcutFor(e, mac)?.action }), + ); + // Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, + // ADR 049). Built here as the dispatcher's paste keys use it; its listeners come at 13. + const paste = new PasteService(term, pty, env.navigator); + // 10. The one container capture keydown listener: configured bindings (ADR 018), then + // Ghostty's shortcuts for this platform (ADR 054, ADR 055, ADR 057) + store.add( + installKeyDispatcher(ctx, { + connection: pty, + find, + clearScreen, + pasteFromClipboard: () => paste.pasteFromClipboard(), + }), + ); + // 11. Font zoom: the last window capture keydown listener, fitting at once (ADR 033) + store.add(installFontZoom(ctx, { fit: () => layout.fit() })); + // 12. Keystrokes and input to the PTY, and the grid size whenever it changes + store.add(term.onData((data: string) => pty.send(data))); + store.add(term.onResize(({ cols, rows }) => pty.sendResize(cols, rows))); + // 13. The right click set to paste, then the container capture paste listener (ADR 051) + store.add(installPaste(ctx, { paste })); + return store; +} + +/** + * Cmd+K (ADR 054): clears the screen, then the placements on it and any sub-line scroll + * offset. Nothing on the alternate screen; the clear is this tab's own, and a reload + * replays the session's output again. + */ +export function screenClearer( + term: { clearScreen(): boolean }, + graphics: { clear(): void }, + smoothScroll: { onOutput(): void }, +): () => boolean { + return () => { + if (!term.clearScreen()) return false; + graphics.clear(); + smoothScroll.onOutput(); + return true; + }; +} + +/** Step 1: the session, the config, the tab title, the engine and the opened terminal. */ +async function bootstrap(env: ClientEnv, store: DisposableStore): Promise<Bootstrap> { + const doc = env.document; + const sessionId = env.window.location.pathname.split('/s/')[1] ?? 'main'; + const config = await loadClientConfig(env.fetch); + const replay = new ReplayWindow(); + // `<title> | webtty` from the program's OSC 0/2 title, or the session id, set before the + // wasm loads so the tab is named at once (ADR 053) + const tabTitle = new TabTitle(sessionId, config.bellTitle, replay, (title) => { + doc.title = title; + }); + // The vendored ghostty-web is bundled into /dist/client-browser.js, so its default wasm + // lookup (relative to its own module URL) would depend on where the bundle sits. Load + // the wasm from the path the server serves it at instead (ADR 041). It is Ghostty's + // official libghostty-vt wasm, the only engine (ADR 042, ADR 047). + const ghostty = await env.loadEngine('/ghostty-vt-small.wasm'); + // webtty's theme keys in ghostty-web's names, without `padding`, which is a webtty + // setting and not a terminal colour (ADR 036) + const terminalTheme = toTerminalTheme(config.theme ?? {}); + const term = store.add(env.createTerminal(toTerminalOptions(config, terminalTheme, ghostty))); + const container = doc.getElementById('terminal') as HTMLElement; + applyPaddingColor(container, config); + await term.open(container); + const mac = detectMac(env.navigator); + const ctx: ClientContext = { term, container, config, terminalTheme, mac, doc, win: env.window }; + return { ctx, sessionId, replay, tabTitle }; +} + +/** Step 2: the edge strips, the composer, smooth scroll, the fit and its signals, the repaint. */ +function installSurface(ctx: ClientContext, env: ClientEnv, store: DisposableStore): Surface { + const { term, container, config, terminalTheme, doc } = ctx; + const frames = frameWindow(env); + // The strips copy the edges of the part of the canvas in view (ADR 043) + const edges = store.add( + new EdgeExtender(container, config.padding, () => term.renderer?.visibleArea, { + document: doc, + window: frames, + }), + ); + // IME and dictation composition shows at the cursor in the theme's colours (ADR 038) + store.add(new Composer(term, container, terminalTheme, { document: doc, window: frames })); + // Pixel-smooth scrollback scrolling for trackpads (ADR 040, ADR 043) + const wheelStream = new WheelStream(env.window); + const smoothScroll = store.add( + new SmoothScroll(term, wheelStream, { window: env.window, performance: env.performance }), + ); + // The fit, its first run, and the resize, font and pixel ratio signals that refit (ADR 033) + const layout = store.add(installLayout(ctx, { ...env, edges, smoothScroll })); + // The first of the document's visibilitychange listeners (ADR 031) + store.add(installRepaintOnVisible(ctx)); + return { smoothScroll, wheelStream, layout }; +} diff --git a/src/client/connection/output-ack.ts b/src/client/connection/output-ack.ts index 6754117..4d81ea0 100644 --- a/src/client/connection/output-ack.ts +++ b/src/client/connection/output-ack.ts @@ -3,12 +3,13 @@ export const ACK_EVERY = 64 * 1024; /** How long after the last output a smaller count is acknowledged, in ms. */ export const ACK_IDLE_MS = 100; -interface Timers { +/** The idle timer's clock, the page's timers by default; the client root passes its env's. */ +export interface AckTimers { set(fn: () => void, ms: number): unknown; clear(handle: unknown): void; } -const windowTimers: Timers = { +const windowTimers: AckTimers = { set: (fn, ms) => setTimeout(fn, ms), clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>), }; @@ -27,7 +28,7 @@ export class OutputAck { constructor( private send: (message: string) => void, - private timers: Timers = windowTimers, + private timers: AckTimers = windowTimers, ) {} /** Records that `chars` more characters of output have been parsed. */ diff --git a/src/client/index.ts b/src/client/index.ts index 2ec4a91..828e80e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,199 +1,7 @@ -import { browserEnv } from './app/browser-env'; -import { loadClientConfig } from './app/client-config'; -import type { ClientContext } from './app/client-context'; -import { detectMac } from './app/platform'; -import { toTerminalOptions } from './app/terminal-options'; -import { toTerminalTheme } from './app/terminal-theme'; -import { createOutputHandler } from './connection/output-pipeline'; -import { PtyConnection } from './connection/pty-connection'; -import { ReplayWindow } from './connection/replay-window'; -import { installClipboard } from './features/clipboard/clipboard-feature'; -import { Composer } from './features/compose/composer'; -import { installFind } from './features/find/find-feature'; -import { installFontZoom } from './features/keyboard/font-zoom-feature'; -import { installKeyDispatcher } from './features/keyboard/key-dispatcher'; -import { shortcutFor } from './features/keyboard/shortcut-map'; -import { KittyGraphics } from './features/kitty/graphics/kitty-graphics'; -import { browserNotifications } from './features/notifications/browser-notifications'; -import { installNotifications } from './features/notifications/notifications-feature'; -import { installPaste } from './features/paste/paste-feature'; -import { PasteService } from './features/paste/paste-service'; -import { SmoothScroll } from './features/scroll/smooth-scroll'; -import { WheelStream } from './features/scroll/wheel-stream'; -import { TabTitle } from './features/tab-title/tab-title'; -import { installTabTitle } from './features/tab-title/tab-title-feature'; -import { EdgeExtender } from './layout/edge-extender'; -import { applyPaddingColor, installLayout } from './layout/layout-feature'; -import { installRepaintOnVisible } from './layout/repaint-on-visible'; - -const sessionId = window.location.pathname.split('/s/')[1] ?? 'main'; - -const env = browserEnv(); - -const config = await loadClientConfig(env.fetch); - -// Whether the output being parsed is the replay sent on connect (ADR 053) -const replay = new ReplayWindow(); - -// `<title> | webtty` from the program's OSC 0/2 title, or the session id, with a 🔔 -// after a bell until the terminal is attended; replayed bells are ignored (ADR 053). -const tabTitle = new TabTitle(sessionId, config.bellTitle, replay, (title) => { - document.title = title; -}); - -// The vendored ghostty-web is bundled into /dist/client-browser.js, so its default -// wasm lookup (relative to its own module URL) would depend on where the bundle sits. -// Load the wasm from the path the server serves it at instead (ADR 041). It is -// Ghostty's official libghostty-vt wasm, the only engine (ADR 042, ADR 047). -const ghostty = await env.loadEngine('/ghostty-vt-small.wasm'); - -// Translate webtty's theme keys to ghostty-web's names and keep `padding`, a webtty -// setting and not a terminal colour, out of the theme it receives (ADR 036). -const terminalTheme = toTerminalTheme(config.theme ?? {}); - -const term = env.createTerminal(toTerminalOptions(config, terminalTheme, ghostty)); - -const container = document.getElementById('terminal') as HTMLElement; -applyPaddingColor(container, config); -await term.open(container); - -const ctx: ClientContext = { - term, - container, - config, - terminalTheme, - mac: detectMac(env.navigator), - doc: env.document, - win: env.window, -}; - -// The strips copy the edges of the part of the canvas in view (ADR 043). -const edges = new EdgeExtender(container, config.padding, () => term.renderer?.visibleArea); -// IME / dictation composition shows at the cursor in the theme's colours, not as a -// stray line under the canvas. See ADR 038. -new Composer(term, container, terminalTheme); -// Pixel-smooth scrollback scrolling for trackpads. See ADR 040 and ADR 043. -const wheelStream = new WheelStream(); -const smoothScroll = new SmoothScroll(term, wheelStream); - -// The fit, its first run, and the resize, font and pixel ratio signals that refit (ADR 033) -const layout = installLayout(ctx, { ...env, edges, smoothScroll }); -// The document listener is the first of the SPEC's visibilitychange listeners; the -// layout's own are on other targets, so their relative order does not matter. -installRepaintOnVisible(ctx); - -// Created with the connection below; read only when a reply is sent. -let connection: PtyConnection; +// The browser entry, bundled to dist/client-browser.js (entry, ADR 060): starts the client +// on the page's own globals. Everything it builds, and the order, is in app/start-client.ts. -// Kitty graphics: the wasm32 libghostty-vt build leaves kitty graphics out and -// discards the protocol's APC sequences, so PTY output goes through this instead of -// term.write(). See ADR 032 and ADR 041. -const graphics = new KittyGraphics(term, container, (data: string) => connection.send(data)); - -// A program's OSC 52 clipboard write, decoded by Ghostty, written to the browser -// clipboard once the user has used this tab since connecting (ADR 052). -const clipboard = installClipboard(ctx, { clipboard: env.navigator.clipboard }); - -// The title and the bell mark (ADR 053). -installTabTitle(ctx, { tabTitle }); - -// Desktop notifications (OSC 9, OSC 777), shown while the terminal is not focused, as -// Ghostty on macOS does; the bell mark without permission (ADR 056). -installNotifications(ctx, { - sessionId, - replay, - title: tabTitle, - notifications: browserNotifications(env.Notification), - userActivation: env.navigator.userActivation, - now: () => env.performance.now(), -}); - -// The PTY socket (ADR 048, ADR 060). PtyConnection owns the URL, the reconnects and the -// close codes; this says what runs around them. -connection = new PtyConnection( - sessionId, - { - size: () => ({ cols: term.cols, rows: term.rows }), - created: () => { - // The output starts with the session's replay: hold OSC 52 writes until input (ADR 052) - clipboard.connected(); - // ... and ignore its bells and notifications until the replay's last piece (ADR 053, - // ADR 056) - replay.connected(); - }, - opening: () => graphics.reset(), - // Focus changes while the socket was down were dropped: send the state the app's - // mode 1004 now misses (ADR 053). After a reset the mode is off until the replay. - opened: () => term.resyncFocus(), - output: createOutputHandler({ - replay, - graphics, - smoothScroll, - now: () => env.performance.now(), - }), - write: (data) => term.write(data), - reset: () => term.reset(), - closeWindow: () => env.window.close(), - }, - { ...env, location: env.window.location }, -); -connection.connect(); - -// Wheel scrolling (ADR 017, ADR 040, ADR 045). WheelStream tells a mouse wheel, a -// trackpad and a TrackPoint apart and picks how each event moves: -// - on the normal screen, unless the app tracks the mouse, SmoothScroll glides the -// scrollback pixel by pixel, advanced just before each render; -// - otherwise the terminal sends the app mouse wheel reports in the app's mouse format -// (ADR 049), or arrow keys on the alternate screen, paced the same way. -term.attachCustomWheelEventHandler( - (e: WheelEvent): boolean => !term.hasMouseTracking() && smoothScroll.handleWheel(e), -); -term.attachWheelPace((e: WheelEvent) => wheelStream.forApp(e)); -term.attachBeforeRender((now: number) => smoothScroll.frame(now)); - -// Cmd+K: nothing on the alternate screen; the clear is this tab's own, and a reload -// replays the session's output again (ADR 054) -function clearScreen(): boolean { - if (!term.clearScreen()) return false; - graphics.clear(); - smoothScroll.onOutput(); - return true; -} - -// Find in the scrollback (ADR 055). Built before the key dispatcher, which drives it; the -// bar is the only append to the body and the only `onFindChange` subscriber, so building -// it earlier than the old shortcut listener changes no order (SPEC step 9). -const { mac } = ctx; -const find = installFind(ctx, { - ...env, - shortcutActionOf: (e) => shortcutFor(e, mac)?.action, -}); - -// Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, ADR 049). -// Built here as the dispatcher's paste keys use it; its own listeners are added last. -const paste = new PasteService(term, connection, env.navigator); - -// The one container capture keydown listener, where the binding listener was: configured -// bindings (ADR 018), then Ghostty's copy, paste, clear, find and jump-to-prompt keys for -// this platform (ADR 054, ADR 055, ADR 057). It runs before the input handler, which -// listens on the container too. -installKeyDispatcher(ctx, { - connection, - find, - clearScreen, - pasteFromClipboard: () => paste.pasteFromClipboard(), -}); - -// Font zoom: the last window capture keydown listener, fitting at once (ADR 033). -installFontZoom(ctx, { fit: () => layout.fit() }); - -// Forward terminal keystrokes and input to the PTY over WebSocket. -term.onData((data: string) => connection.send(data)); - -// Notify the server when the terminal is resized so the PTY dimensions stay in sync. -term.onResize(({ cols, rows }: { cols: number; rows: number }) => - connection.sendResize(cols, rows), -); +import { browserEnv } from './app/browser-env'; +import { startClient } from './app/start-client'; -// The right click set to paste, then the container capture paste listener (ADR 051). -installPaste(ctx, { paste }); +await startClient(browserEnv()); From daf891911dace4c762d9e9818725d7990a18668d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:49:36 -0400 Subject: [PATCH 214/295] docs(arch): the client source layout by folder, connect-pty.ts and fakeClientEnv in the tree, the composition root done --- docs/specs/architecture.md | 5 +++-- docs/specs/client.md | 13 ++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 804bcca..e7c89a1 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -117,6 +117,7 @@ src/ client.html index.css (paths fixed: copied by scripts/build.ts) app/ start-client.ts composition root: bootstrap, install features in the documented order, connect + connect-pty.ts wiring: step 7, the PtyConnection's host (created, open, output, close) and its per-socket ack browser-env.ts adapter: ClientEnv, everything start-client touches outside its own objects (see below) client-context.ts types: ClientContext, Feature, Disposable client-config.ts service + type: ClientConfig (from src/config types), loadClientConfig(fetch) @@ -155,7 +156,7 @@ src/ test-support/graphics-harness.ts kitty-feature.ts test-support/ fake-terminal.ts, fake-socket.ts, fake-window.ts, fake-resize-observer.ts, fake-context.ts, - manual-clock.ts, recording-target.ts, dom-window.ts + fake-client-env.ts, manual-clock.ts, recording-target.ts, dom-window.ts test-support.test.ts the fakes' self-tests (a test-support/ folder holds no *.test.ts) vendor/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md @@ -322,7 +323,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a |---|---|---|---| | Architecture SPEC and conventions | Layers, folders, module kinds, naming, size limits, comments | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | -| Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ⬜ | +| Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | | Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | diff --git a/docs/specs/client.md b/docs/specs/client.md index 65e2b6f..a7b49c3 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -35,11 +35,18 @@ The client's folders, module kinds, dependency rules, size limits and test conve ``` src/client/ - index.ts ← browser entry, bundled to dist/client-browser.js + index.ts ← browser entry, bundled to dist/client-browser.js: await startClient(browserEnv()) client.html ← HTML shell (no inline script), copied to dist/client.html index.css ← terminal and find-bar styles, copied to dist/client.css - *.ts ← app modules: the connection, layout and features (moving into app/, connection/, - layout/ and features/ as ADR 060 lands) + app/ ← start-client.ts, the composition root (the SPEC's 13 steps, one disposer); + connect-pty.ts (its step 7); ClientEnv (browser-env.ts), ClientContext, the config + loader, terminal options and theme, platform checks + connection/ ← PtyConnection, close codes and policy, status line, the output pipeline, OutputAck, + ReplayWindow + layout/ ← the Fitter and layout feature, edge strips, repaint on visible, canvas selectors + features/ ← one folder per feature, each with its install<Name>(ctx, deps) in <name>-feature.ts: + tab-title, notifications, clipboard, paste, find, keyboard, compose, scroll, kitty + test-support/ ← fakes for the tests: terminal, socket, window, ResizeObserver, clock, ClientEnv, context vendor/ ghostty-web/ ← derived from coder/ghostty-web lib/ at 1858a59 and heavily modified by webtty, MIT LICENSE, PROVENANCE.md (upstream pin, local changes, moves and splits); From b046baacd36a636bc696c55cbc071e3577bf2626 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:51:25 -0400 Subject: [PATCH 215/295] test(client): the client root's kitty replies, find keys, replay-aware bell mark and Ctrl+V paste --- src/client/app/start-client.test.ts | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/client/app/start-client.test.ts b/src/client/app/start-client.test.ts index 6bab227..f628719 100644 --- a/src/client/app/start-client.test.ts +++ b/src/client/app/start-client.test.ts @@ -180,6 +180,51 @@ describe('startClient', () => { expect(fake.term.calls).toContain('clearScreen'); }); + test('kitty replies go out on the socket; the find field knows the find keys', async () => { + await start({ platform: 'MacIntel' }); + const socket = fake.sockets.last(); + socket.open(); + socket.receive('\x1b_Ga=q,i=31,s=1,v=1,f=24;AAAA\x1b\\'); + expect(socket.sent.at(-1)).toBe('\x1b_Gi=31;OK\x1b\\'); + const { KeyboardEvent } = fake.dom.window; + fake.container.dispatchEvent( + new KeyboardEvent('keydown', { code: 'KeyF', key: 'f', metaKey: true, bubbles: true }), + ); + const input = fake.dom.document.getElementById('find-bar-input') as HTMLInputElement; + input.dispatchEvent( + new KeyboardEvent('keydown', { code: 'Escape', key: 'Escape', bubbles: true }), + ); + expect(fake.term.calls).toContain('endFind'); + }); + + test('bells and notifications in the replay are ignored, later ones mark the title', async () => { + await start(); + const socket = fake.sockets.last(); + socket.open(); + socket.receive('replayed'); + fake.term.fire('onBell'); + expect(fake.dom.document.title).toBe('work | webtty'); + // The replay ended with the short message before this one. + socket.receive('live'); + // Without the Notification API, a notification is the bell mark (ADR 056). + fake.term.fire('onDesktopNotification', { title: 'done', body: '' }); + expect(fake.dom.document.title).toBe('🔔 work | webtty'); + }); + + test('Ctrl+V pastes what the clipboard reads', async () => { + await start(); + Object.defineProperty(fake.env.navigator, 'clipboard', { + value: { readText: async () => 'pasted' }, + }); + fake.sockets.last().open(); + const { KeyboardEvent } = fake.dom.window; + fake.container.dispatchEvent( + new KeyboardEvent('keydown', { code: 'KeyV', key: 'v', ctrlKey: true, bubbles: true }), + ); + await Promise.resolve(); + expect(fake.term.calls).toContain('paste:pasted'); + }); + test('the scroll hooks are attached; font zoom fits at once', async () => { await start(); expect(fake.term.wheelHandler).toBeDefined(); From 178b2814dd4af3c4d8d6975080909bb5be025ac0 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:52:28 -0400 Subject: [PATCH 216/295] refactor(server): a testable package-version walk with a header getVersion's walk up to package.json becomes findPackageVersion(startDir), so the skip of unreadable and unrelated package.json files and the 'unknown' fallback are tested on a temporary tree. getVersion keeps its behaviour: it caches a found version and never caches 'unknown'. --- src/version.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++- src/version.ts | 38 ++++++++++++++++++++++++------------- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/src/version.test.ts b/src/version.test.ts index 33d2979..2cd7ac8 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -1,3 +1,5 @@ +// Tests for the package version: the walk up to package.json on a temporary tree, and the +// bundled CLI and server entries reporting the version of the package they ship in (ADR 062). import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; @@ -8,7 +10,7 @@ import { makeTmpHome, waitForServer, } from './test-support/server-harness'; -import { getVersion } from './version'; +import { findPackageVersion, getVersion } from './version'; const REPO_ROOT = path.resolve(import.meta.dir, '..'); const FAKE_VERSION = '9.9.9'; @@ -19,6 +21,48 @@ describe('getVersion', () => { const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); expect(getVersion()).toBe(pkg.version); }); + + test('a second call returns the cached version', () => { + expect(getVersion()).toBe(getVersion()); + }); +}); + +describe('findPackageVersion', () => { + let root: string; + let leaf: string; + + beforeAll(() => { + root = makeTmpHome('version-walk'); + leaf = path.join(root, 'pkg', 'nested', 'deeper'); + fs.mkdirSync(leaf, { recursive: true }); + // Walking up from `leaf`: a broken file, then another package, then webtty. + fs.writeFileSync(path.join(root, 'pkg', 'nested', 'deeper', 'package.json'), '{not json'); + fs.writeFileSync( + path.join(root, 'pkg', 'nested', 'package.json'), + JSON.stringify({ name: 'other', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(root, 'pkg', 'package.json'), + JSON.stringify({ name: 'webtty', version: '2.3.4' }), + ); + }); + + afterAll(() => cleanupTmpHome(root)); + + test('skips unreadable and unrelated package.json files on the way up', () => { + expect(findPackageVersion(leaf)).toBe('2.3.4'); + }); + + test('a webtty package.json without a version does not count', () => { + const bare = path.join(root, 'bare'); + fs.mkdirSync(bare); + fs.writeFileSync(path.join(bare, 'package.json'), JSON.stringify({ name: 'webtty' })); + expect(findPackageVersion(bare)).toBe(findPackageVersion(root)); + }); + + test("is 'unknown' when the walk reaches the root without a webtty package", () => { + expect(findPackageVersion(root)).toBe('unknown'); + }); }); // Bundles the CLI and server the way scripts/build.ts does, into a throwaway diff --git a/src/version.ts b/src/version.ts index 88867f0..477f093 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,5 @@ +// The webtty package version, shared by `webtty -v` and `GET /api/server/status`. A util that +// walks up from this file to the package's package.json, read at runtime and cached (ADR 062). import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -5,26 +7,20 @@ import { fileURLToPath } from 'node:url'; let cached: string | undefined; /** - * Returns the webtty version from the nearest `package.json` above this file. - * - * Read at runtime (not inlined at build time) because semantic-release bumps - * `package.json` after `bun run build`. Walking up keeps it working from both - * `src/` and the bundled `dist/cli` / `dist/server` entries. - * Dev checkouts report `0.0.0-development`. + * Returns the version in the nearest `package.json` named `webtty` at or above `startDir`, or + * `'unknown'` when the walk reaches the filesystem root without one. Unreadable or unrelated + * `package.json` files are skipped. Kept apart from getVersion so the walk is tested on a + * temporary tree. */ -export function getVersion(): string { - if (cached) return cached; - let dir = path.dirname(fileURLToPath(import.meta.url)); +export function findPackageVersion(startDir: string): string { + let dir = startDir; while (true) { try { const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { name?: string; version?: string; }; - if (pkg.name === 'webtty' && pkg.version) { - cached = pkg.version; - return cached; - } + if (pkg.name === 'webtty' && pkg.version) return pkg.version; } catch { // no readable package.json at this level — keep walking up } @@ -33,3 +29,19 @@ export function getVersion(): string { dir = parent; } } + +/** + * Returns the webtty version from the nearest `package.json` above this file. + * + * Read at runtime (not inlined at build time) because semantic-release bumps + * `package.json` after `bun run build`. Walking up keeps it working from both + * `src/` and the bundled `dist/cli` / `dist/server` entries. + * Dev checkouts report `0.0.0-development`. + */ +export function getVersion(): string { + if (cached) return cached; + const version = findPackageVersion(path.dirname(fileURLToPath(import.meta.url))); + // As before, 'unknown' is not cached, so a later call walks again. + if (version !== 'unknown') cached = version; + return version; +} From a1a2a2ffa544e10beacd31b67d49052ca98e3bb0 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:52:55 -0400 Subject: [PATCH 217/295] refactor(cli): createCliDeps hands startServer itself to server.start server.start was a closure that called startServer() with no arguments, which only a real spawn could cover. Passing startServer is the same call (ServerControl.start takes no arguments, so its defaults apply) and the test pins the wiring with toBe. --- src/cli/cli-deps.ts | 3 ++- src/cli/main.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli/cli-deps.ts b/src/cli/cli-deps.ts index 8e40062..18c0304 100644 --- a/src/cli/cli-deps.ts +++ b/src/cli/cli-deps.ts @@ -64,7 +64,8 @@ export function createCliDeps(client: WebttyClient = configuredClient()): CliDep }, exit: (code) => process.exit(code), version: getVersion, - server: { start: () => startServer(), stop: () => stopServer(client) }, + // start is called with no arguments, so startServer runs with its defaults. + server: { start: startServer, stop: () => stopServer(client) }, openBrowser: (url) => openBrowser(url), editor: processEditorHost(), tty: processKeyTty(), diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts index a2e0cb5..31b77fd 100644 --- a/src/cli/main.test.ts +++ b/src/cli/main.test.ts @@ -1,3 +1,5 @@ +// Tests for the CLI's composition root: dispatch of every command and alias, usage errors, and +// the production deps createCliDeps wires to the real process (ADR 063). import { describe, expect, spyOn, test } from 'bun:test'; import path from 'node:path'; import { WebttyClient } from './api-client'; @@ -130,6 +132,11 @@ describe('CLI_COMMANDS and createCliDeps', () => { expect(deps.version).toBe(getVersion); expect(deps.editor.env).toBe(process.env); }); + + test('server.start is startServer, so it runs with its defaults', async () => { + const { startServer } = await import('./server-process'); + expect(createCliDeps().server.start).toBe(startServer); + }); }); describe('createCliDeps streams', () => { From 4fd50fe6f94af28ea630b77455a3c52cefe2d799 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:53:27 -0400 Subject: [PATCH 218/295] test(server): coverage pass and cleanup for the server track Every server-track file now starts with a header that says what it is, its kind and its ADRs: the 39 test files that began with imports get one, and output-pacer.ts's JSDoc block becomes the plain // header the other files use. DEFAULT_FLOW_LIMITS and FakeSocket's CLOSED get the JSDoc line every export carries. New tests close the remaining gaps without touching a real server or the configured port: - configuredClient reads the config file's port on every request (tmp HOME, stubbed global fetch); - defaultServerProcessDeps().isRunning probes the configured port the same way; - src/cli/test-support.test.ts checks that fakeCliDeps' default editor and tty fail a test that reaches them. check-architecture reports no violation. Server-track source files are all <= 300 lines (largest: output-pacer.ts 278), tests <= 800 (largest: websocket.test.ts 483). The entries src/server/index.ts and src/cli/index.ts run only as subprocesses (entry.test.ts, cli/index.test.ts, version.test.ts), so they are not in bun's in-process report; pty-process.ts holds only types. Coverage, bun test --coverage --coverage-reporter=lcov (% lines, % funcs): src/cli/api-client.ts 100 100 src/cli/browser-opener.ts 100 100 src/cli/cli-args.ts 100 100 src/cli/cli-deps.ts 100 100 src/cli/commands/edit-config.ts 100 100 src/cli/commands/go.ts 100 100 src/cli/commands/key-format.ts 100 100 src/cli/commands/key.ts 100 100 src/cli/commands/list.ts 100 100 src/cli/commands/remove.ts 100 100 src/cli/commands/rename.ts 100 100 src/cli/commands/run.ts 100 100 src/cli/commands/start-stop.ts 100 100 src/cli/help-text.ts 100 100 src/cli/main.ts 100 100 src/cli/server-process.ts 100 100 src/cli/test-support/fake-cli-deps.ts 100 100 src/cli/test-support/scripted-fetch.ts 100 100 src/config/client-view.ts 100 100 src/config/config-paths.ts 100 100 src/config/config-schema.ts 100 100 src/config/defaults.ts 100 100 src/config/index.ts 100 100 src/config/keyboard-bindings.ts 100 100 src/config/parse-config.ts 100 100 src/ndjson.ts 100 100 src/pty/bun-backend.ts 100 100 src/pty/index.ts 100 100 src/pty/node-backend.ts 100 100 src/runtime.ts 100 100 src/server/app.ts 100 100 src/server/flow.ts 100 100 src/server/http/request-body.ts 100 100 src/server/http/respond.ts 100 100 src/server/http/router.ts 100 100 src/server/http/static-files.ts 100 100 src/server/output-pacer.ts 100 100 src/server/routes/asset-routes.ts 100 100 src/server/routes/config-routes.ts 100 100 src/server/routes/execute-route.ts 100 100 src/server/routes/page-routes.ts 100 100 src/server/routes/publish-route.ts 100 100 src/server/routes/route-table.ts 100 100 src/server/routes/run-command.ts 100 100 src/server/routes/running-session.ts 100 100 src/server/routes/server-routes.ts 100 100 src/server/routes/session-routes.ts 100 100 src/server/server-paths.ts 100 100 src/server/sessions/session-banner.ts 100 100 src/server/sessions/session-id.ts 100 100 src/server/sessions/session-store.ts 100 100 src/server/sessions/session.ts 100 100 src/server/ws/client-message.ts 100 100 src/server/ws/close-codes.ts 100 100 src/server/ws/events-socket.ts 100 100 src/server/ws/pty-socket.ts 100 100 src/server/ws/ws-upgrade.ts 100 100 src/test-support/fake-pty.ts 100 100 src/test-support/fake-socket.ts 100 100 src/test-support/live-server.ts 100 100 src/test-support/response-recorder.ts 100 100 src/test-support/route-harness.ts 100 92.59 src/test-support/server-harness.ts 98.25 100 src/version.ts 100 100 --- src/cli/api-client.test.ts | 42 ++++++++++++++++++++++ src/cli/browser-opener.test.ts | 2 ++ src/cli/cli-args.test.ts | 2 ++ src/cli/commands/edit-config.test.ts | 2 ++ src/cli/commands/go.test.ts | 2 ++ src/cli/commands/key-format.test.ts | 2 ++ src/cli/commands/key.test.ts | 2 ++ src/cli/commands/list.test.ts | 2 ++ src/cli/commands/remove.test.ts | 2 ++ src/cli/commands/rename.test.ts | 2 ++ src/cli/commands/run.test.ts | 2 ++ src/cli/commands/start-stop.test.ts | 2 ++ src/cli/help-text.test.ts | 1 + src/cli/server-process.test.ts | 32 +++++++++++++++++ src/cli/test-support.test.ts | 30 ++++++++++++++++ src/config/client-view.test.ts | 2 ++ src/config/config-paths.test.ts | 2 ++ src/config/config-schema.test.ts | 2 ++ src/config/defaults.test.ts | 2 ++ src/config/index.test.ts | 2 ++ src/config/keyboard-bindings.test.ts | 2 ++ src/config/parse-config.test.ts | 2 ++ src/ndjson.test.ts | 2 ++ src/pty/index.test.ts | 2 ++ src/pty/node-backend.test.ts | 2 ++ src/runtime.test.ts | 2 ++ src/server/app.test.ts | 2 ++ src/server/http/request-body.test.ts | 2 ++ src/server/http/respond.test.ts | 2 ++ src/server/http/static-files.test.ts | 2 ++ src/server/output-pacer.test.ts | 2 ++ src/server/output-pacer.ts | 9 +++-- src/server/server-paths.test.ts | 2 ++ src/server/sessions/session-banner.test.ts | 1 + src/server/sessions/session-id.test.ts | 1 + src/server/sessions/session-store.test.ts | 2 ++ src/server/sessions/session.test.ts | 2 ++ src/server/ws/client-message.test.ts | 2 ++ src/server/ws/close-codes.test.ts | 1 + src/server/ws/events-socket.test.ts | 2 ++ src/server/ws/pty-socket.test.ts | 2 ++ src/server/ws/ws-upgrade.test.ts | 2 ++ src/test-support/fake-socket.ts | 3 +- 43 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 src/cli/test-support.test.ts diff --git a/src/cli/api-client.test.ts b/src/cli/api-client.test.ts index addf3d5..7022367 100644 --- a/src/cli/api-client.test.ts +++ b/src/cli/api-client.test.ts @@ -1,4 +1,9 @@ +// Tests for WebttyClient over a scripted fetch and fake sockets: every request it makes, how it +// reads replies and the NDJSON execute stream, and the configured client's base URL (ADR 063). import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cleanupTmpHome, makeTmpHome } from '../test-support/server-harness'; import { configuredClient, localBaseUrl, ServerUnreachableError, WebttyClient } from './api-client'; import { FAKE_BASE_URL, @@ -38,6 +43,43 @@ describe('localBaseUrl and configuredClient', () => { expect(configuredClient()).toBeInstanceOf(WebttyClient); }); + test("configuredClient's base URL is the config file's port, read per request", async () => { + const home = makeTmpHome('api-client'); + const savedHome = process.env.HOME; + const realFetch = globalThis.fetch; + const seen: string[] = []; + const writePort = (port: number) => { + fs.mkdirSync(path.join(home, '.config', 'webtty'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.config', 'webtty', 'config.json'), + JSON.stringify({ port }), + ); + }; + globalThis.fetch = Object.assign( + async (url: string | URL | Request) => { + seen.push(String(url)); + return json([]); + }, + { preconnect: realFetch.preconnect }, + ); + process.env.HOME = home; + try { + const client = configuredClient(); + writePort(41001); + await client.isRunning(); + writePort(41002); + await client.isRunning(); + } finally { + globalThis.fetch = realFetch; + process.env.HOME = savedHome; + cleanupTmpHome(home); + } + expect(seen).toEqual([ + 'http://127.0.0.1:41001/api/sessions', + 'http://127.0.0.1:41002/api/sessions', + ]); + }); + test('a base URL function is read on every request', async () => { const urls: string[] = []; let port = 1; diff --git a/src/cli/browser-opener.test.ts b/src/cli/browser-opener.test.ts index 6725f8c..5c4f94e 100644 --- a/src/cli/browser-opener.test.ts +++ b/src/cli/browser-opener.test.ts @@ -1,3 +1,5 @@ +// Tests for the browser opener: the bind host a browser can reach, and the per-platform open +// command over a fake spawn, and the WEBTTY_NO_OPEN and NODE_ENV=test opt-outs (ADR 063). import { describe, expect, mock, test } from 'bun:test'; import { type BrowserOpenerHost, diff --git a/src/cli/cli-args.test.ts b/src/cli/cli-args.test.ts index 6be1efa..f69237f 100644 --- a/src/cli/cli-args.test.ts +++ b/src/cli/cli-args.test.ts @@ -1,3 +1,5 @@ +// Table tests for the CLI argv parser: command, arguments and the `--dir` base directory, and +// the aliases that mean `go` (ADR 063). import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { GO_ALIASES, parseArgs } from './cli-args'; diff --git a/src/cli/commands/edit-config.test.ts b/src/cli/commands/edit-config.test.ts index e930cff..ca8dd63 100644 --- a/src/cli/commands/edit-config.test.ts +++ b/src/cli/commands/edit-config.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty config` over a fake EditorHost: the `{}` written when the file is missing, +// the editor from $VISUAL, $EDITOR or the platform, and the live host's filesystem calls (ADR 063). import { describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; diff --git a/src/cli/commands/go.test.ts b/src/cli/commands/go.test.ts index 522a2f5..ad55889 100644 --- a/src/cli/commands/go.test.ts +++ b/src/cli/commands/go.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty go` over fake CLI deps: starting the server when needed, ensuring the +// session, the URL it opens and the creation errors (ADR 063). import { describe, expect, test } from 'bun:test'; import { fakeCliDeps, RUNNING, STOPPED } from '../test-support/fake-cli-deps'; import { json } from '../test-support/scripted-fetch'; diff --git a/src/cli/commands/key-format.test.ts b/src/cli/commands/key-format.test.ts index 6a39e9d..1f7c089 100644 --- a/src/cli/commands/key-format.test.ts +++ b/src/cli/commands/key-format.test.ts @@ -1,3 +1,5 @@ +// Table tests for how `webtty key` shows bytes: the readable form and the `chars` JSON string +// (ADR 063). import { describe, expect, test } from 'bun:test'; import { bytesToChars, bytesToDisplay } from './key-format'; diff --git a/src/cli/commands/key.test.ts b/src/cli/commands/key.test.ts index 672092c..5ad52dd 100644 --- a/src/cli/commands/key.test.ts +++ b/src/cli/commands/key.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty key` over a fake KeyTty: raw mode, combos grouped by time, the output lines +// and the restore on `q` and process end, plus the live TTY's wiring (ADR 063). import { describe, expect, spyOn, test } from 'bun:test'; import { fakeCliDeps } from '../test-support/fake-cli-deps'; import { type KeyTty, key, type ProcessEndEvent, processKeyTty } from './key'; diff --git a/src/cli/commands/list.test.ts b/src/cli/commands/list.test.ts index 52f9de5..9ec78ee 100644 --- a/src/cli/commands/list.test.ts +++ b/src/cli/commands/list.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty ls` over fake CLI deps: the table, the filter, the empty and +// not-running messages, and a reply that is not JSON (ADR 063). import { describe, expect, test } from 'bun:test'; import { fakeCliDeps, STOPPED } from '../test-support/fake-cli-deps'; import { json } from '../test-support/scripted-fetch'; diff --git a/src/cli/commands/remove.test.ts b/src/cli/commands/remove.test.ts index 496bca9..d2a8d0b 100644 --- a/src/cli/commands/remove.test.ts +++ b/src/cli/commands/remove.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty rm` over fake CLI deps: the replies for each status, and stopping the server +// when the last session goes (ADR 063). import { describe, expect, test } from 'bun:test'; import { fakeCliDeps } from '../test-support/fake-cli-deps'; import { json, refused } from '../test-support/scripted-fetch'; diff --git a/src/cli/commands/rename.test.ts b/src/cli/commands/rename.test.ts index 7832252..f88374e 100644 --- a/src/cli/commands/rename.test.ts +++ b/src/cli/commands/rename.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty mv` over fake CLI deps: success, the server's refusals, and the +// not-running and usage messages (ADR 063). import { describe, expect, test } from 'bun:test'; import { fakeCliDeps } from '../test-support/fake-cli-deps'; import { json, refused } from '../test-support/scripted-fetch'; diff --git a/src/cli/commands/run.test.ts b/src/cli/commands/run.test.ts index bb0e63e..c19f8dc 100644 --- a/src/cli/commands/run.test.ts +++ b/src/cli/commands/run.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty run` over fake CLI deps: a headless command's streamed output and exit code, +// and the warm-up of server, session and PTY without a command (ADR 063). import { describe, expect, test } from 'bun:test'; import { fakeCliDeps, RUNNING, STOPPED } from '../test-support/fake-cli-deps'; import { json, lines, ndjson, type Responder, refused } from '../test-support/scripted-fetch'; diff --git a/src/cli/commands/start-stop.test.ts b/src/cli/commands/start-stop.test.ts index 93610d3..123ce85 100644 --- a/src/cli/commands/start-stop.test.ts +++ b/src/cli/commands/start-stop.test.ts @@ -1,3 +1,5 @@ +// Tests for `webtty start` and `webtty stop` over fake CLI deps: each lifecycle step and its +// already-in-that-state message (ADR 063). import { describe, expect, test } from 'bun:test'; import { fakeCliDeps, RUNNING, STOPPED } from '../test-support/fake-cli-deps'; import { start, stop } from './start-stop'; diff --git a/src/cli/help-text.test.ts b/src/cli/help-text.test.ts index cae85ff..7837330 100644 --- a/src/cli/help-text.test.ts +++ b/src/cli/help-text.test.ts @@ -1,3 +1,4 @@ +// Pins the exact text `webtty help` prints (ADR 063). import { describe, expect, test } from 'bun:test'; import { helpText } from './help-text'; diff --git a/src/cli/server-process.test.ts b/src/cli/server-process.test.ts index 5198cc2..2a917ca 100644 --- a/src/cli/server-process.test.ts +++ b/src/cli/server-process.test.ts @@ -1,7 +1,11 @@ +// Tests for the background server process: the entry it spawns, the production deps, and +// startServer and stopServer over fake spawn, filesystem and client (ADR 063). import { describe, expect, mock, spyOn, test } from 'bun:test'; +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { DEFAULT_CONFIG } from '../config'; +import { cleanupTmpHome, makeTmpHome } from '../test-support/server-harness'; import { type FetchLike, WebttyClient } from './api-client'; import { defaultServerProcessDeps, @@ -69,6 +73,34 @@ describe('defaultServerProcessDeps', () => { } }); + test("isRunning probes the configured port's /api/sessions", async () => { + const home = makeTmpHome('server-process'); + fs.mkdirSync(path.join(home, '.config', 'webtty'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.config', 'webtty', 'config.json'), + JSON.stringify({ port: 41003 }), + ); + const savedHome = process.env.HOME; + const realFetch = globalThis.fetch; + const seen: string[] = []; + globalThis.fetch = Object.assign( + async (url: string | URL | Request) => { + seen.push(String(url)); + return Response.json([]); + }, + { preconnect: realFetch.preconnect }, + ); + process.env.HOME = home; + try { + expect(await defaultServerProcessDeps().isRunning()).toBe(true); + } finally { + globalThis.fetch = realFetch; + process.env.HOME = savedHome; + cleanupTmpHome(home); + } + expect(seen).toEqual(['http://127.0.0.1:41003/api/sessions']); + }); + test('sleep resolves after the delay', async () => { const start = Date.now(); await defaultServerProcessDeps().sleep(5); diff --git a/src/cli/test-support.test.ts b/src/cli/test-support.test.ts new file mode 100644 index 0000000..b8bfc67 --- /dev/null +++ b/src/cli/test-support.test.ts @@ -0,0 +1,30 @@ +// Self-tests of the CLI test helpers, kept beside test-support/ because a test-support folder +// holds no *.test.ts (ADR 064): a command that reaches a host its fake deps were not given fails. +import { describe, expect, test } from 'bun:test'; +import { fakeCliDeps } from './test-support/fake-cli-deps'; + +describe('fakeCliDeps', () => { + test('the default editor fails the test on any use', () => { + const { editor } = fakeCliDeps().deps; + const uses: Array<() => unknown> = [ + () => editor.configDir(), + () => editor.mkdirp('/x'), + () => editor.exists('/x'), + () => editor.writeFile('/x', ''), + () => editor.runAttached('vi', []), + ]; + for (const use of uses) expect(use).toThrow('unexpected use of editor'); + }); + + test('the default tty fails the test on any use', () => { + const { tty } = fakeCliDeps().deps; + const uses: Array<() => unknown> = [ + () => tty.setRawMode(true), + () => tty.resume(), + () => tty.onData(() => {}), + () => tty.once('exit', () => {}), + () => tty.off('exit', () => {}), + ]; + for (const use of uses) expect(use).toThrow('unexpected use of tty'); + }); +}); diff --git a/src/config/client-view.test.ts b/src/config/client-view.test.ts index a232c6b..6497aad 100644 --- a/src/config/client-view.test.ts +++ b/src/config/client-view.test.ts @@ -1,3 +1,5 @@ +// Tests for the client-safe config view: the served keys, their order and the legacy +// `scrollback` key (ADR 062). import { describe, expect, test } from 'bun:test'; import { toClientConfig } from './client-view'; import { CLIENT_CONFIG_KEYS } from './config-schema'; diff --git a/src/config/config-paths.test.ts b/src/config/config-paths.test.ts index 566e663..998a75c 100644 --- a/src/config/config-paths.test.ts +++ b/src/config/config-paths.test.ts @@ -1,3 +1,5 @@ +// Tests for the config file location, read from HOME per call so tests can redirect it +// (ADR 062). import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import os from 'node:os'; import path from 'node:path'; diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 528c5eb..3177ad4 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -1,3 +1,5 @@ +// Tests for the client key list: every key exists in the defaults, none repeats, and the legacy +// `scrollback` key stays (ADR 062). import { describe, expect, test } from 'bun:test'; import { CLIENT_CONFIG_KEYS, type ClientConfigKey, type Config } from './config-schema'; import { DEFAULT_CONFIG } from './defaults'; diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 5b3cc37..d1e0ed0 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -1,3 +1,5 @@ +// Tests for the built-in config: the shell default from the environment, and the +// default values every missing key falls back to (ADR 062). import { describe, expect, test } from 'bun:test'; import { DEFAULT_CONFIG, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_THEME, defaultShell } from './defaults'; diff --git a/src/config/index.test.ts b/src/config/index.test.ts index 68bea82..c15b992 100644 --- a/src/config/index.test.ts +++ b/src/config/index.test.ts @@ -1,3 +1,5 @@ +// Tests for the config service on a temporary HOME: the stub written on first run, reading and +// merging a config file, and the re-exports `../config` importers rely on (ADR 062). import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; diff --git a/src/config/keyboard-bindings.test.ts b/src/config/keyboard-bindings.test.ts index 639fbec..df8229b 100644 --- a/src/config/keyboard-bindings.test.ts +++ b/src/config/keyboard-bindings.test.ts @@ -1,3 +1,5 @@ +// Table tests for the `keyboardBindings` rules: validation, normalisation and merging user +// entries over the defaults by `(key, mods)` (ADR 062). import { describe, expect, test } from 'bun:test'; import { isValidBinding, mergeKeyboardBindings, normalizeBinding } from './keyboard-bindings'; diff --git a/src/config/parse-config.test.ts b/src/config/parse-config.test.ts index 7b31ff4..1150b5e 100644 --- a/src/config/parse-config.test.ts +++ b/src/config/parse-config.test.ts @@ -1,3 +1,5 @@ +// Table tests for the pure config parser: one rule per key, the whole object, keyboard bindings +// and the right-click behaviour (ADR 062). import { describe, expect, test } from 'bun:test'; import type { Config } from './config-schema'; import { DEFAULT_CONFIG, DEFAULT_KEYBOARD_BINDINGS, DEFAULT_THEME } from './defaults'; diff --git a/src/ndjson.test.ts b/src/ndjson.test.ts index 0db1274..07875ce 100644 --- a/src/ndjson.test.ts +++ b/src/ndjson.test.ts @@ -1,3 +1,5 @@ +// Tests for the NDJSON LineSplitter: lines split across chunks, CRLF and blank lines, the +// partial last line, the optional CR strip and the maxLength cap (ADR 062, ADR 063). import { describe, expect, test } from 'bun:test'; import { LineSplitter } from './ndjson'; diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 8d6e420..261bb40 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -1,3 +1,5 @@ +// Tests for the PTY facade: which backend each runtime and platform picks, and spawnPty +// returning a working PtyProcess (ADR 062). import { describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; diff --git a/src/pty/node-backend.test.ts b/src/pty/node-backend.test.ts index 041f2a1..47725db 100644 --- a/src/pty/node-backend.test.ts +++ b/src/pty/node-backend.test.ts @@ -1,3 +1,5 @@ +// Tests for the node-pty backend over an injected addon: the spawn arguments per platform, the +// handle's methods and the live deps (ADR 062). import { describe, expect, test } from 'bun:test'; import nodePty from '@lydell/node-pty'; import { diff --git a/src/runtime.test.ts b/src/runtime.test.ts index ff880eb..d726688 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -1,3 +1,5 @@ +// Table tests for the runtime predicates over RuntimeEnv snapshots, and their live-process +// defaults (ADR 062). import { describe, expect, test } from 'bun:test'; import { bunTerminalAvailable, diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 4c69b57..8166c90 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -1,3 +1,5 @@ +// Tests for the server's composition root built in-process on port 0: routes, injected config +// and spawner, the 500 fallback and the shutdown order (ADR 062, ADR 064). import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import type { AddressInfo } from 'node:net'; diff --git a/src/server/http/request-body.test.ts b/src/server/http/request-body.test.ts index 144b1a3..a82f738 100644 --- a/src/server/http/request-body.test.ts +++ b/src/server/http/request-body.test.ts @@ -1,3 +1,5 @@ +// Tests for the request helpers: the 64 KB JSON body reader, its 413/400 replies, the JSON +// content-type check and URL ID decoding (ADR 062). import { describe, expect, test } from 'bun:test'; import http from 'node:http'; import { Socket } from 'node:net'; diff --git a/src/server/http/respond.test.ts b/src/server/http/respond.test.ts index 6f3db0e..b6f175c 100644 --- a/src/server/http/respond.test.ts +++ b/src/server/http/respond.test.ts @@ -1,3 +1,5 @@ +// Tests for the response writers on a recording ServerResponse: CORS, JSON, text and +// redirects, each setHeader, writeHead and end call pinned exactly (ADR 062). import { describe, expect, test } from 'bun:test'; import type http from 'node:http'; import { recordResponse } from '../../test-support/response-recorder'; diff --git a/src/server/http/static-files.test.ts b/src/server/http/static-files.test.ts index 99c54f7..2ef2365 100644 --- a/src/server/http/static-files.test.ts +++ b/src/server/http/static-files.test.ts @@ -1,3 +1,5 @@ +// Tests for static file serving: MIME types by extension, and serveFile's 200 and 404 replies +// (ADR 062). import { describe, expect, mock, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import { mimeType, serveFile } from './static-files'; diff --git a/src/server/output-pacer.test.ts b/src/server/output-pacer.test.ts index 5ee966f..fe755cd 100644 --- a/src/server/output-pacer.test.ts +++ b/src/server/output-pacer.test.ts @@ -1,3 +1,5 @@ +// Tests for the PTY output pacer over fake sockets and a fake PTY: the high and low water +// marks, PTY pause, legacy clients, replay pacing and the end flush (ADR 048, ADR 062). import { describe, expect, test } from 'bun:test'; import { FELL_BEHIND, diff --git a/src/server/output-pacer.ts b/src/server/output-pacer.ts index 669f2ec..b777e4d 100644 --- a/src/server/output-pacer.ts +++ b/src/server/output-pacer.ts @@ -1,8 +1,6 @@ -/** - * PTY output flow control (ADR 048). Each client acknowledges the characters it has - * parsed; the server stops sending to a client that is too far behind, queues its - * output in order, and pauses the PTY while every client is behind. - */ +// PTY output flow control (ADR 048, ADR 062): each client acknowledges the characters it has +// parsed; the server stops sending to a client that is too far behind, queues its output in +// order, and pauses the PTY while every client is behind. An object, one per session. /** Close code for a client whose queued output passed {@link FlowLimits.maxPending}. */ export const FELL_BEHIND = 4003; @@ -27,6 +25,7 @@ export interface FlowLimits { /** How long {@link OutputPacer.end} waits for an acknowledgement before it closes anyway, in ms. */ export const END_FLUSH_MS = 2000; +/** The limits every session uses; tests pass smaller ones to reach each threshold quickly. */ export const DEFAULT_FLOW_LIMITS: FlowLimits = { highWater: 2 * 1024 * 1024, lowWater: 512 * 1024, diff --git a/src/server/server-paths.test.ts b/src/server/server-paths.test.ts index 9ef1b4e..fa0e151 100644 --- a/src/server/server-paths.test.ts +++ b/src/server/server-paths.test.ts @@ -1,3 +1,5 @@ +// Tests for where the server finds its files, over an injected existsSync: the package root, +// the wasm, the client root and the missing-file exit (ADR 062). import { describe, expect, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/server/sessions/session-banner.test.ts b/src/server/sessions/session-banner.test.ts index dd85884..a34737d 100644 --- a/src/server/sessions/session-banner.test.ts +++ b/src/server/sessions/session-banner.test.ts @@ -1,3 +1,4 @@ +// Pins the new-session banner for each package runner: its width and its help hint (ADR 062). import { describe, expect, test } from 'bun:test'; import { sessionBanner } from './session-banner'; diff --git a/src/server/sessions/session-id.test.ts b/src/server/sessions/session-id.test.ts index 46be6af..a40123f 100644 --- a/src/server/sessions/session-id.test.ts +++ b/src/server/sessions/session-id.test.ts @@ -1,3 +1,4 @@ +// Table tests for session IDs: the accepted format and the random default (ADR 062). import { describe, expect, test } from 'bun:test'; import { generateId, isValidId } from './session-id'; diff --git a/src/server/sessions/session-store.test.ts b/src/server/sessions/session-store.test.ts index 68435bd..5b8ccdc 100644 --- a/src/server/sessions/session-store.test.ts +++ b/src/server/sessions/session-store.test.ts @@ -1,3 +1,5 @@ +// Tests for SessionStore: create, get, rename and remove, the last-used ID, the on-empty +// notice, lookup by shell PID and closeAll (ADR 062). import { describe, expect, mock, test } from 'bun:test'; import { FakeSocket } from '../../test-support/fake-socket'; import { SessionStore } from './session-store'; diff --git a/src/server/sessions/session.test.ts b/src/server/sessions/session.test.ts index 83e8c63..8e7396c 100644 --- a/src/server/sessions/session.test.ts +++ b/src/server/sessions/session.test.ts @@ -1,3 +1,5 @@ +// Tests for one Session over a fake PTY and fake sockets: output and scrollback, the PTY's +// start and exit, client attach and detach, close, events and the JSON view (ADR 062). import { describe, expect, mock, test } from 'bun:test'; import { homedir } from 'node:os'; import { DEFAULT_CONFIG } from '../../config'; diff --git a/src/server/ws/client-message.test.ts b/src/server/ws/client-message.test.ts index ddf10a8..9a92cc9 100644 --- a/src/server/ws/client-message.test.ts +++ b/src/server/ws/client-message.test.ts @@ -1,3 +1,5 @@ +// Table tests for what a PTY client sends: the size in the ws URL, clamping, and the resize, +// ack and raw input messages (ADR 048, ADR 062). import { describe, expect, test } from 'bun:test'; import { type ClientMessage, clampSize, parseClientMessage, sizeFromQuery } from './client-message'; diff --git a/src/server/ws/close-codes.test.ts b/src/server/ws/close-codes.test.ts index 88a75af..2d8b65b 100644 --- a/src/server/ws/close-codes.test.ts +++ b/src/server/ws/close-codes.test.ts @@ -1,3 +1,4 @@ +// Pins the WebSocket close codes on the wire, which the client depends on (ADR 048, ADR 062). import { describe, expect, test } from 'bun:test'; import { WS_CLOSE } from './close-codes'; diff --git a/src/server/ws/events-socket.test.ts b/src/server/ws/events-socket.test.ts index 35f0865..efb3408 100644 --- a/src/server/ws/events-socket.test.ts +++ b/src/server/ws/events-socket.test.ts @@ -1,3 +1,5 @@ +// Tests for the event subscriber handler: the close codes for an unknown session and a +// session with no shell, and subscribing until the socket closes (ADR 062). import { describe, expect, test } from 'bun:test'; import { FakePty } from '../../test-support/fake-pty'; import { FakeSocket } from '../../test-support/fake-socket'; diff --git a/src/server/ws/pty-socket.test.ts b/src/server/ws/pty-socket.test.ts index 7362143..d6698b1 100644 --- a/src/server/ws/pty-socket.test.ts +++ b/src/server/ws/pty-socket.test.ts @@ -1,3 +1,5 @@ +// Tests for the terminal client handler over a fake socket and fake PTY: attaching to the +// session and routing resize, ack and input messages (ADR 048, ADR 062). import { describe, expect, test } from 'bun:test'; import { DEFAULT_CONFIG } from '../../config'; import { FakePty } from '../../test-support/fake-pty'; diff --git a/src/server/ws/ws-upgrade.test.ts b/src/server/ws/ws-upgrade.test.ts index f59d9fe..4689c13 100644 --- a/src/server/ws/ws-upgrade.test.ts +++ b/src/server/ws/ws-upgrade.test.ts @@ -1,3 +1,5 @@ +// Tests for the WebSocket server behind the HTTP upgrade: routing `/ws/:id/pty` and +// `/ws/:id/events`, rejecting other paths, and the default PTY spawner (ADR 062). import { afterEach, describe, expect, test } from 'bun:test'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; diff --git a/src/test-support/fake-socket.ts b/src/test-support/fake-socket.ts index 3b5d5f3..0a8ca36 100644 --- a/src/test-support/fake-socket.ts +++ b/src/test-support/fake-socket.ts @@ -4,8 +4,9 @@ type Listener = (...args: never[]) => void; -/** Ready states a test can set, matching the `ws` constants. */ +/** The `ws` OPEN ready state, the one a FakeSocket starts in. */ export const OPEN = 1; +/** The `ws` CLOSED ready state, which close() moves the socket to. */ export const CLOSED = 3; /** A socket with the surface the session, the pacer and the ws handlers use, and no network. */ From c4522400267295c89e1cf4ea09d9ec3c8831f278 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 16:56:57 -0400 Subject: [PATCH 219/295] refactor(client): startClient hands steps 9-13 to installInput, keeping it under 60 lines startClient ran 68 lines. Find, the paste service, the key dispatcher, font zoom, the PTY input and paste move into installInput, beside bootstrap and installSurface, with the numbered comments and the order unchanged. The listener-order tests in start-client.test.ts pin the order. --- src/client/app/start-client.ts | 71 +++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/src/client/app/start-client.ts b/src/client/app/start-client.ts index b42d445..e536ec0 100644 --- a/src/client/app/start-client.ts +++ b/src/client/app/start-client.ts @@ -43,6 +43,14 @@ interface Bootstrap { tabTitle: TabTitle; } +/** What steps 9-13 use from the steps before them. */ +interface InputDeps { + pty: PtyConnection; + graphics: { clear(): void }; + smoothScroll: SmoothScroll; + layout: LayoutFeature; +} + /** What step 2 builds that later steps use. */ interface Surface { smoothScroll: SmoothScroll; @@ -95,32 +103,8 @@ export async function startClient(env: ClientEnv): Promise<Disposable> { connection = pty; // 8. Wheel scrolling: the custom wheel handler, the app's wheel pace, the glide's frame store.add(installScroll(ctx, { smoothScroll, wheelStream })); - const clearScreen = screenClearer(term, graphics, smoothScroll); - // 9. Find in the scrollback (ADR 055), before the dispatcher that drives it - const { mac } = ctx; - const find = store.add( - installFind(ctx, { ...env, shortcutActionOf: (e) => shortcutFor(e, mac)?.action }), - ); - // Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, - // ADR 049). Built here as the dispatcher's paste keys use it; its listeners come at 13. - const paste = new PasteService(term, pty, env.navigator); - // 10. The one container capture keydown listener: configured bindings (ADR 018), then - // Ghostty's shortcuts for this platform (ADR 054, ADR 055, ADR 057) - store.add( - installKeyDispatcher(ctx, { - connection: pty, - find, - clearScreen, - pasteFromClipboard: () => paste.pasteFromClipboard(), - }), - ); - // 11. Font zoom: the last window capture keydown listener, fitting at once (ADR 033) - store.add(installFontZoom(ctx, { fit: () => layout.fit() })); - // 12. Keystrokes and input to the PTY, and the grid size whenever it changes - store.add(term.onData((data: string) => pty.send(data))); - store.add(term.onResize(({ cols, rows }) => pty.sendResize(cols, rows))); - // 13. The right click set to paste, then the container capture paste listener (ADR 051) - store.add(installPaste(ctx, { paste })); + // 9-13. Find, the key dispatcher, font zoom, input to the PTY and paste + installInput(ctx, env, store, { pty, graphics, smoothScroll, layout }); return store; } @@ -194,3 +178,38 @@ function installSurface(ctx: ClientContext, env: ClientEnv, store: DisposableSto store.add(installRepaintOnVisible(ctx)); return { smoothScroll, wheelStream, layout }; } + +/** Steps 9-13: find, the paste service, the key dispatcher, font zoom, the PTY input and paste. */ +function installInput( + ctx: ClientContext, + env: ClientEnv, + store: DisposableStore, + { pty, graphics, smoothScroll, layout }: InputDeps, +): void { + const { term, mac } = ctx; + const clearScreen = screenClearer(term, graphics, smoothScroll); + // 9. Find in the scrollback (ADR 055), before the dispatcher that drives it + const find = store.add( + installFind(ctx, { ...env, shortcutActionOf: (e) => shortcutFor(e, mac)?.action }), + ); + // Pastes: text through the terminal, a non-text paste as Ctrl+V (ADR 014, ADR 037, + // ADR 049). Built here as the dispatcher's paste keys use it; its listeners come at 13. + const paste = new PasteService(term, pty, env.navigator); + // 10. The one container capture keydown listener: configured bindings (ADR 018), then + // Ghostty's shortcuts for this platform (ADR 054, ADR 055, ADR 057) + store.add( + installKeyDispatcher(ctx, { + connection: pty, + find, + clearScreen, + pasteFromClipboard: () => paste.pasteFromClipboard(), + }), + ); + // 11. Font zoom: the last window capture keydown listener, fitting at once (ADR 033) + store.add(installFontZoom(ctx, { fit: () => layout.fit() })); + // 12. Keystrokes and input to the PTY, and the grid size whenever it changes + store.add(term.onData((data: string) => pty.send(data))); + store.add(term.onResize(({ cols, rows }) => pty.sendResize(cols, rows))); + // 13. The right click set to paste, then the container capture paste listener (ADR 051) + store.add(installPaste(ctx, { paste })); +} From 127085249b5ef776e7c053c973a78898f6c187ae Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:00:43 -0400 Subject: [PATCH 220/295] refactor(scripts): split update-ghostty-vt into scripts/ghostty-vt/ update-ghostty-vt.ts ran 360 lines. Its parts move into scripts/ghostty-vt/: minisign.ts (sha256 and verifyMinisign, the parse split from the checks), inspect-wasm.ts (the required exports and ghostty_build_info), sidecar.ts (the pin constants, the Sidecar record, verifyPinned and writePin) and release-fetch.ts (tip, the downloads and the sidecar they produce, with fetch, the GitHub token, the key and the clock passed in as deps whose defaults are the old globals). update-ghostty-vt.ts is the CLI: runCli parses --commit/--verify and returns the exit code, update and verify take the vendor dir and the output, and it re-exports what the vendor test imports. Output, exit codes, request order and the written files are unchanged; --verify prints the same line. The ratchet entry for the file is removed. --- scripts/architecture/ratchet-allowlist.ts | 1 - scripts/ghostty-vt/inspect-wasm.ts | 105 ++++++ scripts/ghostty-vt/minisign.ts | 102 ++++++ scripts/ghostty-vt/release-fetch.ts | 159 +++++++++ scripts/ghostty-vt/sidecar.ts | 90 +++++ scripts/update-ghostty-vt.ts | 384 ++++------------------ 6 files changed, 511 insertions(+), 330 deletions(-) create mode 100644 scripts/ghostty-vt/inspect-wasm.ts create mode 100644 scripts/ghostty-vt/minisign.ts create mode 100644 scripts/ghostty-vt/release-fetch.ts create mode 100644 scripts/ghostty-vt/sidecar.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 9f7aec1..576f8aa 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -15,7 +15,6 @@ const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; export const RATCHET_ALLOWLIST: Allowlist = { strict: false, sizes: { - 'scripts/update-ghostty-vt.ts': { lines: 360, step: 'scripts/ghostty-vt/ split (ADR 059)' }, 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, diff --git a/scripts/ghostty-vt/inspect-wasm.ts b/scripts/ghostty-vt/inspect-wasm.ts new file mode 100644 index 0000000..816ac83 --- /dev/null +++ b/scripts/ghostty-vt/inspect-wasm.ts @@ -0,0 +1,105 @@ +// Offline inspection of a libghostty-vt wasm: the exports the binding needs and what +// `ghostty_build_info` reports, recorded in the sidecar (util, ADR 041, ADR 059). + +/** Exports the binding needs; a pin without them is refused before it is written. */ +export const REQUIRED_EXPORTS = [ + 'memory', + '__indirect_function_table', + 'ghostty_type_json', + 'ghostty_wasm_alloc', + 'ghostty_wasm_free', + 'ghostty_build_info', + 'ghostty_terminal_new', + 'ghostty_terminal_free', + 'ghostty_terminal_vt_write', + 'ghostty_terminal_resize', + 'ghostty_terminal_scroll_viewport', + 'ghostty_render_state_new', + 'ghostty_render_state_update', + 'ghostty_key_encoder_encode', +]; + +/** What `ghostty_build_info` reports; the wasm does not record its commit. */ +export interface WasmBuildInfo { + version: string; + versionBuild: string; + optimize: string; + simd: boolean; + kittyGraphics: boolean; + tmuxControlMode: boolean; +} + +/** The sidecar's `wasm` record: the module's shape and its build info. */ +export interface WasmInfo { + imports: string[]; + exportCount: number; + functionExports: number; + nonFunctionExports: string[]; + initialMemoryBytes: number; + buildInfo: WasmBuildInfo; +} + +// A type literal, not an interface, so the cast from WebAssembly.Exports type-checks. +type BuildInfoExports = { + memory: WebAssembly.Memory; + ghostty_wasm_alloc(len: number): number; + ghostty_wasm_free(ptr: number, len: number): void; + ghostty_build_info(id: number, out: number): number; +}; + +const OPTIMIZE_MODES = ['Debug', 'ReleaseSafe', 'ReleaseSmall', 'ReleaseFast']; + +/** Reads the build info fields through a 16-byte out buffer in wasm memory. */ +function readBuildInfo(ex: BuildInfoExports): WasmBuildInfo { + const out = ex.ghostty_wasm_alloc(16); + const query = (id: number) => { + const rc = ex.ghostty_build_info(id, out); + if (rc !== 0) throw new Error(`wasm: ghostty_build_info(${id}) returned ${rc}`); + return new DataView(ex.memory.buffer); + }; + const str = (id: number) => { + const view = query(id); + const ptr = view.getUint32(out, true); + const len = view.getUint32(out + 4, true); + return new TextDecoder().decode(new Uint8Array(ex.memory.buffer, ptr, len)); + }; + const bool = (id: number) => query(id).getUint8(out) !== 0; + const optimize = query(4).getUint32(out, true); + const buildInfo = { + version: str(5), + versionBuild: str(10), + optimize: OPTIMIZE_MODES[optimize] ?? String(optimize), + simd: bool(1), + kittyGraphics: bool(2), + tmuxControlMode: bool(3), + }; + ex.ghostty_wasm_free(out, 16); + return buildInfo; +} + +/** + * Instantiates the wasm, checks the exports the binding needs and reads `ghostty_build_info`. + * + * @throws If the module does not validate or lacks a required export. + */ +export async function inspectWasm(bytes: Uint8Array<ArrayBuffer>): Promise<WasmInfo> { + if (!WebAssembly.validate(bytes)) throw new Error('wasm: WebAssembly.validate failed'); + const mod = new WebAssembly.Module(bytes); + const exports = WebAssembly.Module.exports(mod); + const names = new Set(exports.map((e) => e.name)); + const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n)); + if (missing.length) throw new Error(`wasm: missing exports ${missing.join(', ')}`); + const instance = await WebAssembly.instantiate(mod, {}); + const ex = instance.exports as BuildInfoExports; + const buildInfo = readBuildInfo(ex); + return { + imports: WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}:${i.kind}`), + exportCount: exports.length, + functionExports: exports.filter((e) => e.kind === 'function').length, + nonFunctionExports: exports + .filter((e) => e.kind !== 'function') + .map((e) => `${e.name}:${e.kind}`), + initialMemoryBytes: ex.memory.buffer.byteLength, + buildInfo, + }; +} diff --git a/scripts/ghostty-vt/minisign.ts b/scripts/ghostty-vt/minisign.ts new file mode 100644 index 0000000..8140e23 --- /dev/null +++ b/scripts/ghostty-vt/minisign.ts @@ -0,0 +1,102 @@ +// Minisign signature verification over node:crypto, used to check that Ghostty's CI signed +// the vendored wasm (pure util, ADR 042, ADR 059). The format is minisign's: an untrusted +// comment, the signature line, the trusted comment and the global signature over it. + +import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'; + +/** What a verified signature says about itself; recorded in the sidecar. */ +export interface MinisigResult { + /** Key id as minisign prints it (big-endian hex). */ + keyId: string; + /** `ED`: signature over BLAKE2b-512 of the file; `Ed`: over the file itself. */ + algorithm: 'Ed' | 'ED'; + trustedComment: string; + timestamp?: number; + file?: string; +} + +/** The parsed fields of a `.minisig` file, before any signature is checked. */ +interface MinisigFile { + algorithm: 'Ed' | 'ED'; + keyId: Buffer; + sigBytes: Buffer; + trustedComment: string; + globalSig: Buffer; +} + +/** The hex sha256 of `bytes`, as the sidecar and the build record it. */ +export function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +const hexId = (b: Uint8Array) => Buffer.from(b).reverse().toString('hex').toUpperCase(); + +function parseMinisig(minisigText: string): MinisigFile { + const lines = minisigText.replace(/\r/g, '').split('\n'); + if (!lines[0]?.startsWith('untrusted comment:')) { + throw new Error('minisign: missing untrusted comment'); + } + const sig = Buffer.from(lines[1] ?? '', 'base64'); + if (sig.length !== 74) throw new Error(`minisign: signature is ${sig.length} bytes, want 74`); + const algorithm = sig.toString('latin1', 0, 2); + if (algorithm !== 'Ed' && algorithm !== 'ED') { + throw new Error(`minisign: unknown algorithm ${algorithm}`); + } + const tcPrefix = 'trusted comment: '; + if (!lines[2]?.startsWith(tcPrefix)) throw new Error('minisign: missing trusted comment'); + const trustedComment = lines[2].slice(tcPrefix.length); + const globalSig = Buffer.from(lines[3] ?? '', 'base64'); + if (globalSig.length !== 64) throw new Error('minisign: bad global signature'); + return { + algorithm, + keyId: sig.subarray(2, 10), + sigBytes: sig.subarray(10, 74), + trustedComment, + globalSig, + }; +} + +/** + * Verifies a minisign signature over `data`: the Ed25519 signature (over BLAKE2b-512 of the + * data for `ED`) and the global signature over the trusted comment, both against `publicKey`. + * + * @throws If the signature, its key id or its trusted comment does not verify. + */ +export function verifyMinisign( + data: Uint8Array, + minisigText: string, + publicKey: string, +): MinisigResult { + const pk = Buffer.from(publicKey, 'base64'); + if (pk.length !== 42 || pk.toString('latin1', 0, 2) !== 'Ed') { + throw new Error('minisign: bad public key'); + } + const pkKeyId = pk.subarray(2, 10); + const { algorithm, keyId, sigBytes, trustedComment, globalSig } = parseMinisig(minisigText); + if (!keyId.equals(pkKeyId)) { + throw new Error(`minisign: signed by key ${hexId(keyId)}, expected ${hexId(pkKeyId)}`); + } + // Raw Ed25519 key in an SPKI DER wrapper for node:crypto. + const key = createPublicKey({ + key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), pk.subarray(10, 42)]), + format: 'der', + type: 'spki', + }); + const message = + algorithm === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data); + if (!edVerify(null, message, key, sigBytes)) { + throw new Error('minisign: file signature is invalid'); + } + const signedComment = Buffer.concat([sigBytes, Buffer.from(trustedComment, 'utf8')]); + if (!edVerify(null, signedComment, key, globalSig)) { + throw new Error('minisign: trusted comment signature is invalid'); + } + const ts = /timestamp:(\d+)/.exec(trustedComment)?.[1]; + return { + keyId: hexId(keyId), + algorithm, + trustedComment, + timestamp: ts ? Number(ts) : undefined, + file: /file:([^\t]+)/.exec(trustedComment)?.[1], + }; +} diff --git a/scripts/ghostty-vt/release-fetch.ts b/scripts/ghostty-vt/release-fetch.ts new file mode 100644 index 0000000..ae0918a --- /dev/null +++ b/scripts/ghostty-vt/release-fetch.ts @@ -0,0 +1,159 @@ +// Fetches a libghostty-vt pin from Ghostty's release channels and verifies it, with +// `fetch` injected so it runs on fakes in tests (service, ADR 041, ADR 042, ADR 059). +// +// Where Ghostty publishes the wasm (.github/workflows/release-tip.yml, build-lib-vt-wasm): +// - tip.files.ghostty.org/<sha>/ghostty-vt-small.wasm: per commit, unsigned. +// - The GitHub `tip` prerelease: the current tip's wasm and .minisig, overwritten by every +// tip build. A signature can only be captured while the pinned commit is `tip`. +// +// The wasm does not record its commit (`ghostty_build_info` reports 0.1.0-dev), so the +// binding to <sha> is: `tip` points at <sha> before and after the download, and the GitHub +// asset has the same sha256 as the commit-addressed one. The signature by Ghostty's release +// key (PACKAGING.md) says Ghostty's CI built those bytes. Both are recorded in the sidecar. + +import { inspectWasm } from './inspect-wasm'; +import { sha256, verifyMinisign } from './minisign'; +import { + GHOSTTY_MINISIGN_PUBKEY, + LICENSE_NOTE, + type PinnedRelease, + type Sidecar, + WASM_FILE, +} from './sidecar'; + +/** Ghostty's GitHub repository, for the API and raw file URLs. */ +export const REPO = 'ghostty-org/ghostty'; +/** The per-commit, unsigned wasm host. */ +export const R2 = 'https://tip.files.ghostty.org'; +/** The GitHub `tip` prerelease's download folder. */ +export const GH_TIP = `https://github.com/${REPO}/releases/download/tip`; + +/** The part of `fetch` the release fetch uses, so a fake needs no more. */ +export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>; + +/** What fetching a pin depends on; production defaults come from `releaseFetchDeps()`. */ +export interface ReleaseFetchDeps { + fetch: FetchFn; + /** A GitHub token for the API's rate limit, if one is set. */ + token?: string; + /** The key the signature must verify against. */ + publicKey: string; + now(): Date; +} + +/** The real network, the token from GITHUB_TOKEN or GH_TOKEN, and Ghostty's release key. */ +export function releaseFetchDeps(overrides: Partial<ReleaseFetchDeps> = {}): ReleaseFetchDeps { + return { + fetch: (url, init) => fetch(url, init), + token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, + publicKey: GHOSTTY_MINISIGN_PUBKEY, + now: () => new Date(), + ...overrides, + }; +} + +interface GitRef { + object: { type: string; sha: string }; +} + +async function get(url: string, deps: ReleaseFetchDeps): Promise<Uint8Array<ArrayBuffer>> { + const res = await deps.fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} + +async function gh<T>(apiPath: string, deps: ReleaseFetchDeps): Promise<T> { + const headers: Record<string, string> = { accept: 'application/vnd.github+json' }; + if (deps.token) headers.authorization = `Bearer ${deps.token}`; + const res = await deps.fetch(`https://api.github.com/repos/${REPO}/${apiPath}`, { headers }); + if (!res.ok) throw new Error(`GitHub API ${apiPath}: ${res.status}`); + return (await res.json()) as T; +} + +/** The commit the `tip` tag points at, through an annotated tag if it is one. */ +export async function tipCommit(deps: ReleaseFetchDeps): Promise<string> { + const ref = await gh<GitRef>('git/ref/tags/tip', deps); + if (ref.object.type === 'commit') return ref.object.sha; + return (await gh<GitRef>(`git/tags/${ref.object.sha}`, deps)).object.sha; +} + +/** The downloads of one pin, taken while `tip` is `commit` before and after. */ +interface Downloads { + url: string; + bytes: Uint8Array<ArrayBuffer>; + hash: string; + minisig: string; + license: Uint8Array<ArrayBuffer>; +} + +async function downloadAtTip(commit: string, deps: ReleaseFetchDeps): Promise<Downloads> { + const tipBefore = await tipCommit(deps); + if (tipBefore !== commit) { + throw new Error( + `${commit} is not the current tip (${tipBefore}); its .minisig is only published while it is tip`, + ); + } + const url = `${R2}/${commit}/${WASM_FILE}`; + const bytes = await get(url, deps); + const hash = sha256(bytes); + const ghBytes = await get(`${GH_TIP}/${WASM_FILE}`, deps); + const minisig = new TextDecoder().decode(await get(`${GH_TIP}/${WASM_FILE}.minisig`, deps)); + const license = await get(`https://raw.githubusercontent.com/${REPO}/${commit}/LICENSE`, deps); + const tipAfter = await tipCommit(deps); + if (tipAfter !== commit) throw new Error(`tip moved to ${tipAfter} during the download; rerun`); + if (sha256(ghBytes) !== hash) { + throw new Error(`GitHub tip asset sha256 ${sha256(ghBytes)} != ${url} ${hash}; rerun later`); + } + const release = await gh<{ assets?: { name: string; digest?: string }[] }>( + 'releases/tags/tip', + deps, + ); + const digest = release.assets?.find((a) => a.name === WASM_FILE)?.digest?.replace(/^sha256:/, ''); + if (digest && digest !== hash) throw new Error(`GitHub asset digest ${digest} != ${hash}`); + return { url, bytes, hash, minisig, license }; +} + +/** + * Fetches the wasm of Ghostty `commit` while it is `tip`, checks it against the GitHub + * asset, its signature and its build info, and returns the pin with its sidecar. + * + * @throws If `commit` is not a full sha, is not tip, or any check fails. + */ +export async function fetchRelease(commit: string, deps: ReleaseFetchDeps): Promise<PinnedRelease> { + if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error('--commit needs a full 40-character sha'); + const { url, bytes, hash, minisig, license } = await downloadAtTip(commit, deps); + const sig = verifyMinisign(bytes, minisig, deps.publicKey); + if (sig.file !== WASM_FILE) throw new Error(`signature is for ${sig.file}, not ${WASM_FILE}`); + const wasm = await inspectWasm(bytes); + if (wasm.buildInfo.optimize !== 'ReleaseSmall') { + throw new Error(`${WASM_FILE} is ${wasm.buildInfo.optimize}, not ReleaseSmall`); + } + const commitInfo = await gh<{ commit?: { committer?: { date?: string } } }>( + `commits/${commit}`, + deps, + ).catch(() => undefined); + + const sidecar: Sidecar = { + repo: `https://github.com/${REPO}`, + commit, + commitDate: commitInfo?.commit?.committer?.date, + url, + file: WASM_FILE, + variant: 'ReleaseSmall', + size: bytes.byteLength, + sha256: hash, + minisig: `${WASM_FILE}.minisig`, + minisigSource: `${GH_TIP}/${WASM_FILE}.minisig (tip == ${commit})`, + publicKey: deps.publicKey, + signature: { + keyId: sig.keyId, + algorithm: sig.algorithm, + trustedComment: sig.trustedComment, + signedAt: sig.timestamp ? new Date(sig.timestamp * 1000).toISOString() : undefined, + }, + verifiedAt: deps.now().toISOString(), + license: LICENSE_NOTE, + wasm, + }; + return { bytes, minisig, license, sidecar }; +} diff --git a/scripts/ghostty-vt/sidecar.ts b/scripts/ghostty-vt/sidecar.ts new file mode 100644 index 0000000..cd4cd2c --- /dev/null +++ b/scripts/ghostty-vt/sidecar.ts @@ -0,0 +1,90 @@ +// The pin record of the vendored libghostty-vt wasm: its constants, the JSON sidecar +// written next to it, and reading, re-verifying and writing a pin on disk (service over +// node:fs, ADR 041, ADR 042, ADR 059). + +import fs from 'node:fs'; +import path from 'node:path'; +import type { WasmInfo } from './inspect-wasm'; +import { type MinisigResult, sha256, verifyMinisign } from './minisign'; + +/** Ghostty's minisign release key, from Ghostty's PACKAGING.md. */ +export const GHOSTTY_MINISIGN_PUBKEY = 'RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV'; +/** The vendored wasm's file name; the build copies it under the same name. */ +export const WASM_FILE = 'ghostty-vt-small.wasm'; +/** The sidecar's licence line for the vendored `LICENSE`. */ +export const LICENSE_NOTE = + 'MIT (Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors; LICENSE)'; + +/** The JSON sidecar written next to the wasm. */ +export interface Sidecar { + repo: string; + commit: string; + commitDate?: string; + url: string; + file: string; + variant: 'ReleaseSmall'; + size: number; + sha256: string; + minisig: string; + minisigSource: string; + publicKey: string; + signature: { + keyId: string; + algorithm: string; + trustedComment: string; + signedAt?: string; + }; + verifiedAt: string; + license: string; + wasm: WasmInfo; +} + +/** Everything a pin writes to the vendor folder, fetched and verified. */ +export interface PinnedRelease { + bytes: Uint8Array; + minisig: string; + license: Uint8Array; + sidecar: Sidecar; +} + +/** + * Re-checks a vendored pin offline: size, sha256, signature against `publicKey`, and the + * signed file name. + * + * @throws On any mismatch. + */ +export function verifyPinned( + dir: string, + publicKey: string, +): { sidecar: Sidecar; sig: MinisigResult } { + const sidecar = JSON.parse( + fs.readFileSync(path.join(dir, `${WASM_FILE}.json`), 'utf8'), + ) as Sidecar; + const bytes = new Uint8Array(fs.readFileSync(path.join(dir, sidecar.file))); + if (bytes.byteLength !== sidecar.size) { + throw new Error(`${sidecar.file}: size ${bytes.byteLength} != sidecar ${sidecar.size}`); + } + const hash = sha256(bytes); + if (hash !== sidecar.sha256) + throw new Error(`${sidecar.file}: sha256 ${hash} != sidecar ${sidecar.sha256}`); + const sig = verifyMinisign( + bytes, + fs.readFileSync(path.join(dir, sidecar.minisig), 'utf8'), + publicKey, + ); + if (sig.file !== sidecar.file) + throw new Error(`signature is for ${sig.file}, not ${sidecar.file}`); + return { sidecar, sig }; +} + +/** Writes the wasm, its signature, Ghostty's licence and the sidecar into `dir`. */ +export function writePin(dir: string, pin: PinnedRelease): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, WASM_FILE), pin.bytes); + fs.writeFileSync(path.join(dir, `${WASM_FILE}.minisig`), pin.minisig); + fs.writeFileSync(path.join(dir, 'LICENSE'), pin.license); + fs.writeFileSync( + path.join(dir, `${WASM_FILE}.json`), + `${JSON.stringify(pin.sidecar, null, 2)}\n`, + ); +} diff --git a/scripts/update-ghostty-vt.ts b/scripts/update-ghostty-vt.ts index 3f6eb57..4f14e11 100644 --- a/scripts/update-ghostty-vt.ts +++ b/scripts/update-ghostty-vt.ts @@ -1,27 +1,24 @@ -/** - * Vendors and verifies Ghostty's official libghostty-vt wasm (ReleaseSmall) into - * src/client/vendor/ghostty-vt/ (ADR 041, ADR 042). - * - * bun scripts/update-ghostty-vt.ts --commit <sha> fetch and verify a new pin (network) - * bun scripts/update-ghostty-vt.ts --verify re-check the committed pin (offline) - * - * Where Ghostty publishes the wasm (.github/workflows/release-tip.yml, build-lib-vt-wasm): - * - tip.files.ghostty.org/<sha>/ghostty-vt-small.wasm: per commit, unsigned. - * - The GitHub `tip` prerelease: the current tip's wasm and .minisig, overwritten by every - * tip build. A signature can only be captured while the pinned commit is `tip`. - * - * The wasm does not record its commit (`ghostty_build_info` reports 0.1.0-dev), so the - * binding to <sha> is: `tip` points at <sha> before and after the download, and the GitHub - * asset has the same sha256 as the commit-addressed one. The signature by Ghostty's release - * key (PACKAGING.md) says Ghostty's CI built those bytes. Both are recorded in the sidecar. - */ -import { createHash, createPublicKey, verify as edVerify } from 'node:crypto'; +// Vendors and verifies Ghostty's official libghostty-vt wasm (ReleaseSmall) into +// src/client/vendor/ghostty-vt/ (CLI entry, ADR 041, ADR 042, ADR 059). The work lives in +// scripts/ghostty-vt/; this file parses the flags and reports. +// +// bun scripts/update-ghostty-vt.ts --commit <sha> fetch and verify a new pin (network) +// bun scripts/update-ghostty-vt.ts --verify re-check the committed pin (offline) + import fs from 'node:fs'; import path from 'node:path'; - -/** Ghostty's minisign release key, from Ghostty's PACKAGING.md. */ -export const GHOSTTY_MINISIGN_PUBKEY = 'RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV'; -export const WASM_FILE = 'ghostty-vt-small.wasm'; +import { inspectWasm } from './ghostty-vt/inspect-wasm'; +import { fetchRelease, type ReleaseFetchDeps, releaseFetchDeps } from './ghostty-vt/release-fetch'; +import { GHOSTTY_MINISIGN_PUBKEY, verifyPinned, WASM_FILE, writePin } from './ghostty-vt/sidecar'; + +// The vendor test (src/client/vendor/ghostty-vt.test.ts) imports these from here. +export type { MinisigResult } from './ghostty-vt/minisign'; +export { sha256, verifyMinisign } from './ghostty-vt/minisign'; +export type { Sidecar } from './ghostty-vt/sidecar'; +export { GHOSTTY_MINISIGN_PUBKEY, verifyPinned, WASM_FILE } from './ghostty-vt/sidecar'; +export { inspectWasm }; + +/** Where the pin lives: the vendored, untouched official wasm. */ export const VENDOR_DIR = path.resolve( import.meta.dir, '..', @@ -31,330 +28,59 @@ export const VENDOR_DIR = path.resolve( 'ghostty-vt', ); -const REPO = 'ghostty-org/ghostty'; -const R2 = 'https://tip.files.ghostty.org'; -const GH_TIP = `https://github.com/${REPO}/releases/download/tip`; -const LICENSE_NOTE = 'MIT (Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors; LICENSE)'; - -/** Exports the binding needs; a pin without them is refused before it is written. */ -const REQUIRED_EXPORTS = [ - 'memory', - '__indirect_function_table', - 'ghostty_type_json', - 'ghostty_wasm_alloc', - 'ghostty_wasm_free', - 'ghostty_build_info', - 'ghostty_terminal_new', - 'ghostty_terminal_free', - 'ghostty_terminal_vt_write', - 'ghostty_terminal_resize', - 'ghostty_terminal_scroll_viewport', - 'ghostty_render_state_new', - 'ghostty_render_state_update', - 'ghostty_key_encoder_encode', -]; - -export interface MinisigResult { - /** Key id as minisign prints it (big-endian hex). */ - keyId: string; - /** `ED`: signature over BLAKE2b-512 of the file; `Ed`: over the file itself. */ - algorithm: 'Ed' | 'ED'; - trustedComment: string; - timestamp?: number; - file?: string; -} - -/** The JSON sidecar written next to the wasm. */ -export interface Sidecar { - repo: string; - commit: string; - commitDate?: string; - url: string; - file: string; - variant: 'ReleaseSmall'; - size: number; - sha256: string; - minisig: string; - minisigSource: string; - publicKey: string; - signature: { - keyId: string; - algorithm: string; - trustedComment: string; - signedAt?: string; - }; - verifiedAt: string; - license: string; - wasm: Awaited<ReturnType<typeof inspectWasm>>; -} - -export function sha256(bytes: Uint8Array): string { - return createHash('sha256').update(bytes).digest('hex'); -} - -/** - * Verifies a minisign signature over `data`: the Ed25519 signature (over BLAKE2b-512 of the - * data for `ED`) and the global signature over the trusted comment, both against `publicKey`. - * - * @throws If the signature, its key id or its trusted comment does not verify. - */ -export function verifyMinisign( - data: Uint8Array, - minisigText: string, - publicKey: string, -): MinisigResult { - const pk = Buffer.from(publicKey, 'base64'); - if (pk.length !== 42 || pk.toString('latin1', 0, 2) !== 'Ed') { - throw new Error('minisign: bad public key'); - } - const pkKeyId = pk.subarray(2, 10); - - const lines = minisigText.replace(/\r/g, '').split('\n'); - if (!lines[0]?.startsWith('untrusted comment:')) { - throw new Error('minisign: missing untrusted comment'); - } - const sig = Buffer.from(lines[1] ?? '', 'base64'); - if (sig.length !== 74) throw new Error(`minisign: signature is ${sig.length} bytes, want 74`); - const algorithm = sig.toString('latin1', 0, 2); - if (algorithm !== 'Ed' && algorithm !== 'ED') { - throw new Error(`minisign: unknown algorithm ${algorithm}`); - } - const keyId = sig.subarray(2, 10); - const sigBytes = sig.subarray(10, 74); - const tcPrefix = 'trusted comment: '; - if (!lines[2]?.startsWith(tcPrefix)) throw new Error('minisign: missing trusted comment'); - const trustedComment = lines[2].slice(tcPrefix.length); - const globalSig = Buffer.from(lines[3] ?? '', 'base64'); - if (globalSig.length !== 64) throw new Error('minisign: bad global signature'); - - const hexId = (b: Uint8Array) => Buffer.from(b).reverse().toString('hex').toUpperCase(); - if (!keyId.equals(pkKeyId)) { - throw new Error(`minisign: signed by key ${hexId(keyId)}, expected ${hexId(pkKeyId)}`); - } - // Raw Ed25519 key in an SPKI DER wrapper for node:crypto. - const key = createPublicKey({ - key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), pk.subarray(10, 42)]), - format: 'der', - type: 'spki', - }); - const message = - algorithm === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data); - if (!edVerify(null, message, key, sigBytes)) { - throw new Error('minisign: file signature is invalid'); - } - const signedComment = Buffer.concat([sigBytes, Buffer.from(trustedComment, 'utf8')]); - if (!edVerify(null, signedComment, key, globalSig)) { - throw new Error('minisign: trusted comment signature is invalid'); - } - const ts = /timestamp:(\d+)/.exec(trustedComment)?.[1]; - return { - keyId: hexId(keyId), - algorithm, - trustedComment, - timestamp: ts ? Number(ts) : undefined, - file: /file:([^\t]+)/.exec(trustedComment)?.[1], - }; +/** Where the CLI writes and what it prints through; tests pass a tmp dir and fakes. */ +export interface CliDeps { + dir: string; + log(line: string): void; + error(line: string): void; + fetch: Partial<ReleaseFetchDeps>; } -/** - * Instantiates the wasm, checks the exports the binding needs and reads `ghostty_build_info`. - * - * @throws If the module does not validate or lacks a required export. - */ -export async function inspectWasm(bytes: Uint8Array<ArrayBuffer>) { - if (!WebAssembly.validate(bytes)) throw new Error('wasm: WebAssembly.validate failed'); - const mod = new WebAssembly.Module(bytes); - const exports = WebAssembly.Module.exports(mod); - const names = new Set(exports.map((e) => e.name)); - const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n)); - if (missing.length) throw new Error(`wasm: missing exports ${missing.join(', ')}`); - const instance = await WebAssembly.instantiate(mod, {}); - const ex = instance.exports as { - memory: WebAssembly.Memory; - ghostty_wasm_alloc(len: number): number; - ghostty_wasm_free(ptr: number, len: number): void; - ghostty_build_info(id: number, out: number): number; - }; - const out = ex.ghostty_wasm_alloc(16); - const query = (id: number) => { - const rc = ex.ghostty_build_info(id, out); - if (rc !== 0) throw new Error(`wasm: ghostty_build_info(${id}) returned ${rc}`); - return new DataView(ex.memory.buffer); - }; - const str = (id: number) => { - const view = query(id); - const ptr = view.getUint32(out, true); - const len = view.getUint32(out + 4, true); - return new TextDecoder().decode(new Uint8Array(ex.memory.buffer, ptr, len)); - }; - const bool = (id: number) => query(id).getUint8(out) !== 0; - const optimize = query(4).getUint32(out, true); - const buildInfo = { - version: str(5), - versionBuild: str(10), - optimize: ['Debug', 'ReleaseSafe', 'ReleaseSmall', 'ReleaseFast'][optimize] ?? String(optimize), - simd: bool(1), - kittyGraphics: bool(2), - tmuxControlMode: bool(3), - }; - ex.ghostty_wasm_free(out, 16); - return { - imports: WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}:${i.kind}`), - exportCount: exports.length, - functionExports: exports.filter((e) => e.kind === 'function').length, - nonFunctionExports: exports - .filter((e) => e.kind !== 'function') - .map((e) => `${e.name}:${e.kind}`), - initialMemoryBytes: ex.memory.buffer.byteLength, - buildInfo, - }; +/** Fetches, verifies and writes the pin for Ghostty `commit` (the only networked step). */ +export async function update(commit: string, deps: CliDeps): Promise<void> { + const pin = await fetchRelease(commit, releaseFetchDeps(deps.fetch)); + writePin(deps.dir, pin); + deps.log(`wrote ${WASM_FILE}{,.minisig,.json} and LICENSE for ${commit} to ${deps.dir}`); + deps.log('Update PROVENANCE.md if needed, then re-run the binding tests (ADR 041).'); } -/** - * Re-checks a vendored pin offline: size, sha256, signature against `publicKey`, and the - * signed file name. - * - * @throws On any mismatch. - */ -export function verifyPinned( - dir: string, - publicKey: string, -): { sidecar: Sidecar; sig: MinisigResult } { - const sidecar = JSON.parse( - fs.readFileSync(path.join(dir, `${WASM_FILE}.json`), 'utf8'), - ) as Sidecar; - const bytes = new Uint8Array(fs.readFileSync(path.join(dir, sidecar.file))); - if (bytes.byteLength !== sidecar.size) { - throw new Error(`${sidecar.file}: size ${bytes.byteLength} != sidecar ${sidecar.size}`); - } - const hash = sha256(bytes); - if (hash !== sidecar.sha256) - throw new Error(`${sidecar.file}: sha256 ${hash} != sidecar ${sidecar.sha256}`); - const sig = verifyMinisign( - bytes, - fs.readFileSync(path.join(dir, sidecar.minisig), 'utf8'), - publicKey, - ); - if (sig.file !== sidecar.file) - throw new Error(`signature is for ${sig.file}, not ${sidecar.file}`); - return { sidecar, sig }; -} - -async function get(url: string): Promise<Uint8Array<ArrayBuffer>> { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); - return new Uint8Array(await res.arrayBuffer()); -} - -async function gh<T>(apiPath: string): Promise<T> { - const headers: Record<string, string> = { accept: 'application/vnd.github+json' }; - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; - if (token) headers.authorization = `Bearer ${token}`; - const res = await fetch(`https://api.github.com/repos/${REPO}/${apiPath}`, { headers }); - if (!res.ok) throw new Error(`GitHub API ${apiPath}: ${res.status}`); - return (await res.json()) as T; -} - -interface GitRef { - object: { type: string; sha: string }; -} - -async function tipCommit(): Promise<string> { - const ref = await gh<GitRef>('git/ref/tags/tip'); - if (ref.object.type === 'commit') return ref.object.sha; - return (await gh<GitRef>(`git/tags/${ref.object.sha}`)).object.sha; -} - -async function update(commit: string): Promise<void> { - if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error('--commit needs a full 40-character sha'); - const tipBefore = await tipCommit(); - if (tipBefore !== commit) { - throw new Error( - `${commit} is not the current tip (${tipBefore}); its .minisig is only published while it is tip`, - ); - } - const url = `${R2}/${commit}/${WASM_FILE}`; - const bytes = await get(url); - const hash = sha256(bytes); - const ghBytes = await get(`${GH_TIP}/${WASM_FILE}`); - const minisig = new TextDecoder().decode(await get(`${GH_TIP}/${WASM_FILE}.minisig`)); - const license = await get(`https://raw.githubusercontent.com/${REPO}/${commit}/LICENSE`); - const tipAfter = await tipCommit(); - if (tipAfter !== commit) throw new Error(`tip moved to ${tipAfter} during the download; rerun`); - if (sha256(ghBytes) !== hash) { - throw new Error(`GitHub tip asset sha256 ${sha256(ghBytes)} != ${url} ${hash}; rerun later`); - } - const release = await gh<{ assets?: { name: string; digest?: string }[] }>('releases/tags/tip'); - const digest = release.assets?.find((a) => a.name === WASM_FILE)?.digest?.replace(/^sha256:/, ''); - if (digest && digest !== hash) throw new Error(`GitHub asset digest ${digest} != ${hash}`); - - const sig = verifyMinisign(bytes, minisig, GHOSTTY_MINISIGN_PUBKEY); - if (sig.file !== WASM_FILE) throw new Error(`signature is for ${sig.file}, not ${WASM_FILE}`); - const wasm = await inspectWasm(bytes); - if (wasm.buildInfo.optimize !== 'ReleaseSmall') { - throw new Error(`${WASM_FILE} is ${wasm.buildInfo.optimize}, not ReleaseSmall`); - } - const commitInfo = await gh<{ commit?: { committer?: { date?: string } } }>( - `commits/${commit}`, - ).catch(() => undefined); - - const sidecar: Sidecar = { - repo: `https://github.com/${REPO}`, - commit, - commitDate: commitInfo?.commit?.committer?.date, - url, - file: WASM_FILE, - variant: 'ReleaseSmall', - size: bytes.byteLength, - sha256: hash, - minisig: `${WASM_FILE}.minisig`, - minisigSource: `${GH_TIP}/${WASM_FILE}.minisig (tip == ${commit})`, - publicKey: GHOSTTY_MINISIGN_PUBKEY, - signature: { - keyId: sig.keyId, - algorithm: sig.algorithm, - trustedComment: sig.trustedComment, - signedAt: sig.timestamp ? new Date(sig.timestamp * 1000).toISOString() : undefined, - }, - verifiedAt: new Date().toISOString(), - license: LICENSE_NOTE, - wasm, - }; - fs.mkdirSync(VENDOR_DIR, { recursive: true }); - fs.writeFileSync(path.join(VENDOR_DIR, WASM_FILE), bytes); - fs.writeFileSync(path.join(VENDOR_DIR, `${WASM_FILE}.minisig`), minisig); - fs.writeFileSync(path.join(VENDOR_DIR, 'LICENSE'), license); - fs.writeFileSync( - path.join(VENDOR_DIR, `${WASM_FILE}.json`), - `${JSON.stringify(sidecar, null, 2)}\n`, - ); - console.log(`wrote ${WASM_FILE}{,.minisig,.json} and LICENSE for ${commit} to ${VENDOR_DIR}`); - console.log('Update PROVENANCE.md if needed, then re-run the binding tests (ADR 041).'); -} - -async function verify(): Promise<void> { - const { sidecar, sig } = verifyPinned(VENDOR_DIR, GHOSTTY_MINISIGN_PUBKEY); +/** Re-checks the committed pin offline and prints what it verified. */ +export async function verify(deps: CliDeps): Promise<void> { + const { sidecar, sig } = verifyPinned(deps.dir, GHOSTTY_MINISIGN_PUBKEY); if (sidecar.publicKey !== GHOSTTY_MINISIGN_PUBKEY) { throw new Error(`sidecar key ${sidecar.publicKey} != ${GHOSTTY_MINISIGN_PUBKEY}`); } const wasm = await inspectWasm( - new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))), + new Uint8Array(fs.readFileSync(path.join(deps.dir, sidecar.file))), ); - console.log( + deps.log( `OK ${sidecar.file} ${sidecar.sha256}, Ghostty ${sidecar.commit}, ${wasm.buildInfo.optimize}, ` + `signed by ${sig.keyId} (${sig.trustedComment})`, ); } -if (import.meta.main) { - const args = process.argv.slice(2); +/** Runs the CLI on `args` and returns its exit code: 0, or 1 after printing the error. */ +export async function runCli(args: string[], overrides: Partial<CliDeps> = {}): Promise<number> { + const deps: CliDeps = { + dir: VENDOR_DIR, + log: (line) => console.log(line), + error: (line) => console.error(line), + fetch: {}, + ...overrides, + }; const commitAt = args.indexOf('--commit'); try { - if (args.includes('--verify')) await verify(); - else if (commitAt >= 0 && args[commitAt + 1]) await update(args[commitAt + 1] as string); + if (args.includes('--verify')) await verify(deps); + else if (commitAt >= 0 && args[commitAt + 1]) await update(args[commitAt + 1] as string, deps); else throw new Error('usage: update-ghostty-vt.ts --commit <sha> | --verify'); + return 0; } catch (err) { - console.error(`error: ${(err as Error).message}`); - process.exit(1); + deps.error(`error: ${(err as Error).message}`); + return 1; } } + +if (import.meta.main) { + const code = await runCli(process.argv.slice(2)); + if (code !== 0) process.exit(code); +} From e6ed5f69b538df753d64bb73944f29c132e24ab3 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:04:03 -0400 Subject: [PATCH 221/295] test(scripts): the ghostty-vt pin library on fakes: minisign, inspectWasm, the sidecar, the release fetch and the CLI test-support/ gives a throwaway minisign signer, a hand-assembled wasm with the required exports and a fake fetch for Ghostty's release channels. With them every verifyMinisign and verifyPinned failure branch, inspectWasm on the vendored wasm (offline, equal to the sidecar's record) and on a Debug build, fetchRelease's request order, token, annotated tag and every refusal, and runCli's --verify, --commit and usage paths are tested. Fetching the committed release back through the fake rebuilds its sidecar byte for byte. --- scripts/ghostty-vt/inspect-wasm.test.ts | 42 ++++ scripts/ghostty-vt/minisign.test.ts | 112 ++++++++++ scripts/ghostty-vt/release-fetch.test.ts | 205 ++++++++++++++++++ scripts/ghostty-vt/sidecar.test.ts | 97 +++++++++ .../ghostty-vt/test-support/fake-release.ts | 77 +++++++ .../test-support/minisign-signer.ts | 50 +++++ scripts/ghostty-vt/test-support/tiny-wasm.ts | 46 ++++ scripts/update-ghostty-vt.test.ts | 131 +++++++++++ 8 files changed, 760 insertions(+) create mode 100644 scripts/ghostty-vt/inspect-wasm.test.ts create mode 100644 scripts/ghostty-vt/minisign.test.ts create mode 100644 scripts/ghostty-vt/release-fetch.test.ts create mode 100644 scripts/ghostty-vt/sidecar.test.ts create mode 100644 scripts/ghostty-vt/test-support/fake-release.ts create mode 100644 scripts/ghostty-vt/test-support/minisign-signer.ts create mode 100644 scripts/ghostty-vt/test-support/tiny-wasm.ts create mode 100644 scripts/update-ghostty-vt.test.ts diff --git a/scripts/ghostty-vt/inspect-wasm.test.ts b/scripts/ghostty-vt/inspect-wasm.test.ts new file mode 100644 index 0000000..f2b9e79 --- /dev/null +++ b/scripts/ghostty-vt/inspect-wasm.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { VENDOR_DIR } from '../update-ghostty-vt'; +import { inspectWasm } from './inspect-wasm'; +import type { Sidecar } from './sidecar'; +import { EMPTY_WASM, tinyWasm } from './test-support/tiny-wasm'; + +describe('inspectWasm', () => { + test('reads the vendored wasm as its sidecar records it, offline', async () => { + const sidecar = JSON.parse( + fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + ) as Sidecar; + const bytes = new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))); + expect(await inspectWasm(bytes)).toEqual(sidecar.wasm); + }); + + test('reports the build info of a module with every required export', async () => { + const info = await inspectWasm(tinyWasm(0)); + expect(info.exportCount).toBe(14); + expect(info.functionExports).toBe(12); + expect(info.nonFunctionExports).toEqual(['memory:memory', '__indirect_function_table:table']); + expect(info.imports).toEqual([]); + expect(info.initialMemoryBytes).toBe(65536); + expect(info.buildInfo).toEqual({ + version: '', + versionBuild: '', + optimize: 'Debug', + simd: false, + kittyGraphics: false, + tmuxControlMode: false, + }); + }); + + test.each([ + ['bytes that are not wasm', new Uint8Array([1, 2, 3]), 'wasm: WebAssembly.validate failed'], + ['a module without the exports', EMPTY_WASM, 'wasm: missing exports memory, __indirect'], + ['a failing ghostty_build_info', tinyWasm(1), 'wasm: ghostty_build_info(4) returned 1'], + ])('rejects %s', async (_name, bytes, message) => { + await expect(inspectWasm(bytes)).rejects.toThrow(message); + }); +}); diff --git a/scripts/ghostty-vt/minisign.test.ts b/scripts/ghostty-vt/minisign.test.ts new file mode 100644 index 0000000..5d081a9 --- /dev/null +++ b/scripts/ghostty-vt/minisign.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; +import { sha256, verifyMinisign } from './minisign'; +import { createMinisignSigner } from './test-support/minisign-signer'; + +const DATA = new TextEncoder().encode('ghostty'); +const COMMENT = 'timestamp:1700000000\tfile:ghostty-vt-small.wasm\thashed'; +const signer = createMinisignSigner(); +const minisig = signer.sign(DATA, COMMENT); +const lines = minisig.split('\n'); +const withLine = (index: number, text: string) => + lines.map((line, i) => (i === index ? text : line)).join('\n'); +const b64 = (...parts: Buffer[]) => Buffer.concat(parts).toString('base64'); + +describe('sha256', () => { + test('is the lowercase hex digest', () => { + expect(sha256(new TextEncoder().encode('abc'))).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + }); +}); + +describe('verifyMinisign accepts', () => { + test('a prehashed (ED) signature and reads the trusted comment', () => { + expect(verifyMinisign(DATA, minisig, signer.publicKey)).toEqual({ + keyId: '0807060504030201', + algorithm: 'ED', + trustedComment: COMMENT, + timestamp: 1700000000, + file: 'ghostty-vt-small.wasm', + }); + }); + + test('a legacy (Ed) signature over the data itself', () => { + expect(verifyMinisign(DATA, signer.sign(DATA, COMMENT, 'Ed'), signer.publicKey).algorithm).toBe( + 'Ed', + ); + }); + + test('CRLF line endings and a comment without timestamp or file', () => { + const sig = signer.sign(DATA, 'no fields').replace(/\n/g, '\r\n'); + const result = verifyMinisign(DATA, sig, signer.publicKey); + expect(result.timestamp).toBeUndefined(); + expect(result.file).toBeUndefined(); + }); +}); + +describe('verifyMinisign rejects', () => { + const pk = Buffer.from(signer.publicKey, 'base64'); + const sig = Buffer.from(lines[1] as string, 'base64'); + const other = createMinisignSigner(Buffer.from([9, 9, 9, 9, 9, 9, 9, 9])); + + test.each([ + ['a short public key', minisig, pk.subarray(0, 41).toString('base64'), 'bad public key'], + [ + 'a public key of another algorithm', + minisig, + b64(Buffer.from('XX'), pk.subarray(2)), + 'bad public key', + ], + [ + 'a missing untrusted comment', + withLine(0, 'comment'), + signer.publicKey, + 'missing untrusted comment', + ], + [ + 'a short signature', + withLine(1, sig.subarray(0, 70).toString('base64')), + signer.publicKey, + 'signature is 70 bytes, want 74', + ], + [ + 'an unknown algorithm', + withLine(1, b64(Buffer.from('Xx'), sig.subarray(2))), + signer.publicKey, + 'unknown algorithm Xx', + ], + [ + 'a missing trusted comment', + withLine(2, 'comment'), + signer.publicKey, + 'missing trusted comment', + ], + ['a short global signature', withLine(3, 'AAAA'), signer.publicKey, 'bad global signature'], + [ + 'another key id', + minisig, + other.publicKey, + 'signed by key 0807060504030201, expected 0909090909090909', + ], + [ + 'a signature from another key with the same id', + createMinisignSigner().sign(DATA, COMMENT), + signer.publicKey, + 'file signature is invalid', + ], + [ + 'an edited trusted comment', + withLine(2, 'trusted comment: edited'), + signer.publicKey, + 'trusted comment signature is invalid', + ], + ])('%s', (_name, text, key, message) => { + expect(() => verifyMinisign(DATA, text, key)).toThrow(message); + }); + + test('changed data', () => { + expect(() => + verifyMinisign(new TextEncoder().encode('ghostty!'), minisig, signer.publicKey), + ).toThrow('file signature is invalid'); + }); +}); diff --git a/scripts/ghostty-vt/release-fetch.test.ts b/scripts/ghostty-vt/release-fetch.test.ts new file mode 100644 index 0000000..37fdcae --- /dev/null +++ b/scripts/ghostty-vt/release-fetch.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { VENDOR_DIR } from '../update-ghostty-vt'; +import { fetchRelease, type ReleaseFetchDeps, releaseFetchDeps, tipCommit } from './release-fetch'; +import { GHOSTTY_MINISIGN_PUBKEY, type Sidecar, WASM_FILE } from './sidecar'; +import { type FakeRelease, fakeReleaseFetch } from './test-support/fake-release'; +import { createMinisignSigner } from './test-support/minisign-signer'; +import { tinyWasm } from './test-support/tiny-wasm'; + +// The committed pin, served back by the fake: fetching it must rebuild its sidecar exactly. +const SIDECAR_TEXT = fs.readFileSync(path.join(VENDOR_DIR, `${WASM_FILE}.json`), 'utf8'); +const SIDECAR = JSON.parse(SIDECAR_TEXT) as Sidecar; +const WASM = new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, WASM_FILE))); +const MINISIG = fs.readFileSync(path.join(VENDOR_DIR, `${WASM_FILE}.minisig`), 'utf8'); +const LICENSE = fs.readFileSync(path.join(VENDOR_DIR, 'LICENSE'), 'utf8'); +const COMMIT = SIDECAR.commit; +const OTHER = 'f'.repeat(40); + +const release = (overrides: Partial<FakeRelease> = {}): FakeRelease => ({ + commit: COMMIT, + wasm: WASM, + minisig: MINISIG, + license: LICENSE, + commitDate: SIDECAR.commitDate, + ...overrides, +}); + +function depsFor(served: FakeRelease, overrides: Partial<ReleaseFetchDeps> = {}) { + const fake = fakeReleaseFetch(served); + const deps = releaseFetchDeps({ + fetch: fake.fetch, + token: undefined, + now: () => new Date(SIDECAR.verifiedAt), + ...overrides, + }); + return { deps, requests: fake.requests }; +} + +describe('fetchRelease', () => { + test('rebuilds the committed sidecar from the same release', async () => { + const { deps } = depsFor(release()); + const pin = await fetchRelease(COMMIT, deps); + expect(`${JSON.stringify(pin.sidecar, null, 2)}\n`).toBe(SIDECAR_TEXT); + expect(pin.bytes).toEqual(WASM); + expect(pin.minisig).toBe(MINISIG); + expect(new TextDecoder().decode(pin.license)).toBe(LICENSE); + }); + + test('checks tip before and after the downloads, then the release and the commit', async () => { + const { deps, requests } = depsFor(release()); + await fetchRelease(COMMIT, deps); + const api = 'https://api.github.com/repos/ghostty-org/ghostty/'; + const tip = 'https://github.com/ghostty-org/ghostty/releases/download/tip/'; + expect(requests.map((r) => r.url)).toEqual([ + `${api}git/ref/tags/tip`, + `https://tip.files.ghostty.org/${COMMIT}/${WASM_FILE}`, + `${tip}${WASM_FILE}`, + `${tip}${WASM_FILE}.minisig`, + `https://raw.githubusercontent.com/ghostty-org/ghostty/${COMMIT}/LICENSE`, + `${api}git/ref/tags/tip`, + `${api}releases/tags/tip`, + `${api}commits/${COMMIT}`, + ]); + expect(requests[1]?.init).toEqual({ redirect: 'follow' }); + expect(requests[0]?.init).toEqual({ headers: { accept: 'application/vnd.github+json' } }); + }); + + test('sends the GitHub token to the API only', async () => { + const { deps, requests } = depsFor(release(), { token: 'secret' }); + await fetchRelease(COMMIT, deps); + const headers = requests[0]?.init?.headers as Record<string, string>; + expect(headers.authorization).toBe('Bearer secret'); + expect(requests[1]?.init).toEqual({ redirect: 'follow' }); + }); + + test('accepts a matching asset digest and leaves out a commit date it cannot get', async () => { + const { deps } = depsFor( + release({ digest: `sha256:${SIDECAR.sha256}`, commitDate: undefined }), + ); + const pin = await fetchRelease(COMMIT, deps); + expect(pin.sidecar.commitDate).toBeUndefined(); + }); + + test.each<[string, string, Partial<FakeRelease>, string]>([ + ['a short sha', 'abc', {}, '--commit needs a full 40-character sha'], + [ + 'a commit that is not tip', + COMMIT, + { tips: [OTHER] }, + `${COMMIT} is not the current tip (${OTHER})`, + ], + [ + 'tip moving during the download', + COMMIT, + { tips: [COMMIT, OTHER] }, + `tip moved to ${OTHER} during the download; rerun`, + ], + [ + 'a GitHub asset with other bytes', + COMMIT, + { tipWasm: new Uint8Array([1]) }, + 'GitHub tip asset sha256', + ], + [ + 'a GitHub asset digest that differs', + COMMIT, + { digest: 'sha256:beef' }, + `GitHub asset digest beef != ${SIDECAR.sha256}`, + ], + [ + 'a failed download', + COMMIT, + { failing: { [`https://tip.files.ghostty.org/${COMMIT}/${WASM_FILE}`]: 404 } }, + `GET https://tip.files.ghostty.org/${COMMIT}/${WASM_FILE}: 404`, + ], + [ + 'a failed API call', + COMMIT, + { failing: { 'https://api.github.com/repos/ghostty-org/ghostty/git/ref/tags/tip': 403 } }, + 'GitHub API git/ref/tags/tip: 403', + ], + [ + 'a signature by another key', + COMMIT, + { minisig: createMinisignSigner().sign(WASM, 'file:ghostty-vt-small.wasm') }, + 'minisign: signed by key', + ], + ])('rejects %s', async (_name, commit, overrides, message) => { + const { deps } = depsFor(release(overrides)); + await expect(fetchRelease(commit, deps)).rejects.toThrow(message); + }); + + test('rejects a signature for another file name', async () => { + const signer = createMinisignSigner(); + const { deps } = depsFor(release({ minisig: signer.sign(WASM, 'file:other.wasm') }), { + publicKey: signer.publicKey, + }); + await expect(fetchRelease(COMMIT, deps)).rejects.toThrow( + `signature is for other.wasm, not ${WASM_FILE}`, + ); + }); + + test('rejects a build that is not ReleaseSmall', async () => { + const signer = createMinisignSigner(); + const wasm = tinyWasm(0); + const { deps } = depsFor(release({ wasm, minisig: signer.sign(wasm, `file:${WASM_FILE}`) }), { + publicKey: signer.publicKey, + }); + await expect(fetchRelease(COMMIT, deps)).rejects.toThrow( + `${WASM_FILE} is Debug, not ReleaseSmall`, + ); + }); +}); + +describe('tipCommit', () => { + test('reads a lightweight tag', async () => { + expect(await tipCommit(depsFor(release()).deps)).toBe(COMMIT); + }); + + test('follows an annotated tag to its commit', async () => { + const { deps, requests } = depsFor(release({ annotated: true })); + expect(await tipCommit(deps)).toBe(COMMIT); + expect(requests.map((r) => r.url.split('/repos/ghostty-org/ghostty/')[1])).toEqual([ + 'git/ref/tags/tip', + `git/tags/tag-${COMMIT}`, + ]); + }); +}); + +describe('releaseFetchDeps', () => { + const saved = { GITHUB_TOKEN: process.env.GITHUB_TOKEN, GH_TOKEN: process.env.GH_TOKEN }; + afterEach(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + test("defaults to the network, Ghostty's key and the current time", () => { + const deps = releaseFetchDeps(); + expect(deps.publicKey).toBe(GHOSTTY_MINISIGN_PUBKEY); + expect(typeof deps.fetch).toBe('function'); + expect(Math.abs(deps.now().getTime() - Date.now())).toBeLessThan(5000); + }); + + test('takes the token from GITHUB_TOKEN, then GH_TOKEN', () => { + delete process.env.GITHUB_TOKEN; + process.env.GH_TOKEN = 'gh'; + expect(releaseFetchDeps().token).toBe('gh'); + process.env.GITHUB_TOKEN = 'github'; + expect(releaseFetchDeps().token).toBe('github'); + }); + + test('the default fetch is the global fetch', async () => { + const spy = spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok')); + try { + const res = await releaseFetchDeps().fetch('https://example.test/', { redirect: 'follow' }); + expect(await res.text()).toBe('ok'); + expect(spy).toHaveBeenCalledWith('https://example.test/', { redirect: 'follow' }); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/scripts/ghostty-vt/sidecar.test.ts b/scripts/ghostty-vt/sidecar.test.ts new file mode 100644 index 0000000..45a7b87 --- /dev/null +++ b/scripts/ghostty-vt/sidecar.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { sha256 } from './minisign'; +import { type PinnedRelease, type Sidecar, verifyPinned, WASM_FILE, writePin } from './sidecar'; +import { createMinisignSigner } from './test-support/minisign-signer'; + +const signer = createMinisignSigner(); +const BYTES = new TextEncoder().encode('not really wasm'); +const COMMENT = `timestamp:1\tfile:${WASM_FILE}\thashed`; + +function pin( + overrides: Partial<Sidecar> = {}, + minisig = signer.sign(BYTES, COMMENT), +): PinnedRelease { + return { + bytes: BYTES, + minisig, + license: new TextEncoder().encode('MIT License\n'), + sidecar: { + file: WASM_FILE, + size: BYTES.byteLength, + sha256: sha256(BYTES), + minisig: `${WASM_FILE}.minisig`, + publicKey: signer.publicKey, + ...overrides, + } as Sidecar, + }; +} + +let dirs: string[] = []; +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-pin-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe('writePin', () => { + test('writes the wasm, signature, licence and a pretty sidecar with a final newline', () => { + const dir = path.join(tmpDir(), 'nested', 'ghostty-vt'); + const written = pin(); + writePin(dir, written); + expect(fs.readdirSync(dir).sort()).toEqual([ + 'LICENSE', + WASM_FILE, + `${WASM_FILE}.json`, + `${WASM_FILE}.minisig`, + ]); + expect(new Uint8Array(fs.readFileSync(path.join(dir, WASM_FILE)))).toEqual(BYTES); + expect(fs.readFileSync(path.join(dir, `${WASM_FILE}.json`), 'utf8')).toBe( + `${JSON.stringify(written.sidecar, null, 2)}\n`, + ); + }); +}); + +describe('verifyPinned', () => { + test('returns the sidecar and the signature of a good pin', () => { + const dir = tmpDir(); + writePin(dir, pin()); + const { sidecar, sig } = verifyPinned(dir, signer.publicKey); + expect(sidecar.sha256).toBe(sha256(BYTES)); + expect(sig.file).toBe(WASM_FILE); + }); + + test.each([ + ['a size that does not match', pin({ size: 1 }), `${WASM_FILE}: size 15 != sidecar 1`], + [ + 'a sha256 that does not match', + pin({ sha256: 'beef' }), + `sha256 ${sha256(BYTES)} != sidecar beef`, + ], + [ + 'a signature for another file', + pin({}, signer.sign(BYTES, 'timestamp:1\tfile:other.wasm')), + `signature is for other.wasm, not ${WASM_FILE}`, + ], + [ + 'a signature over other bytes', + pin({}, signer.sign(new Uint8Array([1]), COMMENT)), + 'minisign: file signature is invalid', + ], + ])('rejects %s', (_name, written, message) => { + const dir = tmpDir(); + writePin(dir, written); + expect(() => verifyPinned(dir, signer.publicKey)).toThrow(message); + }); + + test('rejects a missing sidecar', () => { + expect(() => verifyPinned(tmpDir(), signer.publicKey)).toThrow('ENOENT'); + }); +}); diff --git a/scripts/ghostty-vt/test-support/fake-release.ts b/scripts/ghostty-vt/test-support/fake-release.ts new file mode 100644 index 0000000..fddd5de --- /dev/null +++ b/scripts/ghostty-vt/test-support/fake-release.ts @@ -0,0 +1,77 @@ +// Ghostty's release channels on a fake `fetch` (test fake, ADR 064): the GitHub API's tip +// tag, release and commit, the commit-addressed wasm, the `tip` assets and the licence, +// each overridable, with every request recorded in order. + +import type { FetchFn } from '../release-fetch'; + +const API = 'https://api.github.com/repos/ghostty-org/ghostty/'; +const R2 = 'https://tip.files.ghostty.org/'; +const GH_TIP = 'https://github.com/ghostty-org/ghostty/releases/download/tip/'; +const RAW = 'https://raw.githubusercontent.com/ghostty-org/ghostty/'; + +/** One release as the fake serves it. A `tips` list is served one entry per tip lookup. */ +export interface FakeRelease { + commit: string; + wasm: Uint8Array<ArrayBuffer>; + minisig: string; + /** The `tip` asset's bytes; defaults to `wasm`. */ + tipWasm?: Uint8Array<ArrayBuffer>; + license?: string; + /** Successive answers to the tip lookup; the last one repeats. Defaults to `[commit]`. */ + tips?: string[]; + /** Serve `tip` as an annotated tag that points at the commit. */ + annotated?: boolean; + /** The `releases/tags/tip` asset digest, if any. */ + digest?: string; + /** The commit date, or `undefined` to answer the commits API with 500. */ + commitDate?: string; + /** URLs answered with this status instead. */ + failing?: Record<string, number>; +} + +/** A recorded request: its URL and the init the code passed. */ +export interface FakeRequest { + url: string; + init?: RequestInit; +} + +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + +/** A fake `fetch` serving `release`, and the requests it saw. */ +export function fakeReleaseFetch(release: FakeRelease): { + fetch: FetchFn; + requests: FakeRequest[]; +} { + const requests: FakeRequest[] = []; + const tips = [...(release.tips ?? [release.commit])]; + const nextTip = () => (tips.length > 1 ? (tips.shift() as string) : (tips[0] as string)); + const routes: Record<string, () => Response> = { + [`${API}git/ref/tags/tip`]: () => { + const sha = nextTip(); + return json({ + object: release.annotated ? { type: 'tag', sha: `tag-${sha}` } : { type: 'commit', sha }, + }); + }, + [`${API}releases/tags/tip`]: () => + json({ + assets: [{ name: 'other.wasm' }, { name: 'ghostty-vt-small.wasm', digest: release.digest }], + }), + [`${API}commits/${release.commit}`]: () => + release.commitDate === undefined + ? new Response('', { status: 500 }) + : json({ commit: { committer: { date: release.commitDate } } }), + [`${R2}${release.commit}/ghostty-vt-small.wasm`]: () => new Response(release.wasm), + [`${GH_TIP}ghostty-vt-small.wasm`]: () => new Response(release.tipWasm ?? release.wasm), + [`${GH_TIP}ghostty-vt-small.wasm.minisig`]: () => new Response(release.minisig), + [`${RAW}${release.commit}/LICENSE`]: () => new Response(release.license ?? 'MIT License\n'), + }; + const fetch: FetchFn = async (url, init) => { + requests.push({ url, init }); + const failed = release.failing?.[url]; + if (failed) return new Response('', { status: failed }); + const tag = /git\/tags\/tag-(\w+)$/.exec(url)?.[1]; + if (tag) return json({ object: { type: 'commit', sha: tag } }); + return routes[url]?.() ?? new Response('', { status: 404 }); + }; + return { fetch, requests }; +} diff --git a/scripts/ghostty-vt/test-support/minisign-signer.ts b/scripts/ghostty-vt/test-support/minisign-signer.ts new file mode 100644 index 0000000..149a9c8 --- /dev/null +++ b/scripts/ghostty-vt/test-support/minisign-signer.ts @@ -0,0 +1,50 @@ +// A throwaway minisign key that signs like `minisign -S` (test fake, ADR 064), so the +// verifier's branches and a pin with another file name or build are tested without +// Ghostty's private key. + +import { createHash, generateKeyPairSync, type KeyObject, sign } from 'node:crypto'; + +/** A generated Ed25519 key with its minisign public key string and key id. */ +export interface MinisignSigner { + /** The public key as minisign prints it: base64 of `Ed`, the key id and the raw key. */ + publicKey: string; + /** Eight key id bytes, little-endian as they sit in the key and the signature. */ + keyId: Buffer; + /** Signs `data` and returns the `.minisig` text with `trustedComment`. */ + sign(data: Uint8Array, trustedComment: string, algorithm?: 'Ed' | 'ED'): string; +} + +function rawPublicKey(key: KeyObject): Buffer { + const der = key.export({ format: 'der', type: 'spki' }); + return der.subarray(der.length - 32); +} + +/** Generates a fresh signer; `keyId` defaults to eight bytes counting up from 1. */ +export function createMinisignSigner( + keyId = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), +): MinisignSigner { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pk = Buffer.concat([Buffer.from('Ed', 'latin1'), keyId, rawPublicKey(publicKey)]); + return { + publicKey: pk.toString('base64'), + keyId, + sign(data, trustedComment, algorithm = 'ED') { + const message = + algorithm === 'ED' ? createHash('blake2b512').update(data).digest() : Buffer.from(data); + const sigBytes = sign(null, message, privateKey); + const global = sign( + null, + Buffer.concat([sigBytes, Buffer.from(trustedComment, 'utf8')]), + privateKey, + ); + const sig = Buffer.concat([Buffer.from(algorithm, 'latin1'), keyId, sigBytes]); + return [ + 'untrusted comment: signature from a test key', + sig.toString('base64'), + `trusted comment: ${trustedComment}`, + global.toString('base64'), + '', + ].join('\n'); + }, + }; +} diff --git a/scripts/ghostty-vt/test-support/tiny-wasm.ts b/scripts/ghostty-vt/test-support/tiny-wasm.ts new file mode 100644 index 0000000..ba929d8 --- /dev/null +++ b/scripts/ghostty-vt/test-support/tiny-wasm.ts @@ -0,0 +1,46 @@ +// A hand-assembled wasm module with the exports the binding needs (test fake, ADR 064): +// every function returns one constant, so `ghostty_build_info` can succeed with an empty +// Debug build or fail with a nonzero code, without the real libghostty-vt. + +import { REQUIRED_EXPORTS } from '../inspect-wasm'; + +const MAGIC_AND_VERSION = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; +const FUNC = 0x00; +const TABLE = 0x01; +const MEMORY = 0x02; + +function section(id: number, body: number[]): number[] { + return [id, body.length, ...body]; +} + +function exportEntry(name: string): number[] { + const kind = name === 'memory' ? MEMORY : name === '__indirect_function_table' ? TABLE : FUNC; + const bytes = [...Buffer.from(name, 'utf8')]; + return [bytes.length, ...bytes, kind, 0]; +} + +/** + * A module whose one function, `(i32, i32) -> i32`, returns `result` (0..63), exported + * under every required function name, with a one-page memory and an empty table. + */ +export function tinyWasm(result: number): Uint8Array<ArrayBuffer> { + const exports = REQUIRED_EXPORTS.flatMap(exportEntry); + const exportBody = [REQUIRED_EXPORTS.length, ...exports]; + const bytes = [ + ...MAGIC_AND_VERSION, + ...section(1, [1, 0x60, 2, 0x7f, 0x7f, 1, 0x7f]), + ...section(3, [1, 0]), + ...section(4, [1, 0x70, 0, 0]), + ...section(5, [1, 0, 1]), + // The export section is over 127 bytes, so its size is a two-byte LEB128. + 7, + (exportBody.length & 0x7f) | 0x80, + exportBody.length >> 7, + ...exportBody, + ...section(10, [1, 4, 0, 0x41, result, 0x0b]), + ]; + return new Uint8Array(bytes); +} + +/** The smallest valid module: no exports at all. */ +export const EMPTY_WASM = new Uint8Array([...MAGIC_AND_VERSION]); diff --git a/scripts/update-ghostty-vt.test.ts b/scripts/update-ghostty-vt.test.ts new file mode 100644 index 0000000..458aade --- /dev/null +++ b/scripts/update-ghostty-vt.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Sidecar } from './ghostty-vt/sidecar'; +import { fakeReleaseFetch } from './ghostty-vt/test-support/fake-release'; +import * as cli from './update-ghostty-vt'; +import { type CliDeps, runCli, VENDOR_DIR, WASM_FILE } from './update-ghostty-vt'; + +const FILES = ['LICENSE', WASM_FILE, `${WASM_FILE}.json`, `${WASM_FILE}.minisig`]; +const SIDECAR = JSON.parse( + fs.readFileSync(path.join(VENDOR_DIR, `${WASM_FILE}.json`), 'utf8'), +) as Sidecar; + +let dirs: string[] = []; +function tmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-vt-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +/** Runs the CLI with its output captured. */ +async function run(args: string[], overrides: Partial<CliDeps> = {}) { + const out: string[] = []; + const err: string[] = []; + const code = await runCli(args, { + log: (line) => out.push(line), + error: (line) => err.push(line), + ...overrides, + }); + return { code, out, err }; +} + +describe('update-ghostty-vt.ts', () => { + test('--verify passes offline on the committed pin', async () => { + const { code, out, err } = await run(['--verify']); + expect({ code, err }).toEqual({ code: 0, err: [] }); + expect(out).toEqual([ + `OK ${WASM_FILE} ${SIDECAR.sha256}, Ghostty ${SIDECAR.commit}, ReleaseSmall, ` + + `signed by F5787DDB42320225 (${SIDECAR.signature.trustedComment})`, + ]); + }); + + test('--verify refuses a sidecar that names another key', async () => { + const dir = tmpDir(); + for (const name of FILES) fs.copyFileSync(path.join(VENDOR_DIR, name), path.join(dir, name)); + const edited = { ...SIDECAR, publicKey: 'other' }; + fs.writeFileSync(path.join(dir, `${WASM_FILE}.json`), JSON.stringify(edited)); + const { code, err } = await run(['--verify'], { dir }); + expect(code).toBe(1); + expect(err).toEqual([`error: sidecar key other != ${cli.GHOSTTY_MINISIGN_PUBKEY}`]); + }); + + test('--commit fetches, writes the pin into the dir and says what to do next', async () => { + const dir = path.join(tmpDir(), 'ghostty-vt'); + const fake = fakeReleaseFetch({ + commit: SIDECAR.commit, + wasm: new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, WASM_FILE))), + minisig: fs.readFileSync(path.join(VENDOR_DIR, `${WASM_FILE}.minisig`), 'utf8'), + license: fs.readFileSync(path.join(VENDOR_DIR, 'LICENSE'), 'utf8'), + commitDate: SIDECAR.commitDate, + }); + const now = () => new Date(SIDECAR.verifiedAt); + const { code, out } = await run(['--commit', SIDECAR.commit], { + dir, + fetch: { fetch: fake.fetch, now }, + }); + expect(code).toBe(0); + expect(out).toEqual([ + `wrote ${WASM_FILE}{,.minisig,.json} and LICENSE for ${SIDECAR.commit} to ${dir}`, + 'Update PROVENANCE.md if needed, then re-run the binding tests (ADR 041).', + ]); + for (const name of FILES) { + expect(fs.readFileSync(path.join(dir, name))).toEqual( + fs.readFileSync(path.join(VENDOR_DIR, name)), + ); + } + expect((await run(['--verify'], { dir })).code).toBe(0); + }); + + test.each([[[]], [['--commit']], [['--other']]])('prints the usage for %j', async (args) => { + const { code, out, err } = await run(args); + expect({ code, out, err }).toEqual({ + code: 1, + out: [], + err: ['error: usage: update-ghostty-vt.ts --commit <sha> | --verify'], + }); + }); + + test('prints to the console by default', async () => { + const log = spyOn(console, 'log').mockImplementation(() => {}); + const error = spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await runCli(['--verify'])).toBe(0); + expect(await runCli([])).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith( + 'error: usage: update-ghostty-vt.ts --commit <sha> | --verify', + ); + } finally { + log.mockRestore(); + error.mockRestore(); + } + }); + + test.each([ + [['--verify'], 0, `OK ${WASM_FILE} `], + [[], 1, ''], + ])('as a process, %j exits %d', (args, exitCode, stdout) => { + const proc = Bun.spawnSync([ + process.execPath, + path.join(import.meta.dir, 'update-ghostty-vt.ts'), + ...args, + ]); + expect(proc.exitCode).toBe(exitCode); + expect(proc.stdout.toString()).toStartWith(stdout); + }); + + test('re-exports what the vendor test imports', () => { + expect(typeof cli.verifyMinisign).toBe('function'); + expect(typeof cli.verifyPinned).toBe('function'); + expect(typeof cli.inspectWasm).toBe('function'); + expect(typeof cli.sha256).toBe('function'); + expect(VENDOR_DIR.endsWith(path.join('src', 'client', 'vendor', 'ghostty-vt'))).toBe(true); + }); +}); From e91edddf46abcadb70c9c7a99e9398e486228d02 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:05:13 -0400 Subject: [PATCH 222/295] refactor(scripts): headers and tested pure parts for check-pkg-manager and clean-pkg-scripts check-pkg-manager.ts gets foreignPackageManager (the user agent to a manager name), foreignPackageManagerMessage and checkPackageManager; clean-pkg-scripts.ts gets stripPackageJson and cleanPkgScripts(command, dir, error), which return the exit code. Each runs only under import.meta.main, with the same messages, files and exit codes as before. Their tests cover the detection table, the strip transform, strip and restore on a tmp dir, the leftover-backup refusal, the restore warning, the usage, and both scripts as processes. --- scripts/check-pkg-manager.test.ts | 60 ++++++++++++++++ scripts/check-pkg-manager.ts | 35 ++++++++-- scripts/clean-pkg-scripts.test.ts | 111 ++++++++++++++++++++++++++++++ scripts/clean-pkg-scripts.ts | 77 ++++++++++++++------- 4 files changed, 252 insertions(+), 31 deletions(-) create mode 100644 scripts/check-pkg-manager.test.ts create mode 100644 scripts/clean-pkg-scripts.test.ts diff --git a/scripts/check-pkg-manager.test.ts b/scripts/check-pkg-manager.test.ts new file mode 100644 index 0000000..80ee2da --- /dev/null +++ b/scripts/check-pkg-manager.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { + checkPackageManager, + foreignPackageManager, + foreignPackageManagerMessage, +} from './check-pkg-manager'; + +describe('foreignPackageManager', () => { + test.each([ + ['', undefined], + ['bun/1.3.11 npm/? node/v24.3.0 darwin arm64', undefined], + ['npm/10.9.2 node/v22.12.0 darwin arm64 workspaces/false', 'npm'], + ['pnpm/9.15.0 npm/? node/v22.12.0 darwin arm64', 'pnpm'], + ['yarn/1.22.22 npm/? node/v22.12.0 darwin arm64', 'yarn'], + ['/odd', 'unknown'], + ])('%j is %p', (agent, expected) => { + expect(foreignPackageManager(agent)).toBe(expected); + }); +}); + +describe('foreignPackageManagerMessage', () => { + test('names the manager it saw and asks for bun install, in red', () => { + expect(foreignPackageManagerMessage('npm')).toBe( + '\n\x1b[31m ERROR: This project uses Bun. Run `bun install` instead.\x1b[0m\n\n' + + ' Detected: npm\n Run: bun install\n', + ); + }); +}); + +describe('checkPackageManager', () => { + test('passes Bun silently and fails npm with the message', () => { + const errors: string[] = []; + expect(checkPackageManager('bun/1.3.11', (m) => errors.push(m))).toBe(0); + expect(checkPackageManager('npm/10.9.2', (m) => errors.push(m))).toBe(1); + expect(errors).toEqual([foreignPackageManagerMessage('npm')]); + }); +}); + +describe('check-pkg-manager.ts as a process', () => { + const run = (agent: string | undefined) => { + const env = { ...process.env }; + if (agent === undefined) delete env.npm_config_user_agent; + else env.npm_config_user_agent = agent; + return Bun.spawnSync([process.execPath, path.join(import.meta.dir, 'check-pkg-manager.ts')], { + env, + }); + }; + + test('passes under bun or with no user agent', () => { + expect(run('bun/1.3.11').exitCode).toBe(0); + expect(run(undefined).exitCode).toBe(0); + }); + + test('fails under npm with the message on stderr', () => { + const proc = run('npm/10.9.2 node/v22.12.0'); + expect(proc.exitCode).toBe(1); + expect(proc.stderr.toString()).toBe(`${foreignPackageManagerMessage('npm')}\n`); + }); +}); diff --git a/scripts/check-pkg-manager.ts b/scripts/check-pkg-manager.ts index 6ff8c2d..530fdfa 100644 --- a/scripts/check-pkg-manager.ts +++ b/scripts/check-pkg-manager.ts @@ -1,12 +1,35 @@ -const agent = process.env.npm_config_user_agent ?? ''; +// The preinstall guard: refuses an install by npm, pnpm or yarn, since the project is +// managed with Bun and its lockfile is bun.lock (entry, ADR 059). The detection and the +// message are pure functions, tested; the script runs them on npm_config_user_agent. -if (agent && !agent.startsWith('bun')) { - const detected = agent.split('/')[0] || 'unknown'; - console.error(` +/** The package manager the user agent names when it is not Bun, or undefined for Bun or none. */ +export function foreignPackageManager(agent: string): string | undefined { + if (!agent || agent.startsWith('bun')) return undefined; + return agent.split('/')[0] || 'unknown'; +} + +/** The error printed for a foreign package manager, with Bun's install command. */ +export function foreignPackageManagerMessage(detected: string): string { + return ` \x1b[31m ERROR: This project uses Bun. Run \`bun install\` instead.\x1b[0m Detected: ${detected} Run: bun install -`); - process.exit(1); +`; +} + +/** Prints the error and returns 1 for a foreign package manager, else returns 0. */ +export function checkPackageManager( + agent: string, + error: (message: string) => void = console.error, +): number { + const detected = foreignPackageManager(agent); + if (detected === undefined) return 0; + error(foreignPackageManagerMessage(detected)); + return 1; +} + +if (import.meta.main) { + const code = checkPackageManager(process.env.npm_config_user_agent ?? ''); + if (code !== 0) process.exit(code); } diff --git a/scripts/clean-pkg-scripts.test.ts b/scripts/clean-pkg-scripts.test.ts new file mode 100644 index 0000000..9087abe --- /dev/null +++ b/scripts/clean-pkg-scripts.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { cleanPkgScripts, stripPackageJson } from './clean-pkg-scripts'; + +const PKG = `${JSON.stringify( + { + name: 'webtty', + bin: { webtty: 'dist/cli/index.js' }, + scripts: { build: 'bun run scripts/build.ts' }, + dependencies: { ws: '8.21.3' }, + devDependencies: { typescript: '5.9.3' }, + }, + null, + 2, +)}\n`; + +let dirs: string[] = []; +function pkgDir(files: Record<string, string> = { 'package.json': PKG }): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-pkg-')); + dirs.push(dir); + for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text); + return dir; +} +const read = (dir: string, name: string) => fs.readFileSync(path.join(dir, name), 'utf8'); + +afterEach(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe('stripPackageJson', () => { + test('drops scripts and devDependencies and keeps the rest in order', () => { + expect(stripPackageJson(PKG)).toBe( + `${JSON.stringify( + { name: 'webtty', bin: { webtty: 'dist/cli/index.js' }, dependencies: { ws: '8.21.3' } }, + null, + 2, + )}\n`, + ); + }); + + test('leaves a package without them as it was', () => { + expect(stripPackageJson('{"name":"x"}')).toBe('{\n "name": "x"\n}\n'); + }); +}); + +describe('cleanPkgScripts', () => { + const errors: unknown[][] = []; + const error = (...args: unknown[]) => errors.push(args); + afterEach(() => { + errors.length = 0; + }); + + test('strip backs package.json up, then restore puts it back and removes the backup', () => { + const dir = pkgDir(); + expect(cleanPkgScripts('strip', dir, error)).toBe(0); + expect(read(dir, 'package.json')).toBe(stripPackageJson(PKG)); + expect(read(dir, 'package.json.bak')).toBe(PKG); + expect(cleanPkgScripts('restore', dir, error)).toBe(0); + expect(read(dir, 'package.json')).toBe(PKG); + expect(fs.existsSync(path.join(dir, 'package.json.bak'))).toBe(false); + expect(errors).toEqual([]); + }); + + test('strip refuses to overwrite a backup left by a failed publish', () => { + const dir = pkgDir({ 'package.json': PKG, 'package.json.bak': 'old' }); + expect(cleanPkgScripts('strip', dir, error)).toBe(1); + expect(read(dir, 'package.json')).toBe(PKG); + expect(read(dir, 'package.json.bak')).toBe('old'); + expect(errors).toEqual([ + [ + 'Error: package.json.bak already exists — a previous publish may have failed. Run restore first.', + ], + ]); + }); + + test('restore without a backup warns and still succeeds', () => { + const dir = pkgDir(); + expect(cleanPkgScripts('restore', dir, error)).toBe(0); + expect(read(dir, 'package.json')).toBe(PKG); + expect(errors[0]?.[0]).toBe( + 'Warning: failed to restore package.json from backup — restore manually if needed.', + ); + expect(errors[0]?.[1]).toBeInstanceOf(Error); + }); + + test.each([[undefined], ['pack']])('prints the usage for %p', (command) => { + expect(cleanPkgScripts(command, pkgDir(), error)).toBe(1); + expect(errors).toEqual([['Usage: clean-pkg-scripts.ts strip | restore']]); + }); + + test('runs in the current folder and reports to the console by default', () => { + const dir = pkgDir(); + const proc = Bun.spawnSync( + [process.execPath, path.join(import.meta.dir, 'clean-pkg-scripts.ts'), 'strip'], + { cwd: dir }, + ); + expect(proc.exitCode).toBe(0); + expect(read(dir, 'package.json')).toBe(stripPackageJson(PKG)); + const usage = Bun.spawnSync( + [process.execPath, path.join(import.meta.dir, 'clean-pkg-scripts.ts')], + { + cwd: dir, + }, + ); + expect(usage.exitCode).toBe(1); + expect(usage.stderr.toString()).toBe('Usage: clean-pkg-scripts.ts strip | restore\n'); + }); +}); diff --git a/scripts/clean-pkg-scripts.ts b/scripts/clean-pkg-scripts.ts index 0a9b7a4..78abcf0 100644 --- a/scripts/clean-pkg-scripts.ts +++ b/scripts/clean-pkg-scripts.ts @@ -1,37 +1,64 @@ #!/usr/bin/env bun +// Publish-time package.json cleanup: `strip` (prepack) backs package.json up and drops its +// scripts and devDependencies from the packed tarball, `restore` (postpack) puts it back +// (entry, ADR 059). The transform is a pure function and the commands take their folder, +// so both are tested on a tmp dir. import fs from 'node:fs'; +import path from 'node:path'; const PKG_PATH = 'package.json'; const BACKUP_PATH = 'package.json.bak'; -const command = process.argv[2]; -if (command === 'strip') { - if (fs.existsSync(BACKUP_PATH)) { - console.error( - 'Error: package.json.bak already exists — a previous publish may have failed. Run restore first.', - ); - process.exit(1); - } - const original = fs.readFileSync(PKG_PATH, 'utf8'); - fs.writeFileSync(BACKUP_PATH, original); +/** Where the commands report problems; console.error in the script. */ +export type ErrorFn = (...args: unknown[]) => void; - const pkg = JSON.parse(original); +/** package.json text without `scripts` and `devDependencies`, two-space indented. */ +export function stripPackageJson(text: string): string { + const pkg = JSON.parse(text); delete pkg.scripts; delete pkg.devDependencies; - fs.writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`); -} else if (command === 'restore') { - try { - const original = fs.readFileSync(BACKUP_PATH, 'utf8'); - fs.writeFileSync(PKG_PATH, original); - fs.unlinkSync(BACKUP_PATH); - } catch (err) { - console.error( - 'Warning: failed to restore package.json from backup — restore manually if needed.', - err, - ); + return `${JSON.stringify(pkg, null, 2)}\n`; +} + +/** Runs `strip` or `restore` in `dir` and returns the exit code. */ +export function cleanPkgScripts( + command: string | undefined, + dir = '.', + error: ErrorFn = console.error, +): number { + const pkgPath = path.join(dir, PKG_PATH); + const backupPath = path.join(dir, BACKUP_PATH); + if (command === 'strip') { + if (fs.existsSync(backupPath)) { + error( + 'Error: package.json.bak already exists — a previous publish may have failed. Run restore first.', + ); + return 1; + } + const original = fs.readFileSync(pkgPath, 'utf8'); + fs.writeFileSync(backupPath, original); + fs.writeFileSync(pkgPath, stripPackageJson(original)); + return 0; } -} else { - console.error('Usage: clean-pkg-scripts.ts strip | restore'); - process.exit(1); + if (command === 'restore') { + try { + const original = fs.readFileSync(backupPath, 'utf8'); + fs.writeFileSync(pkgPath, original); + fs.unlinkSync(backupPath); + } catch (err) { + error( + 'Warning: failed to restore package.json from backup — restore manually if needed.', + err, + ); + } + return 0; + } + error('Usage: clean-pkg-scripts.ts strip | restore'); + return 1; +} + +if (import.meta.main) { + const code = cleanPkgScripts(process.argv[2]); + if (code !== 0) process.exit(code); } From fbda7d3f5b82a263c58a3473bc355c2993f57493 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:05:20 -0400 Subject: [PATCH 223/295] docs(arch): scripts/ghostty-vt/ tests and test-support in the tree --- docs/specs/architecture.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index e7c89a1..05e1ecf 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -173,7 +173,8 @@ scripts/ check-coverage.ts per-layer coverage targets over bun's lcov report (run by test:coverage) check-pkg-manager.ts clean-pkg-scripts.ts package-manager guard and publish-time script cleanup (headers; pure parts tested) update-ghostty-vt.ts CLI only (--commit, --verify) - ghostty-vt/ minisign.ts, sidecar.ts, inspect-wasm.ts, release-fetch.ts (fetch injected) + ghostty-vt/ minisign.ts, sidecar.ts, inspect-wasm.ts, release-fetch.ts (fetch injected), each with its test + test-support/ minisign-signer.ts (a throwaway key), tiny-wasm.ts (the required exports), fake-release.ts (fake fetch) ``` ### The vendored layer From f54f4863a4161f35d86e55550d05b97acec22827 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:11:33 -0400 Subject: [PATCH 224/295] test(client): vendor tests and their fixtures move into vendor/tests/ The vendored layer's tests and PTY captures move out of src/client/vendor/ into vendor/tests/ (flat for now; ADR 061, ADR 064), so the vendor folder holds only the two vendored trees and their tests. Only relative paths change. The ratchet allowlist, the checker's fixture example, the update script's comment, the ghostty-vt PROVENANCE and the client SPEC's layout follow the new paths. --- docs/specs/client.md | 5 +++-- scripts/architecture/ratchet-allowlist.ts | 6 +++--- scripts/check-architecture.test.ts | 2 +- scripts/update-ghostty-vt.ts | 2 +- src/client/vendor/ghostty-vt/PROVENANCE.md | 2 +- src/client/vendor/{ => tests}/buffer.test.ts | 8 ++++---- src/client/vendor/{ => tests}/fit.test.ts | 4 ++-- src/client/vendor/{ => tests}/fixtures/less.bin | 0 src/client/vendor/{ => tests}/fixtures/ls.bin | 0 src/client/vendor/{ => tests}/fixtures/nvim.bin | 0 src/client/vendor/{ => tests}/fixtures/seq.bin | 0 src/client/vendor/{ => tests}/fixtures/sgr.bin | 0 src/client/vendor/{ => tests}/fixtures/top.bin | 0 src/client/vendor/{ => tests}/fixtures/uni.bin | 0 src/client/vendor/{ => tests}/fixtures/vim.bin | 0 src/client/vendor/{ => tests}/ghostty-vt.test.ts | 4 ++-- src/client/vendor/{ => tests}/glide.test.ts | 2 +- .../vendor/{ => tests}/input-handler.test.ts | 8 ++++---- src/client/vendor/{ => tests}/mouse-route.test.ts | 2 +- .../vendor/{ => tests}/official-replay.test.ts | 4 ++-- .../vendor/{ => tests}/official-terminal.test.ts | 6 +++--- src/client/vendor/{ => tests}/renderer.test.ts | 8 ++++---- src/client/vendor/{ => tests}/search.test.ts | 2 +- .../vendor/{ => tests}/selection-manager.test.ts | 10 +++++----- src/client/vendor/{ => tests}/terminal.test.ts | 14 +++++++------- src/client/vendor/{ => tests}/wheel.test.ts | 2 +- 26 files changed, 46 insertions(+), 45 deletions(-) rename src/client/vendor/{ => tests}/buffer.test.ts (88%) rename src/client/vendor/{ => tests}/fit.test.ts (94%) rename src/client/vendor/{ => tests}/fixtures/less.bin (100%) rename src/client/vendor/{ => tests}/fixtures/ls.bin (100%) rename src/client/vendor/{ => tests}/fixtures/nvim.bin (100%) rename src/client/vendor/{ => tests}/fixtures/seq.bin (100%) rename src/client/vendor/{ => tests}/fixtures/sgr.bin (100%) rename src/client/vendor/{ => tests}/fixtures/top.bin (100%) rename src/client/vendor/{ => tests}/fixtures/uni.bin (100%) rename src/client/vendor/{ => tests}/fixtures/vim.bin (100%) rename src/client/vendor/{ => tests}/ghostty-vt.test.ts (98%) rename src/client/vendor/{ => tests}/glide.test.ts (99%) rename src/client/vendor/{ => tests}/input-handler.test.ts (98%) rename src/client/vendor/{ => tests}/mouse-route.test.ts (98%) rename src/client/vendor/{ => tests}/official-replay.test.ts (99%) rename src/client/vendor/{ => tests}/official-terminal.test.ts (99%) rename src/client/vendor/{ => tests}/renderer.test.ts (99%) rename src/client/vendor/{ => tests}/search.test.ts (99%) rename src/client/vendor/{ => tests}/selection-manager.test.ts (98%) rename src/client/vendor/{ => tests}/terminal.test.ts (99%) rename src/client/vendor/{ => tests}/wheel.test.ts (99%) diff --git a/docs/specs/client.md b/docs/specs/client.md index a7b49c3..1a66137 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -52,10 +52,11 @@ src/client/ PROVENANCE.md (upstream pin, local changes, moves and splits); ghostty.ts holds every wasm call, the binding to the official wasm (ADR 042, ADR 047); fn-table.ts is the function-table trampoline borrowed from ghostty-vt-js (LICENSE.ghostty-vt-js) - fixtures/ ← PTY captures replayed in 1-byte and 512-byte writes by vendor/official-replay.test.ts ghostty-vt/ ← official ghostty-vt-small.wasm (Ghostty 05dcf887, ReleaseSmall) + .minisig + sidecar (commit, URL, size, sha256, signature, key, licence), Ghostty LICENSE, PROVENANCE.md; - checked offline by vendor/ghostty-vt.test.ts (size, sha256, minisign signature) + checked offline by vendor/tests/ghostty-vt.test.ts (size, sha256, minisign signature) + tests/ ← the vendored layer's tests (Biome-linted); fixtures/ holds PTY captures replayed in + 1-byte and 512-byte writes by vendor/tests/official-replay.test.ts scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step); --verify re-checks offline ``` diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 576f8aa..c60fd03 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -34,9 +34,9 @@ export const RATCHET_ALLOWLIST: Allowlist = { lines: 2147, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/official-terminal.test.ts': { lines: 1440, step: VENDOR_TESTS }, - 'src/client/vendor/renderer.test.ts': { lines: 1094, step: VENDOR_TESTS }, - 'src/client/vendor/terminal.test.ts': { lines: 1165, step: VENDOR_TESTS }, + 'src/client/vendor/tests/official-terminal.test.ts': { lines: 1440, step: VENDOR_TESTS }, + 'src/client/vendor/tests/renderer.test.ts': { lines: 1094, step: VENDOR_TESTS }, + 'src/client/vendor/tests/terminal.test.ts': { lines: 1165, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, }, diff --git a/scripts/check-architecture.test.ts b/scripts/check-architecture.test.ts index 9bf2628..e68fd4d 100644 --- a/scripts/check-architecture.test.ts +++ b/scripts/check-architecture.test.ts @@ -60,7 +60,7 @@ describe('readSourceTree', () => { 'src/b.ts': 'b', 'src/a/a.ts': 'a', 'src/pty/node-pty.d.ts': 'declare', - 'src/client/vendor/fixtures/ls.bin': 'bytes', + 'src/client/vendor/tests/fixtures/ls.bin': 'bytes', 'src/node_modules/x/index.ts': 'skipped', 'src/.hidden/x.ts': 'skipped', 'scripts/build.ts': 'build', diff --git a/scripts/update-ghostty-vt.ts b/scripts/update-ghostty-vt.ts index 4f14e11..395249d 100644 --- a/scripts/update-ghostty-vt.ts +++ b/scripts/update-ghostty-vt.ts @@ -11,7 +11,7 @@ import { inspectWasm } from './ghostty-vt/inspect-wasm'; import { fetchRelease, type ReleaseFetchDeps, releaseFetchDeps } from './ghostty-vt/release-fetch'; import { GHOSTTY_MINISIGN_PUBKEY, verifyPinned, WASM_FILE, writePin } from './ghostty-vt/sidecar'; -// The vendor test (src/client/vendor/ghostty-vt.test.ts) imports these from here. +// The vendor test (src/client/vendor/tests/ghostty-vt.test.ts) imports these from here. export type { MinisigResult } from './ghostty-vt/minisign'; export { sha256, verifyMinisign } from './ghostty-vt/minisign'; export type { Sidecar } from './ghostty-vt/sidecar'; diff --git a/src/client/vendor/ghostty-vt/PROVENANCE.md b/src/client/vendor/ghostty-vt/PROVENANCE.md index 526f375..56c44c9 100644 --- a/src/client/vendor/ghostty-vt/PROVENANCE.md +++ b/src/client/vendor/ghostty-vt/PROVENANCE.md @@ -6,6 +6,6 @@ Ghostty's official libghostty-vt wasm, ReleaseSmall build, unpatched and not edi - **`ghostty-vt-small.wasm.minisig`:** from the GitHub `tip` release while `tip` pointed at that commit, with the GitHub asset byte-identical to the commit-addressed one. It is Ghostty's minisign signature with the release key from Ghostty's `PACKAGING.md` (`RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV`, key id `F5787DDB42320225`). - **`ghostty-vt-small.wasm.json`:** the sidecar: commit, source URL, size, sha256, signature source, public key, the signature's key id and trusted comment, licence, and the wasm's exports and `ghostty_build_info`. -Checked offline by `src/client/vendor/ghostty-vt.test.ts` (size, sha256, signature, key) on every `bun test`, and by `bun scripts/update-ghostty-vt.ts --verify`. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar, and writes the licence to `dist/THIRD_PARTY_LICENSES`. +Checked offline by `src/client/vendor/tests/ghostty-vt.test.ts` (size, sha256, signature, key) on every `bun test`, and by `bun scripts/update-ghostty-vt.ts --verify`. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar, and writes the licence to `dist/THIRD_PARTY_LICENSES`. The wasm, signature, sidecar and `LICENSE` are written by `bun scripts/update-ghostty-vt.ts --commit <sha>`, the only step that uses the network, and are not edited by hand. A pin bump is a deliberate change: update the commit above and re-run the binding's tests (`official-terminal.test.ts`, `official-replay.test.ts`, ADR 041). diff --git a/src/client/vendor/buffer.test.ts b/src/client/vendor/tests/buffer.test.ts similarity index 88% rename from src/client/vendor/buffer.test.ts rename to src/client/vendor/tests/buffer.test.ts index bc0ade0..990a024 100644 --- a/src/client/vendor/buffer.test.ts +++ b/src/client/vendor/tests/buffer.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { BufferNamespace } from './ghostty-web/buffer'; -import { Ghostty } from './ghostty-web/ghostty'; -import type { Terminal } from './ghostty-web/terminal'; +import { BufferNamespace } from '../ghostty-web/buffer'; +import { Ghostty } from '../ghostty-web/ghostty'; +import type { Terminal } from '../ghostty-web/terminal'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'ghostty-vt/ghostty-vt-small.wasm', + '../ghostty-vt/ghostty-vt-small.wasm', ); // The buffer API is what the link providers and the kitty graphics read: the diff --git a/src/client/vendor/fit.test.ts b/src/client/vendor/tests/fit.test.ts similarity index 94% rename from src/client/vendor/fit.test.ts rename to src/client/vendor/tests/fit.test.ts index 3cc2783..58892a6 100644 --- a/src/client/vendor/fit.test.ts +++ b/src/client/vendor/tests/fit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { FitAddon, fitDimensions } from './ghostty-web/addons/fit'; -import type { ITerminalCore } from './ghostty-web/interfaces'; +import { FitAddon, fitDimensions } from '../ghostty-web/addons/fit'; +import type { ITerminalCore } from '../ghostty-web/interfaces'; const NONE = { top: 0, right: 0, bottom: 0, left: 0 }; const pad = (p: number) => ({ top: p, right: p, bottom: p, left: p }); diff --git a/src/client/vendor/fixtures/less.bin b/src/client/vendor/tests/fixtures/less.bin similarity index 100% rename from src/client/vendor/fixtures/less.bin rename to src/client/vendor/tests/fixtures/less.bin diff --git a/src/client/vendor/fixtures/ls.bin b/src/client/vendor/tests/fixtures/ls.bin similarity index 100% rename from src/client/vendor/fixtures/ls.bin rename to src/client/vendor/tests/fixtures/ls.bin diff --git a/src/client/vendor/fixtures/nvim.bin b/src/client/vendor/tests/fixtures/nvim.bin similarity index 100% rename from src/client/vendor/fixtures/nvim.bin rename to src/client/vendor/tests/fixtures/nvim.bin diff --git a/src/client/vendor/fixtures/seq.bin b/src/client/vendor/tests/fixtures/seq.bin similarity index 100% rename from src/client/vendor/fixtures/seq.bin rename to src/client/vendor/tests/fixtures/seq.bin diff --git a/src/client/vendor/fixtures/sgr.bin b/src/client/vendor/tests/fixtures/sgr.bin similarity index 100% rename from src/client/vendor/fixtures/sgr.bin rename to src/client/vendor/tests/fixtures/sgr.bin diff --git a/src/client/vendor/fixtures/top.bin b/src/client/vendor/tests/fixtures/top.bin similarity index 100% rename from src/client/vendor/fixtures/top.bin rename to src/client/vendor/tests/fixtures/top.bin diff --git a/src/client/vendor/fixtures/uni.bin b/src/client/vendor/tests/fixtures/uni.bin similarity index 100% rename from src/client/vendor/fixtures/uni.bin rename to src/client/vendor/tests/fixtures/uni.bin diff --git a/src/client/vendor/fixtures/vim.bin b/src/client/vendor/tests/fixtures/vim.bin similarity index 100% rename from src/client/vendor/fixtures/vim.bin rename to src/client/vendor/tests/fixtures/vim.bin diff --git a/src/client/vendor/ghostty-vt.test.ts b/src/client/vendor/tests/ghostty-vt.test.ts similarity index 98% rename from src/client/vendor/ghostty-vt.test.ts rename to src/client/vendor/tests/ghostty-vt.test.ts index eae6d16..1f22f84 100644 --- a/src/client/vendor/ghostty-vt.test.ts +++ b/src/client/vendor/tests/ghostty-vt.test.ts @@ -8,9 +8,9 @@ import { type Sidecar, verifyMinisign, verifyPinned, -} from '../../../scripts/update-ghostty-vt'; +} from '../../../../scripts/update-ghostty-vt'; -const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ghostty-vt'); +const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../ghostty-vt'); // Ghostty's minisign release key, copied from Ghostty's PACKAGING.md (ADR 042). Kept here // rather than imported so a changed key in the script or the sidecar fails this test. diff --git a/src/client/vendor/glide.test.ts b/src/client/vendor/tests/glide.test.ts similarity index 99% rename from src/client/vendor/glide.test.ts rename to src/client/vendor/tests/glide.test.ts index 27da142..c66041a 100644 --- a/src/client/vendor/glide.test.ts +++ b/src/client/vendor/tests/glide.test.ts @@ -7,7 +7,7 @@ import { SPRING_RELEASE_MS, springOmega, springStep, -} from './ghostty-web/glide'; +} from '../ghostty-web/glide'; describe('springStep', () => { test('approaches a target from rest without overshooting', () => { diff --git a/src/client/vendor/input-handler.test.ts b/src/client/vendor/tests/input-handler.test.ts similarity index 98% rename from src/client/vendor/input-handler.test.ts rename to src/client/vendor/tests/input-handler.test.ts index 62e3ac6..776e400 100644 --- a/src/client/vendor/input-handler.test.ts +++ b/src/client/vendor/tests/input-handler.test.ts @@ -1,19 +1,19 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Ghostty } from './ghostty-web/ghostty'; +import { Ghostty } from '../ghostty-web/ghostty'; import { InputHandler, type MouseEventReport, motionButton, mouseMods, plainKeySequence, -} from './ghostty-web/input-handler'; -import { Key, Mods } from './ghostty-web/types'; +} from '../ghostty-web/input-handler'; +import { Key, Mods } from '../ghostty-web/types'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'ghostty-vt/ghostty-vt-small.wasm', + '../ghostty-vt/ghostty-vt-small.wasm', ); describe('motionButton', () => { diff --git a/src/client/vendor/mouse-route.test.ts b/src/client/vendor/tests/mouse-route.test.ts similarity index 98% rename from src/client/vendor/mouse-route.test.ts rename to src/client/vendor/tests/mouse-route.test.ts index 16e10dc..c3dd2dc 100644 --- a/src/client/vendor/mouse-route.test.ts +++ b/src/client/vendor/tests/mouse-route.test.ts @@ -5,7 +5,7 @@ import { type RightClickAction, type RightClickBehavior, rightClickAction, -} from './ghostty-web/mouse-route'; +} from '../ghostty-web/mouse-route'; describe('pressToApp', () => { test('a press is the app’s only while it tracks the mouse and Shift is up', () => { diff --git a/src/client/vendor/official-replay.test.ts b/src/client/vendor/tests/official-replay.test.ts similarity index 99% rename from src/client/vendor/official-replay.test.ts rename to src/client/vendor/tests/official-replay.test.ts index 97e1897..afcf375 100644 --- a/src/client/vendor/official-replay.test.ts +++ b/src/client/vendor/tests/official-replay.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; +import { Ghostty, type GhosttyTerminal } from '../ghostty-web/ghostty'; // The official engine (ADR 042, ADR 047) replaying the same input two ways, cell // for cell. A terminal that was scrolled, paged and cached while the input arrived @@ -17,7 +17,7 @@ import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; // `seq 5000`, an SGR/256-colour/truecolor test and a Unicode test. const HERE = path.dirname(fileURLToPath(import.meta.url)); -const OFFICIAL_WASM = path.join(HERE, 'ghostty-vt/ghostty-vt-small.wasm'); +const OFFICIAL_WASM = path.join(HERE, '../ghostty-vt/ghostty-vt-small.wasm'); const FIXTURES = path.join(HERE, 'fixtures'); const COLS = 120; const ROWS = 40; diff --git a/src/client/vendor/official-terminal.test.ts b/src/client/vendor/tests/official-terminal.test.ts similarity index 99% rename from src/client/vendor/official-terminal.test.ts rename to src/client/vendor/tests/official-terminal.test.ts index 742d1ca..a9b00f0 100644 --- a/src/client/vendor/official-terminal.test.ts +++ b/src/client/vendor/tests/official-terminal.test.ts @@ -7,14 +7,14 @@ import { type GhosttyTerminal, type MouseInput, type OfficialTerminal, -} from './ghostty-web/ghostty'; -import { CellFlags, Key, KeyAction, KeyEncoderOption, Mods } from './ghostty-web/types'; +} from '../ghostty-web/ghostty'; +import { CellFlags, Key, KeyAction, KeyEncoderOption, Mods } from '../ghostty-web/types'; // The official libghostty-vt binding (ADR 042) on the vendored wasm, loaded from disk. const OFFICIAL_WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'ghostty-vt/ghostty-vt-small.wasm', + '../ghostty-vt/ghostty-vt-small.wasm', ); const FG = 0xd4d4d4; diff --git a/src/client/vendor/renderer.test.ts b/src/client/vendor/tests/renderer.test.ts similarity index 99% rename from src/client/vendor/renderer.test.ts rename to src/client/vendor/tests/renderer.test.ts index 7924848..4af1403 100644 --- a/src/client/vendor/renderer.test.ts +++ b/src/client/vendor/tests/renderer.test.ts @@ -1,19 +1,19 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Ghostty } from './ghostty-web/ghostty'; +import { Ghostty } from '../ghostty-web/ghostty'; import { CanvasRenderer, frameLines, REPAINT_CHUNK_ROWS, watchDevicePixelRatio, wholeDevicePixelRatio, -} from './ghostty-web/renderer'; -import type { GhosttyCell } from './ghostty-web/types'; +} from '../ghostty-web/renderer'; +import type { GhosttyCell } from '../ghostty-web/types'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'ghostty-vt/ghostty-vt-small.wasm', + '../ghostty-vt/ghostty-vt-small.wasm', ); describe('wholeDevicePixelRatio', () => { diff --git a/src/client/vendor/search.test.ts b/src/client/vendor/tests/search.test.ts similarity index 99% rename from src/client/vendor/search.test.ts rename to src/client/vendor/tests/search.test.ts index 51a5ea5..e83f6dc 100644 --- a/src/client/vendor/search.test.ts +++ b/src/client/vendor/tests/search.test.ts @@ -6,7 +6,7 @@ import { matchesInRows, spansKey, viewportYToShow, -} from './ghostty-web/search'; +} from '../ghostty-web/search'; // Find (ADR 055): highlights from libghostty-vt's newest-to-oldest matches, and the view. diff --git a/src/client/vendor/selection-manager.test.ts b/src/client/vendor/tests/selection-manager.test.ts similarity index 98% rename from src/client/vendor/selection-manager.test.ts rename to src/client/vendor/tests/selection-manager.test.ts index fda3445..dc67cbc 100644 --- a/src/client/vendor/selection-manager.test.ts +++ b/src/client/vendor/tests/selection-manager.test.ts @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Ghostty, type GhosttyTerminal } from './ghostty-web/ghostty'; -import type { CanvasRenderer } from './ghostty-web/renderer'; -import { SelectionManager } from './ghostty-web/selection-manager'; -import type { Terminal } from './ghostty-web/terminal'; +import { Ghostty, type GhosttyTerminal } from '../ghostty-web/ghostty'; +import type { CanvasRenderer } from '../ghostty-web/renderer'; +import { SelectionManager } from '../ghostty-web/selection-manager'; +import type { Terminal } from '../ghostty-web/terminal'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'ghostty-vt/ghostty-vt-small.wasm', + '../ghostty-vt/ghostty-vt-small.wasm', ); type Listener = (e: object) => void; diff --git a/src/client/vendor/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts similarity index 99% rename from src/client/vendor/terminal.test.ts rename to src/client/vendor/tests/terminal.test.ts index ccb70ad..1bee0c5 100644 --- a/src/client/vendor/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Ghostty, type TerminalEffects } from './ghostty-web/ghostty'; -import { InputHandler } from './ghostty-web/input-handler'; -import { LinkDetector } from './ghostty-web/link-detector'; -import { OSC8LinkProvider } from './ghostty-web/providers/osc8-link-provider'; +import { Ghostty, type TerminalEffects } from '../ghostty-web/ghostty'; +import { InputHandler } from '../ghostty-web/input-handler'; +import { LinkDetector } from '../ghostty-web/link-detector'; +import { OSC8LinkProvider } from '../ghostty-web/providers/osc8-link-provider'; import { cursorCellOffset, deviceCellPixels, @@ -12,12 +12,12 @@ import { mouseReportText, mouseSurface, Terminal, -} from './ghostty-web/terminal'; -import type { GhosttyTerminalConfig, ILink } from './ghostty-web/types'; +} from '../ghostty-web/terminal'; +import type { GhosttyTerminalConfig, ILink } from '../ghostty-web/types'; const WASM = path.join( path.dirname(fileURLToPath(import.meta.url)), - 'ghostty-vt/ghostty-vt-small.wasm', + '../ghostty-vt/ghostty-vt-small.wasm', ); describe('mouseCursorFor', () => { diff --git a/src/client/vendor/wheel.test.ts b/src/client/vendor/tests/wheel.test.ts similarity index 99% rename from src/client/vendor/wheel.test.ts rename to src/client/vendor/tests/wheel.test.ts index bf40c2b..ba344a1 100644 --- a/src/client/vendor/wheel.test.ts +++ b/src/client/vendor/tests/wheel.test.ts @@ -5,7 +5,7 @@ import { wheelArrowKey, wheelArrows, wheelTicks, -} from './ghostty-web/wheel'; +} from '../ghostty-web/wheel'; describe('wheelTicks', () => { const fresh = (): WheelAccumulator => ({ accum: 0, dir: 0 }); From 4426cc4feb56b6616e192055999d9c3df3bbb827 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:13:20 -0400 Subject: [PATCH 225/295] test(client): one wasm path and one compile for the vendor tests, and the barrel pinned vendor/tests/support/wasm.ts names the official wasm, the pinned ghostty-vt folder and the fixtures once, compiles the wasm on first use and builds each Ghostty on a fresh instance of that module (ADR 064). The engine tests take their Ghostty from loadGhostty() and the raw-export spies from instantiateWasm() instead of each file resolving and compiling the wasm itself; the integrity test reads GHOSTTY_VT_DIR. Assertions are unchanged. barrel.test.ts pins the barrel's runtime exports, so the splits behind the facades cannot drop one (ADR 061), and keeps Ghostty.load(path), the page's loader, under test. The allowlisted test sizes are lowered to their new line counts. --- docs/specs/client.md | 5 +- scripts/architecture/ratchet-allowlist.ts | 6 +-- src/client/vendor/tests/barrel.test.ts | 52 +++++++++++++++++++ src/client/vendor/tests/buffer.test.ts | 11 +--- src/client/vendor/tests/ghostty-vt.test.ts | 20 ++++--- src/client/vendor/tests/input-handler.test.ts | 17 ++---- .../vendor/tests/official-replay.test.ts | 11 ++-- .../vendor/tests/official-terminal.test.ts | 16 ++---- src/client/vendor/tests/renderer.test.ts | 21 +++----- .../vendor/tests/selection-manager.test.ts | 16 ++---- src/client/vendor/tests/support/wasm.ts | 42 +++++++++++++++ src/client/vendor/tests/terminal.test.ts | 32 +++++------- 12 files changed, 149 insertions(+), 100 deletions(-) create mode 100644 src/client/vendor/tests/barrel.test.ts create mode 100644 src/client/vendor/tests/support/wasm.ts diff --git a/docs/specs/client.md b/docs/specs/client.md index 1a66137..04934ed 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -55,8 +55,9 @@ src/client/ ghostty-vt/ ← official ghostty-vt-small.wasm (Ghostty 05dcf887, ReleaseSmall) + .minisig + sidecar (commit, URL, size, sha256, signature, key, licence), Ghostty LICENSE, PROVENANCE.md; checked offline by vendor/tests/ghostty-vt.test.ts (size, sha256, minisign signature) - tests/ ← the vendored layer's tests (Biome-linted); fixtures/ holds PTY captures replayed in - 1-byte and 512-byte writes by vendor/tests/official-replay.test.ts + tests/ ← the vendored layer's tests (Biome-linted); support/wasm.ts is the one wasm path and + compiles it once; fixtures/ holds PTY captures replayed in 1-byte and 512-byte writes + by vendor/tests/official-replay.test.ts scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step); --verify re-checks offline ``` diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index c60fd03..1b5654d 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -34,9 +34,9 @@ export const RATCHET_ALLOWLIST: Allowlist = { lines: 2147, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/official-terminal.test.ts': { lines: 1440, step: VENDOR_TESTS }, - 'src/client/vendor/tests/renderer.test.ts': { lines: 1094, step: VENDOR_TESTS }, - 'src/client/vendor/tests/terminal.test.ts': { lines: 1165, step: VENDOR_TESTS }, + 'src/client/vendor/tests/official-terminal.test.ts': { lines: 1432, step: VENDOR_TESTS }, + 'src/client/vendor/tests/renderer.test.ts': { lines: 1087, step: VENDOR_TESTS }, + 'src/client/vendor/tests/terminal.test.ts': { lines: 1159, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, }, diff --git a/src/client/vendor/tests/barrel.test.ts b/src/client/vendor/tests/barrel.test.ts new file mode 100644 index 0000000..9272a4f --- /dev/null +++ b/src/client/vendor/tests/barrel.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import * as barrel from '../ghostty-web'; +import { WASM_PATH } from './support/wasm'; + +// The vendored layer's public barrel is the only path app code imports it through +// (ADR 060, ADR 061). Its runtime surface is pinned here so the splits behind the +// facades cannot drop or rename an export by accident. + +describe('the ghostty-web barrel', () => { + test('exports the same runtime names', () => { + expect(Object.keys(barrel).sort()).toEqual([ + 'AT_ONCE', + 'CanvasRenderer', + 'CellFlags', + 'DirtyState', + 'DisposableStore', + 'EventEmitter', + 'FRAME_MS', + 'FitAddon', + 'Ghostty', + 'Glide', + 'InputHandler', + 'Key', + 'KeyAction', + 'KeyEncoderOption', + 'LinkDetector', + 'Mods', + 'OSC8LinkProvider', + 'OfficialTerminal', + 'SelectionManager', + 'Terminal', + 'UrlRegexProvider', + 'easeOutCubic', + 'linear', + 'listen', + 'toDisposable', + 'watchDevicePixelRatio', + ]); + }); + + test('loads the official wasm from a path, as the page does, and runs a terminal', async () => { + const ghostty = await barrel.Ghostty.load(WASM_PATH); + const term = ghostty.createTerminal(10, 2); + expect(term).toBeInstanceOf(barrel.OfficialTerminal); + term.write('hi\r\nthere'); + const text = (y: number) => + (term.getLine(y) ?? []).map((cell) => String.fromCodePoint(cell.codepoint || 32)).join(''); + expect(text(0).trimEnd()).toBe('hi'); + expect(text(1).trimEnd()).toBe('there'); + term.free(); + }); +}); diff --git a/src/client/vendor/tests/buffer.test.ts b/src/client/vendor/tests/buffer.test.ts index 990a024..90d62f5 100644 --- a/src/client/vendor/tests/buffer.test.ts +++ b/src/client/vendor/tests/buffer.test.ts @@ -1,20 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { BufferNamespace } from '../ghostty-web/buffer'; -import { Ghostty } from '../ghostty-web/ghostty'; import type { Terminal } from '../ghostty-web/terminal'; - -const WASM = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../ghostty-vt/ghostty-vt-small.wasm', -); +import { loadGhostty } from './support/wasm'; // The buffer API is what the link providers and the kitty graphics read: the // cursor, the line count, and each cell's codepoint and hyperlink id. describe('BufferNamespace', () => { test('reads the cursor, scrollback and screen lines, codepoints and links', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(10, 2); wasmTerm.write('old\r\nmid\r\n\x1b]8;;https://a.b\x1b\\ln\x1b]8;;\x1b\\k'); const buffer = new BufferNamespace({ wasmTerm } as unknown as Terminal); diff --git a/src/client/vendor/tests/ghostty-vt.test.ts b/src/client/vendor/tests/ghostty-vt.test.ts index 1f22f84..fc6e0b3 100644 --- a/src/client/vendor/tests/ghostty-vt.test.ts +++ b/src/client/vendor/tests/ghostty-vt.test.ts @@ -2,15 +2,13 @@ import { describe, expect, test } from 'bun:test'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { GHOSTTY_MINISIGN_PUBKEY, type Sidecar, verifyMinisign, verifyPinned, } from '../../../../scripts/update-ghostty-vt'; - -const VENDOR_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../ghostty-vt'); +import { GHOSTTY_VT_DIR } from './support/wasm'; // Ghostty's minisign release key, copied from Ghostty's PACKAGING.md (ADR 042). Kept here // rather than imported so a changed key in the script or the sidecar fails this test. @@ -19,10 +17,10 @@ const GHOSTTY_KEY_ID = 'F5787DDB42320225'; describe('vendored official ghostty-vt-small.wasm', () => { const sidecar = JSON.parse( - fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + fs.readFileSync(path.join(GHOSTTY_VT_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), ) as Sidecar; - const wasm = new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))); - const minisig = fs.readFileSync(path.join(VENDOR_DIR, sidecar.minisig), 'utf8'); + const wasm = new Uint8Array(fs.readFileSync(path.join(GHOSTTY_VT_DIR, sidecar.file))); + const minisig = fs.readFileSync(path.join(GHOSTTY_VT_DIR, sidecar.minisig), 'utf8'); test('is the ReleaseSmall build the sidecar names', () => { expect(sidecar.file).toBe('ghostty-vt-small.wasm'); @@ -57,11 +55,11 @@ describe('vendored official ghostty-vt-small.wasm', () => { }); test('passes the same offline check as `update-ghostty-vt.ts --verify`', () => { - expect(() => verifyPinned(VENDOR_DIR, GHOSTTY_PUBLIC_KEY)).not.toThrow(); + expect(() => verifyPinned(GHOSTTY_VT_DIR, GHOSTTY_PUBLIC_KEY)).not.toThrow(); }); test('ships with the Ghostty MIT licence', () => { - const license = fs.readFileSync(path.join(VENDOR_DIR, 'LICENSE'), 'utf8'); + const license = fs.readFileSync(path.join(GHOSTTY_VT_DIR, 'LICENSE'), 'utf8'); expect(license).toStartWith('MIT License'); expect(license).toContain('Mitchell Hashimoto, Ghostty contributors'); }); @@ -69,10 +67,10 @@ describe('vendored official ghostty-vt-small.wasm', () => { describe('verifyMinisign rejects', () => { const sidecar = JSON.parse( - fs.readFileSync(path.join(VENDOR_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), + fs.readFileSync(path.join(GHOSTTY_VT_DIR, 'ghostty-vt-small.wasm.json'), 'utf8'), ) as Sidecar; - const wasm = new Uint8Array(fs.readFileSync(path.join(VENDOR_DIR, sidecar.file))); - const minisig = fs.readFileSync(path.join(VENDOR_DIR, sidecar.minisig), 'utf8'); + const wasm = new Uint8Array(fs.readFileSync(path.join(GHOSTTY_VT_DIR, sidecar.file))); + const minisig = fs.readFileSync(path.join(GHOSTTY_VT_DIR, sidecar.minisig), 'utf8'); test('a wasm with one byte changed', () => { const tampered = wasm.slice(); diff --git a/src/client/vendor/tests/input-handler.test.ts b/src/client/vendor/tests/input-handler.test.ts index 776e400..966fb20 100644 --- a/src/client/vendor/tests/input-handler.test.ts +++ b/src/client/vendor/tests/input-handler.test.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Ghostty } from '../ghostty-web/ghostty'; import { InputHandler, type MouseEventReport, @@ -10,11 +7,7 @@ import { plainKeySequence, } from '../ghostty-web/input-handler'; import { Key, Mods } from '../ghostty-web/types'; - -const WASM = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../ghostty-vt/ghostty-vt-small.wasm', -); +import { loadGhostty } from './support/wasm'; describe('motionButton', () => { test('no button held is none, which Ghostty reports as hover (35)', () => { @@ -61,7 +54,7 @@ describe('InputHandler mouse', () => { }; async function setup() { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const doc = target(); const outside = {}; const container = { @@ -197,7 +190,7 @@ describe('InputHandler wheel', () => { // for a wheel over the padding, where the container is the target, and sent the tick // twice. test('sends nothing for a wheel event on the container, even with mouse tracking', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const listeners = new Map<string, ((e: object) => void)[]>(); const container = { addEventListener: (type: string, listener: (e: object) => void) => @@ -228,7 +221,7 @@ describe('InputHandler paste', () => { // webtty's capture listener on the container takes every paste and stops it (ADR 037), // so the handler's own paste path never ran. test('sends nothing for a paste or an insertFromPaste beforeinput', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const listeners = new Map<string, ((e: object) => void)[]>(); const element = { addEventListener: (type: string, listener: (e: object) => void) => @@ -283,7 +276,7 @@ describe('plainKeySequence', () => { describe('InputHandler keys', () => { /** Sends a keydown through a handler and returns what it wrote, under DECCKM if asked. */ async function keys(appCursor = false) { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const listeners = new Map<string, ((e: object) => void)[]>(); const container = { addEventListener: (type: string, listener: (e: object) => void) => diff --git a/src/client/vendor/tests/official-replay.test.ts b/src/client/vendor/tests/official-replay.test.ts index afcf375..6cf33e4 100644 --- a/src/client/vendor/tests/official-replay.test.ts +++ b/src/client/vendor/tests/official-replay.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Ghostty, type GhosttyTerminal } from '../ghostty-web/ghostty'; +import type { Ghostty, GhosttyTerminal } from '../ghostty-web/ghostty'; +import { FIXTURES_DIR, loadGhostty } from './support/wasm'; // The official engine (ADR 042, ADR 047) replaying the same input two ways, cell // for cell. A terminal that was scrolled, paged and cached while the input arrived @@ -16,9 +16,6 @@ import { Ghostty, type GhosttyTerminal } from '../ghostty-web/ghostty'; // in less, `ls -laG /usr/bin | head -80`, `top` showing one `sleep` process, // `seq 5000`, an SGR/256-colour/truecolor test and a Unicode test. -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const OFFICIAL_WASM = path.join(HERE, '../ghostty-vt/ghostty-vt-small.wasm'); -const FIXTURES = path.join(HERE, 'fixtures'); const COLS = 120; const ROWS = 40; const THEME = { fgColor: 0xd4d4d4, bgColor: 0x1e1e1e }; @@ -26,7 +23,7 @@ const THEME = { fgColor: 0xd4d4d4, bgColor: 0x1e1e1e }; let vt: Ghostty; beforeAll(async () => { - vt = await Ghostty.load(OFFICIAL_WASM); + vt = await loadGhostty(); }); interface Cell { @@ -184,7 +181,7 @@ const CAPTURES = ['ls', 'vim', 'nvim', 'less', 'top', 'sgr', 'uni', 'seq']; describe('PTY captures at 120x40: 1-byte writes match 512-byte writes', () => { for (const name of CAPTURES) { test(name, () => { - const data = new Uint8Array(fs.readFileSync(path.join(FIXTURES, `${name}.bin`))); + const data = new Uint8Array(fs.readFileSync(path.join(FIXTURES_DIR, `${name}.bin`))); const whole = vt.createTerminal(COLS, ROWS, THEME); const bytes = vt.createTerminal(COLS, ROWS, THEME); let checkpoints = 0; diff --git a/src/client/vendor/tests/official-terminal.test.ts b/src/client/vendor/tests/official-terminal.test.ts index a9b00f0..fb36b8f 100644 --- a/src/client/vendor/tests/official-terminal.test.ts +++ b/src/client/vendor/tests/official-terminal.test.ts @@ -1,6 +1,4 @@ import { beforeAll, describe, expect, test } from 'bun:test'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { DirtyState, Ghostty, @@ -9,14 +7,10 @@ import { type OfficialTerminal, } from '../ghostty-web/ghostty'; import { CellFlags, Key, KeyAction, KeyEncoderOption, Mods } from '../ghostty-web/types'; +import { instantiateWasm, loadGhostty } from './support/wasm'; // The official libghostty-vt binding (ADR 042) on the vendored wasm, loaded from disk. -const OFFICIAL_WASM = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../ghostty-vt/ghostty-vt-small.wasm', -); - const FG = 0xd4d4d4; const BG = 0x1e1e1e; const RED = 0xcd3131; @@ -30,7 +24,7 @@ const THEME = { let vt: Ghostty; beforeAll(async () => { - vt = await Ghostty.load(OFFICIAL_WASM); + vt = await loadGhostty(); }); function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { @@ -409,8 +403,7 @@ describe('official engine: scrollback, resize and the viewport', () => { }); test('a scroll step decodes nothing and moves no viewport once its page is cached', async () => { - const wasm = await WebAssembly.instantiate(await Bun.file(OFFICIAL_WASM).arrayBuffer(), {}); - const real = wasm.instance.exports as Record<string, (...args: number[]) => number>; + const real = (await instantiateWasm()).exports as Record<string, (...args: number[]) => number>; const calls = { update: 0, rows: 0, scroll: 0 }; const counted = (name: string, key: keyof typeof calls) => @@ -702,8 +695,7 @@ describe('official engine: effects and replies', () => { }); test('makes one ghostty_terminal_resize call per grid or pixel change', async () => { - const wasm = await WebAssembly.instantiate(await Bun.file(OFFICIAL_WASM).arrayBuffer(), {}); - const real = wasm.instance.exports as Record<string, unknown>; + const real = (await instantiateWasm()).exports as Record<string, unknown>; let resizes = 0; const resize = real.ghostty_terminal_resize as (...args: number[]) => number; const spy = { diff --git a/src/client/vendor/tests/renderer.test.ts b/src/client/vendor/tests/renderer.test.ts index 4af1403..8b7e890 100644 --- a/src/client/vendor/tests/renderer.test.ts +++ b/src/client/vendor/tests/renderer.test.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Ghostty } from '../ghostty-web/ghostty'; import { CanvasRenderer, frameLines, @@ -10,11 +7,7 @@ import { wholeDevicePixelRatio, } from '../ghostty-web/renderer'; import type { GhosttyCell } from '../ghostty-web/types'; - -const WASM = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../ghostty-vt/ghostty-vt-small.wasm', -); +import { loadGhostty } from './support/wasm'; describe('wholeDevicePixelRatio', () => { test('keeps a whole ratio', () => { @@ -161,7 +154,7 @@ describe('frameLines', () => { }); test('serves the same rows as getLine from a real terminal', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = ghostty.createTerminal(20, 5); term.write('\x1b[31mred\x1b[0m \x1b[1;44mbold\x1b[0m\r\n中文 ✓\r\n\x1b[7mrev\x1b[0m'); term.update(); @@ -204,7 +197,7 @@ describe('graphemes while scrolled', () => { const { canvas, drawn } = recordingCanvas(15); globalThis.document = { createElement: () => canvas } as unknown as Document; try { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = ghostty.createTerminal(30, 6, { scrollbackLines: 10000 }); // A grapheme at a different column on every line, so a lookup on the wrong // row finds something else. @@ -466,7 +459,7 @@ describe('overscan and copied scrolls (ADR 043)', () => { } async function terminal() { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); // Colours, backgrounds, a wide character, combining marks and descenders that // overflow into the rows above and below. @@ -687,7 +680,7 @@ describe('overscan and copied scrolls (ADR 043)', () => { test(`long runs are repainted in chunks on a small scratch canvas, as a full redraw`, async () => { const tall = 3 * REPAINT_CHUNK_ROWS + 5; - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = ghostty.createTerminal(cols, tall, { scrollbackLines: 10000 }); const provider = { getScrollbackLine: (o: number) => term.getScrollbackLine(o), @@ -748,7 +741,7 @@ describe('overscan and copied scrolls (ADR 043)', () => { }); test(`a hovered OSC 8 link underlines and repaints only its own range`, async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = ghostty.createTerminal(cols, rows); // Two links, on rows 1 and 4: the engine gives every link cell the same hyperlink_id term.write('a\r\n\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\nb\r\nc\r\n'); @@ -1044,7 +1037,7 @@ describe('overscan and copied scrolls (ADR 043)', () => { }); test(`the block cursor redraws the grapheme of its own screen row`, async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); // Every row starts with a cluster of its own, the cursor on a combining one term.write( diff --git a/src/client/vendor/tests/selection-manager.test.ts b/src/client/vendor/tests/selection-manager.test.ts index dc67cbc..f4bdf3b 100644 --- a/src/client/vendor/tests/selection-manager.test.ts +++ b/src/client/vendor/tests/selection-manager.test.ts @@ -1,15 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Ghostty, type GhosttyTerminal } from '../ghostty-web/ghostty'; +import type { Ghostty, GhosttyTerminal } from '../ghostty-web/ghostty'; import type { CanvasRenderer } from '../ghostty-web/renderer'; import { SelectionManager } from '../ghostty-web/selection-manager'; import type { Terminal } from '../ghostty-web/terminal'; - -const WASM = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../ghostty-vt/ghostty-vt-small.wasm', -); +import { loadGhostty } from './support/wasm'; type Listener = (e: object) => void; @@ -48,7 +42,7 @@ describe('SelectionManager copy on select', () => { }, }, }); - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); wasmTerm = ghostty.createTerminal(20, 4); wasmTerm.write('hello world\r\nsecond line'); }); @@ -187,7 +181,7 @@ describe('SelectionManager text of wide characters', () => { beforeEach(async () => { globalThis.document = target() as unknown as Document; - ghostty = await Ghostty.load(WASM); + ghostty = await loadGhostty(); }); afterEach(() => { @@ -269,7 +263,7 @@ describe('SelectionManager gestures', () => { configurable: true, value: { clipboard: { writeText: async (text: string) => void writes.push(text) } }, }); - ghostty = await Ghostty.load(WASM); + ghostty = await loadGhostty(); }); afterEach(() => { diff --git a/src/client/vendor/tests/support/wasm.ts b/src/client/vendor/tests/support/wasm.ts new file mode 100644 index 0000000..beb8a9f --- /dev/null +++ b/src/client/vendor/tests/support/wasm.ts @@ -0,0 +1,42 @@ +// The vendored tests' one way to the official wasm (test support, ADR 064): its path, the +// folders beside it, and Ghostty instances built from a single compile, so a test file +// that opens many terminals does not compile the wasm for each one. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Ghostty } from '../../ghostty-web/ghostty'; + +const VENDOR_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** The pinned ghostty-vt folder (wasm, sidecar, signature, licence) the integrity test reads. */ +export const GHOSTTY_VT_DIR = path.join(VENDOR_DIR, 'ghostty-vt'); + +/** The official libghostty-vt wasm every engine test runs on (ADR 042, ADR 047). */ +export const WASM_PATH = path.join(GHOSTTY_VT_DIR, 'ghostty-vt-small.wasm'); + +/** The PTY captures the replay test feeds the engine. */ +export const FIXTURES_DIR = path.join(VENDOR_DIR, 'tests', 'fixtures'); + +let compiled: Promise<WebAssembly.Module> | undefined; + +/** The wasm, read and compiled on first use and shared by every later call. */ +export function compiledWasm(): Promise<WebAssembly.Module> { + compiled ??= Bun.file(WASM_PATH) + .arrayBuffer() + .then((bytes) => WebAssembly.compile(bytes)); + return compiled; +} + +/** A fresh instance with its own memory, for tests that wrap or count the raw exports. */ +export async function instantiateWasm(): Promise<WebAssembly.Instance> { + return WebAssembly.instantiate(await compiledWasm(), {}); +} + +/** + * A Ghostty on a fresh instance, as `Ghostty.load` builds one (the official wasm imports + * nothing), without reading and compiling the file again. + */ +export async function loadGhostty(): Promise<Ghostty> { + const instance = await instantiateWasm(); + return new Ghostty(instance.exports as unknown as ConstructorParameters<typeof Ghostty>[0]); +} diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index 1bee0c5..0310c12 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Ghostty, type TerminalEffects } from '../ghostty-web/ghostty'; +import type { TerminalEffects } from '../ghostty-web/ghostty'; import { InputHandler } from '../ghostty-web/input-handler'; import { LinkDetector } from '../ghostty-web/link-detector'; import { OSC8LinkProvider } from '../ghostty-web/providers/osc8-link-provider'; @@ -14,11 +12,7 @@ import { Terminal, } from '../ghostty-web/terminal'; import type { GhosttyTerminalConfig, ILink } from '../ghostty-web/types'; - -const WASM = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '../ghostty-vt/ghostty-vt-small.wasm', -); +import { loadGhostty } from './support/wasm'; describe('mouseCursorFor', () => { const link: ILink = { @@ -135,7 +129,7 @@ describe('mouseReportText', () => { describe('Terminal wheel in mouse-tracking apps', () => { // A 10 px x 20 px CSS cell at DPR 1, the grid at (5, 5) in a container at (0, 0). async function wheelTerm(modes: string) { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(40, 10); wasmTerm.write(modes); const term = new Terminal({ ghostty: {} as never, cols: 40, rows: 10 }); @@ -252,7 +246,7 @@ describe('Terminal cursor style', () => { describe('Terminal paste', () => { // Pastes are encoded by ghostty_terminal_paste and leave through onData (ADR 049). async function pasteTerm(options: object = {}) { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(40, 10); const term = new Terminal({ ghostty: {} as never, ...options }); const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; @@ -285,7 +279,7 @@ describe('Terminal clearScreen', () => { // Ghostty's clear_screen with what libghostty-vt has: the erasing is written to the // terminal, and only FF, at a prompt, goes to the app (ADR 054). async function clearTerm(options: object = {}) { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(20, 5); const term = new Terminal({ ghostty: {} as never, ...options }); const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; @@ -445,7 +439,7 @@ describe('Terminal clearScreen', () => { describe('Terminal find', () => { // Find with libghostty-vt's search, run in the render loop (ADR 055). async function findTerm() { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = new Terminal({ ghostty, cols: 20, rows: 4, scrollbackLines: 1000 }); const highlights: unknown[] = []; const renderer = { @@ -674,7 +668,7 @@ describe('Terminal focus reports', () => { // on a focus change while the mode is on, and the current state when it is set // (ADR 053). async function focusTerm(options: object = {}) { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(20, 2); const term = new Terminal({ ghostty: {} as never, ...options }); const element = { contains: (node: unknown) => node === inside }; @@ -809,7 +803,7 @@ describe('Terminal clipboard writes', () => { // Program clipboard writes come from the engine's readEffects and reach the embedder // through onClipboardWrite (ADR 052). test('OSC 52 output fires onClipboardWrite with the decoded text, once', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = new Terminal({ ghostty: {} as never }); (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); const write = (data: string) => @@ -851,7 +845,7 @@ describe('Terminal desktop notifications', () => { // OSC 9 and OSC 777 come from the engine's readEffects and reach the embedder through // onDesktopNotification (ADR 056). test('OSC 9 and OSC 777 output fire onDesktopNotification, each once, in order', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = new Terminal({ ghostty: {} as never }); (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); const write = (data: string) => @@ -884,7 +878,7 @@ describe('Terminal input and the selection', () => { // Keys and text clear the selection; mouse reports leave it, so a Shift selection in an // app with any-motion mode 1003 survives the next hover or click (ADR 051). test('a mouse report keeps the selection, a key clears it', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = new Terminal({ ghostty: {} as never }); const internals = term as unknown as { selectionManager: unknown }; let selection = 'hello'; @@ -945,7 +939,7 @@ describe('Terminal link hover', () => { // The engine's hyperlink_id is a 1-bit "has a link" flag, so every OSC 8 cell carries // the same value: the hover is the hovered link's range, never an id. test('hovering an OSC 8 link gives the renderer that link range only', async () => { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(30, 4); wasmTerm.write('\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\n'); wasmTerm.write('xx \x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); @@ -1014,7 +1008,7 @@ describe('Terminal right-click', () => { // Every contextmenu goes through one handler: the app's right button cancels the menu, // and otherwise rightClickBehavior decides (ADR 051). async function setup(rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none') { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(20, 2); wasmTerm.write('hello'); const term = new Terminal({ ghostty: {} as never, rightClickBehavior }); @@ -1115,7 +1109,7 @@ describe('Terminal right-click', () => { describe('Terminal jumpToPrompt', () => { // Ghostty's jump_to_prompt on libghostty-vt's DELTA_PROMPT, in webtty's view (ADR 057). async function promptTerm() { - const ghostty = await Ghostty.load(WASM); + const ghostty = await loadGhostty(); const term = new Terminal({ ghostty, cols: 20, rows: 5 }); const internals = term as unknown as { isOpen: boolean; From 89145750bfcabb60f0c3dcd4be24cbdb5d0b07e7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:17:38 -0400 Subject: [PATCH 226/295] test(client): the vendored Terminal's listener snapshot, on fake DOM targets core/listener-snapshot.test.ts opens a Terminal as webtty does (copy on select, a blinking cursor, OSC 52 and OSC 9 reports) on recording targets and pins every listener open() adds, in order, with its target, type, capture, once and passive flags, and whether dispose() removes it (ADR 064). It records today's code exactly: the listeners that leak (the container's beforeinput, the window's focus and blur, the document's visibilitychange, mousedown and Terminal mouseup, and the terminal canvas' own and SelectionManager's listeners) and the empty compositionupdate listener. Every vendor split step leaves it unchanged; only the ADR 065 fix may change it. It also checks that a second dispose() removes nothing more and that the blink is open()'s one interval. support/fake-event-target.ts holds the element, document, window and media query fakes, each a RecordingTarget logging into one timeline; support/fake-canvas.ts the 2D context that draws nothing. The architecture SPEC names the test's path. Vendor coverage (bun test --coverage src/client/vendor): terminal.ts lines 52.9% -> 68.6%, selection-manager.ts 61.4% -> 66.0%, renderer.ts 95.3% -> 95.9%. --- docs/specs/architecture.md | 2 +- .../tests/core/listener-snapshot.test.ts | 143 ++++++++++++++++++ .../vendor/tests/support/fake-canvas.ts | 24 +++ .../vendor/tests/support/fake-event-target.ts | 140 +++++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/client/vendor/tests/core/listener-snapshot.test.ts create mode 100644 src/client/vendor/tests/support/fake-canvas.ts create mode 100644 src/client/vendor/tests/support/fake-event-target.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 05e1ecf..3473b0b 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -315,7 +315,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a - **Order:** the client root has an install-order test; the per-message pipeline and the on-open sequence have call-order tests; the key dispatcher has a case-by-case equivalence test against the old two-listener behaviour. - **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. - **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. -- **Listener snapshots.** Before the vendored facades are split, a test opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. +- **Listener snapshots.** Before the vendored facades are split, a test (`vendor/tests/core/listener-snapshot.test.ts`) opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. - **Coverage targets** (`bun run test:coverage`): app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov`, enforces the per-layer targets from `coverage/lcov.info`. ## Features diff --git a/src/client/vendor/tests/core/listener-snapshot.test.ts b/src/client/vendor/tests/core/listener-snapshot.test.ts new file mode 100644 index 0000000..f665d49 --- /dev/null +++ b/src/client/vendor/tests/core/listener-snapshot.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { ListenerRecord, RecordingTarget } from '../../../test-support/recording-target'; +import { Terminal } from '../../ghostty-web/terminal'; +import { FakeDocument, FakeElement, FakeWindow } from '../support/fake-event-target'; +import { loadGhostty } from '../support/wasm'; + +// The baseline the vendor split (ADR 061) is held to: every listener Terminal.open() adds, +// in order, with its target, type and flags, and whether dispose() removes it (ADR 064). +// Each split step leaves this unchanged. Only the ADR 065 fix may change it: the rows +// marked LEAKS become removed and the empty compositionupdate listener goes. + +/** One row: target, type, the flags set, and what dispose() did with it. */ +function row(record: ListenerRecord, targets: readonly RecordingTarget[]): string { + const flags = [ + record.capture ? 'capture' : '', + record.once ? 'once' : '', + record.passive === false ? 'passive:false' : '', + ].filter(Boolean); + const target = targets.find((t) => t.name === record.target); + const removed = target?.removed.includes(record) ?? false; + return [record.target, record.type, ...flags, removed ? '-> removed' : '-> LEAKS'].join(' '); +} + +/** Today's listeners, in the order open() adds them. */ +const SNAPSHOT = [ + 'container beforeinput -> LEAKS', + 'container focusin -> removed', + 'window focus -> LEAKS', + 'container focusin -> removed', + 'container focusout -> removed', + 'window blur -> LEAKS', + 'document visibilitychange -> LEAKS', + // The terminal's canvas: it focuses the textarea on a press or tap + 'canvas mousedown -> LEAKS', + 'canvas touchend -> LEAKS', + // CanvasRenderer: the drawing canvas and the device-pixel-ratio watch + 'drawCanvas contextrestored -> removed', + 'mediaQuery change once -> removed', + // InputHandler + 'container keydown -> removed', + 'textarea beforeinput -> removed', + 'container compositionstart -> removed', + 'container compositionupdate -> removed', + 'container compositionend -> removed', + 'container mousedown -> removed', + 'document mouseup -> removed', + 'document mousemove -> removed', + // SelectionManager + 'canvas mousedown -> LEAKS', + 'canvas mousemove -> LEAKS', + 'canvas mouseleave -> LEAKS', + 'canvas mouseenter -> LEAKS', + 'document mousemove -> removed', + 'document mousedown -> LEAKS', + 'document mouseup -> removed', + 'canvas click -> LEAKS', + 'document click -> removed', + // Terminal: links, scrollbar, the context menu, scrollbar drag and the wheel + 'container mousedown capture -> removed', + 'container mousemove -> removed', + 'container mouseleave -> removed', + 'container click -> removed', + 'container mousedown capture -> removed', + 'container keydown capture -> removed', + 'container contextmenu capture -> removed', + 'document mouseup -> LEAKS', + 'container wheel capture passive:false -> removed', +]; + +describe('Terminal listener snapshot', () => { + const g = globalThis as Record<string, unknown>; + const saved = ['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map( + (key) => [key, g[key]] as const, + ); + let timeline: ListenerRecord[]; + let doc: FakeDocument; + let win: FakeWindow; + let container: FakeElement; + + beforeEach(() => { + timeline = []; + doc = new FakeDocument(timeline, { + canvas: ['canvas', 'drawCanvas', 'scratchCanvas', 'measureCanvas'], + textarea: ['textarea'], + }); + win = new FakeWindow(timeline); + container = new FakeElement('container', 'div', doc); + // The vendored code reads these globals; no frame ever runs. + g.document = doc; + g.window = win; + g.requestAnimationFrame = () => 1; + g.cancelAnimationFrame = () => {}; + }); + + afterEach(() => { + for (const [key, value] of saved) g[key] = value; + }); + + /** Opens a terminal as webtty does: copy on select, a blinking cursor, OSC 52 and OSC 9 reports on. */ + async function openTerminal(): Promise<Terminal> { + const term = new Terminal({ + ghostty: await loadGhostty(), + cols: 80, + rows: 24, + cursorBlink: true, + copyOnSelect: true, + clipboardWrite: true, + desktopNotifications: true, + }); + term.open(container as unknown as HTMLElement); + return term; + } + + const targets = () => [container, doc, win, ...doc.created, ...win.mediaQueries]; + + test('open() adds these listeners in this order, and dispose() removes the ones marked', async () => { + const term = await openTerminal(); + const opened = timeline.length; + term.dispose(); + // dispose() adds nothing + expect(timeline.length).toBe(opened); + expect(timeline.map((record) => row(record, targets()))).toEqual(SNAPSHOT); + // The cursor blink, the one interval open() starts on the window + expect(win.intervals).toEqual([530]); + }); + + test('a second dispose() removes nothing more', async () => { + const term = await openTerminal(); + term.dispose(); + const removed = targets().map((target) => target.removed.length); + term.dispose(); + expect(targets().map((target) => target.removed.length)).toEqual(removed); + }); + + test('the listeners left after dispose() are the leaking ones', async () => { + const term = await openTerminal(); + term.dispose(); + const left = targets().flatMap((target) => + target.active.map((record) => row(record, targets())), + ); + expect(left.sort()).toEqual(SNAPSHOT.filter((r) => r.endsWith('LEAKS')).sort()); + }); +}); diff --git a/src/client/vendor/tests/support/fake-canvas.ts b/src/client/vendor/tests/support/fake-canvas.ts new file mode 100644 index 0000000..f71ced5 --- /dev/null +++ b/src/client/vendor/tests/support/fake-canvas.ts @@ -0,0 +1,24 @@ +// A 2D canvas context that draws nothing (test fake, ADR 064), for vendor tests that open +// a whole Terminal and only care about what it wires up, not what it paints. + +/** The glyph box every `measureText` reports: an 8 px wide cell, 10 px ascent, 3 px descent. */ +export const FAKE_GLYPH = { width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }; + +/** + * A `CanvasRenderingContext2D` stand-in: `measureText` reports `FAKE_GLYPH`, every other + * method is a no-op and every property accepts and keeps what is written to it, so the + * renderer can measure its font and draw frames on it. + */ +export function fakeContext2d(): CanvasRenderingContext2D { + const state: Record<string | symbol, unknown> = { + measureText: () => ({ ...FAKE_GLYPH }), + }; + const context = new Proxy(state, { + get: (target, key) => (key in target ? target[key] : () => undefined), + set: (target, key, value) => { + target[key] = value; + return true; + }, + }); + return context as unknown as CanvasRenderingContext2D; +} diff --git a/src/client/vendor/tests/support/fake-event-target.ts b/src/client/vendor/tests/support/fake-event-target.ts new file mode 100644 index 0000000..9f74ea7 --- /dev/null +++ b/src/client/vendor/tests/support/fake-event-target.ts @@ -0,0 +1,140 @@ +// The smallest DOM a vendored Terminal can open on (test fake, ADR 064): an element, a +// document and a window, each a RecordingTarget that logs its listeners into one shared +// timeline, so a test reads every listener open() adds, in order, and which dispose() removes. + +import { type ListenerRecord, RecordingTarget } from '../../../test-support/recording-target'; +import { fakeContext2d } from './fake-canvas'; + +/** An element: attributes, style, children and focus, on a recording target. */ +export class FakeElement extends RecordingTarget { + readonly style: Record<string, string> = {}; + readonly dataset: Record<string, string> = {}; + readonly children: FakeElement[] = []; + parentNode: FakeElement | null = null; + width = 0; + height = 0; + /** Set on a canvas only: a context that draws nothing. */ + getContext?: () => CanvasRenderingContext2D; + private readonly attributes = new Map<string, string>(); + + constructor( + name: string, + readonly tagName: string, + readonly ownerDocument: FakeDocument, + ) { + super(name); + this.timeline = ownerDocument.timeline; + } + + hasAttribute(key: string): boolean { + return this.attributes.has(key); + } + + setAttribute(key: string, value: string): void { + this.attributes.set(key, value); + } + + removeAttribute(key: string): void { + this.attributes.delete(key); + } + + appendChild<T extends FakeElement>(child: T): T { + return this.insertBefore(child, null); + } + + insertBefore<T extends FakeElement>(child: T, before: FakeElement | null): T { + child.parentNode?.removeChild(child); + const at = before ? this.children.indexOf(before) : -1; + this.children.splice(at < 0 ? this.children.length : at, 0, child); + child.parentNode = this; + return child; + } + + removeChild<T extends FakeElement>(child: T): T { + const at = this.children.indexOf(child); + if (at >= 0) this.children.splice(at, 1); + child.parentNode = null; + return child; + } + + remove(): void { + this.parentNode?.removeChild(this); + } + + focus(): void { + this.ownerDocument.activeElement = this; + } +} + +/** + * A document that creates fake elements, naming each from `names` (by tag, in creation + * order) so a listener record says which canvas or textarea it is on. + */ +export class FakeDocument extends RecordingTarget { + activeElement: FakeElement | null = null; + visibilityState: DocumentVisibilityState = 'visible'; + /** Every element created, in order. */ + readonly created: FakeElement[] = []; + private readonly made = new Map<string, number>(); + + constructor( + timeline: ListenerRecord[], + private readonly names: Record<string, string[]> = {}, + ) { + super('document'); + this.timeline = timeline; + } + + createElement(tag: string): FakeElement { + const index = this.made.get(tag) ?? 0; + this.made.set(tag, index + 1); + const element = new FakeElement(this.names[tag]?.[index] ?? `${tag}#${index + 1}`, tag, this); + if (tag === 'canvas') { + const context = fakeContext2d(); + element.getContext = () => context; + } + this.created.push(element); + return element; + } +} + +/** A media query list on a recording target, as `matchMedia` returns it. */ +export class FakeMediaQueryList extends RecordingTarget { + readonly matches = false; + + constructor(readonly media: string) { + super('mediaQuery'); + } +} + +/** + * A window at device pixel ratio 1 whose intervals are logged and never run, so nothing + * the terminal schedules on it fires after the test. + */ +export class FakeWindow extends RecordingTarget { + readonly devicePixelRatio = 1; + /** The delay of every interval set, in order. */ + readonly intervals: number[] = []; + /** The media queries `matchMedia` handed out, in order. */ + readonly mediaQueries: FakeMediaQueryList[] = []; + + constructor(timeline: ListenerRecord[]) { + super('window'); + this.timeline = timeline; + } + + matchMedia(media: string): FakeMediaQueryList { + const query = new FakeMediaQueryList(media); + query.timeline = this.timeline; + this.mediaQueries.push(query); + return query; + } + + setInterval(_handler: () => void, ms = 0): number { + return this.intervals.push(ms); + } + + clearTimeout(): void {} + + clearInterval(): void {} +} From c3ef133e6da37ed52c04acdb7cf326c4901e1ffa Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:22:20 -0400 Subject: [PATCH 227/295] refactor(client): the vendor types.ts split into domain-named type files The vendored types.ts held four unrelated groups; each moves, unchanged, to a file named for its domain (ADR 061): the key encoder enums and KeyEvent to input/key-types.ts, the render-state cell, flags, cursor and dirty state to engine/cell-types.ts, the terminal's creation config to engine/engine-types.ts (the engine's narrow-interface file), and the link contract to link-types.ts. types.ts becomes a re-export shim, so every importer compiles unchanged until V15 moves them and removes it. Each new file carries the Coder derivation header and a JSDoc line per export; PROVENANCE.md records the moves and the SPEC's tree lists link-types.ts. The listener snapshot is unchanged. --- docs/specs/architecture.md | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 8 + .../vendor/ghostty-web/engine/cell-types.ts | 51 +++ .../vendor/ghostty-web/engine/engine-types.ts | 38 ++ .../vendor/ghostty-web/input/key-types.ts | 240 ++++++++++ src/client/vendor/ghostty-web/link-types.ts | 46 ++ src/client/vendor/ghostty-web/types.ts | 410 +----------------- 7 files changed, 395 insertions(+), 400 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/cell-types.ts create mode 100644 src/client/vendor/ghostty-web/engine/engine-types.ts create mode 100644 src/client/vendor/ghostty-web/input/key-types.ts create mode 100644 src/client/vendor/ghostty-web/link-types.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 3473b0b..e3dc21e 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -191,7 +191,7 @@ ghostty-web/ selection-manager.ts facade: SelectionManager state + API (SelectionHost) (≤ 300) interfaces.ts buffer.ts (IBufferSource) event-emitter.ts types.ts (re-export shim until removed) search.ts glide.ts wheel.ts mouse-route.ts webtty-original, kept in place - link-detector.ts providers/ addons/fit.ts fn-table.ts + link-detector.ts link-types.ts providers/ addons/fit.ts fn-table.ts lifecycle/ disposable-store.ts (DisposableStore, listen, toDisposable) – webtty-original engine/ every wasm call; no DOM engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 51dfa2a..8f34dd8 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -58,6 +58,10 @@ Not from upstream; each starts with, or will start with when it is next touched, ## Layout - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. +- `input/`: keyboard and mouse input (`key-types.ts`). +- `engine/`: the binding to the official wasm (`cell-types.ts`, `engine-types.ts`). +- `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. +- `types.ts`: a re-export shim for the files above until its importers move (ADR 061). ## Moves and splits @@ -65,3 +69,7 @@ Each commit that moves code out of an upstream-derived file appends its rows her | From (upstream file: symbols) | To | Commit subject | |---|---|---| +| `types.ts`: `KeyEncoderOption`, `KeyAction`, `Key`, `Mods`, `KeyEvent` | `input/key-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | +| `types.ts`: `DirtyState`, `RenderStateCursor`, `GhosttyCell`, `CellFlags` | `engine/cell-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | +| `types.ts`: `GhosttyTerminalConfig` | `engine/engine-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | +| `types.ts`: `IBufferCellPosition`, `IBufferRange`, `ILink`, `ILinkProvider` | `link-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | diff --git a/src/client/vendor/ghostty-web/engine/cell-types.ts b/src/client/vendor/ghostty-web/engine/cell-types.ts new file mode 100644 index 0000000..0cdec1b --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/cell-types.ts @@ -0,0 +1,51 @@ +// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// What the binding reads out of Ghostty's render state (types): a decoded cell and its +// style flags, the cursor and the dirty state (ADR 047, ADR 061). + +/** How much of the render state changed since the last frame, so the renderer can skip work. */ +export enum DirtyState { + NONE = 0, + PARTIAL = 1, + FULL = 2, +} + +/** The cursor as the render state reports it: where, whether it shows, and its shape. */ +export interface RenderStateCursor { + x: number; + y: number; + viewportX: number; // -1 if not in viewport + viewportY: number; + visible: boolean; + blinking: boolean; + style: 'block' | 'underline' | 'bar'; +} + +/** + * A cell as the binding decodes it from Ghostty's render state (webtty: the field + * comments give ghostty-web's original C sizes) + */ +export interface GhosttyCell { + codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) + fg_r: number; // u8 (foreground red) + fg_g: number; // u8 (foreground green) + fg_b: number; // u8 (foreground blue) + bg_r: number; // u8 (background red) + bg_g: number; // u8 (background green) + bg_b: number; // u8 (background blue) + flags: number; // u8 (style flags bitfield) + width: number; // u8 (character width: 1=normal, 2=wide, etc.) + hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set) + grapheme_len: number; // u8 (number of extra codepoints beyond first) +} + +/** The style bits of `GhosttyCell.flags`, which the renderer draws. */ +export enum CellFlags { + BOLD = 1 << 0, + ITALIC = 1 << 1, + UNDERLINE = 1 << 2, + STRIKETHROUGH = 1 << 3, + INVERSE = 1 << 4, + INVISIBLE = 1 << 5, + BLINK = 1 << 6, + FAINT = 1 << 7, +} diff --git a/src/client/vendor/ghostty-web/engine/engine-types.ts b/src/client/vendor/ghostty-web/engine/engine-types.ts new file mode 100644 index 0000000..61f5b34 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-types.ts @@ -0,0 +1,38 @@ +// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The engine's narrow interfaces (types): what the split parts of the vendored layer import +// instead of the binding's classes (ADR 061). For now the terminal's creation config. + +/** + * Terminal configuration, applied when the terminal is created. Colours are + * 0xRRGGBB; undefined leaves one unset, so 0 is black. (webtty, ADR 047) + */ +export interface GhosttyTerminalConfig { + /** + * History in lines, with a 32 MiB ceiling; 0 = no line limit. Default 10,000. + * (webtty, ADR 046) + */ + scrollbackLines?: number; + fgColor?: number; + bgColor?: number; + cursorColor?: number; + /** The 16 ANSI colours; an undefined entry keeps Ghostty's default. */ + palette?: (number | undefined)[]; + /** + * The default cursor shape and blink, which DECSCUSR Ps 0 and RIS restore. Default + * block, not blinking. (webtty, ADR 049) + */ + cursorStyle?: 'block' | 'underline' | 'bar'; + cursorBlink?: boolean; + /** + * Whether programs may write the clipboard (OSC 52, OSC 1337 Copy, OSC 5522): the + * writes are reported by `readEffects`. Off, Ghostty's clipboard write callback is + * left unset and the writes are ignored. Default true. (webtty, ADR 052) + */ + clipboardWrite?: boolean; + /** + * Whether programs may ask for desktop notifications (OSC 9, OSC 777 `notify`): they + * are reported by `readEffects`. Off, Ghostty's desktop notification callback is left + * unset and the requests are ignored. Default true. (webtty, ADR 056) + */ + desktopNotifications?: boolean; +} diff --git a/src/client/vendor/ghostty-web/input/key-types.ts b/src/client/vendor/ghostty-web/input/key-types.ts new file mode 100644 index 0000000..628f083 --- /dev/null +++ b/src/client/vendor/ghostty-web/input/key-types.ts @@ -0,0 +1,240 @@ +// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The key encoder's types (types): the Ghostty key, action, modifier and option enums the +// binding translates to the official libghostty-vt enums by name at load (ADR 047, ADR 061). +// The `Key` enum mirrors ghostty/src/input/key.zig, so the file is an exempt data table. + +/** The key encoder's options, set from the terminal's modes before each key is encoded. */ +export enum KeyEncoderOption { + CURSOR_KEY_APPLICATION = 0, // DEC mode 1 + KEYPAD_KEY_APPLICATION = 1, // DEC mode 66 + IGNORE_KEYPAD_WITH_NUMLOCK = 2, // DEC mode 1035 + ALT_ESC_PREFIX = 3, // DEC mode 1036 + MODIFY_OTHER_KEYS_STATE_2 = 4, // xterm modifyOtherKeys + KITTY_KEYBOARD_FLAGS = 5, // Kitty protocol flags +} + +/** Whether a key event is a press, a repeat or a release. */ +export enum KeyAction { + RELEASE = 0, + PRESS = 1, + REPEAT = 2, +} + +/** + * Physical key codes matching Ghostty's internal Key enum. + * These values are used by Ghostty's key encoder to produce correct escape sequences. + * Reference: ghostty/src/input/key.zig + */ +export enum Key { + // Unidentified key + UNIDENTIFIED = 0, + + // Writing System Keys + GRAVE = 1, // ` and ~ + BACKSLASH = 2, // \ and | + BRACKET_LEFT = 3, // [ and { + BRACKET_RIGHT = 4, // ] and } + COMMA = 5, // , and < + ZERO = 6, + ONE = 7, + TWO = 8, + THREE = 9, + FOUR = 10, + FIVE = 11, + SIX = 12, + SEVEN = 13, + EIGHT = 14, + NINE = 15, + EQUAL = 16, // = and + + INTL_BACKSLASH = 17, + INTL_RO = 18, + INTL_YEN = 19, + A = 20, + B = 21, + C = 22, + D = 23, + E = 24, + F = 25, + G = 26, + H = 27, + I = 28, + J = 29, + K = 30, + L = 31, + M = 32, + N = 33, + O = 34, + P = 35, + Q = 36, + R = 37, + S = 38, + T = 39, + U = 40, + V = 41, + W = 42, + X = 43, + Y = 44, + Z = 45, + MINUS = 46, // - and _ + PERIOD = 47, // . and > + QUOTE = 48, // ' and " + SEMICOLON = 49, // ; and : + SLASH = 50, // / and ? + + // Functional Keys + ALT_LEFT = 51, + ALT_RIGHT = 52, + BACKSPACE = 53, + CAPS_LOCK = 54, + CONTEXT_MENU = 55, + CONTROL_LEFT = 56, + CONTROL_RIGHT = 57, + ENTER = 58, + META_LEFT = 59, + META_RIGHT = 60, + SHIFT_LEFT = 61, + SHIFT_RIGHT = 62, + SPACE = 63, + TAB = 64, + CONVERT = 65, + KANA_MODE = 66, + NON_CONVERT = 67, + + // Control Pad Section + DELETE = 68, + END = 69, + HELP = 70, + HOME = 71, + INSERT = 72, + PAGE_DOWN = 73, + PAGE_UP = 74, + + // Arrow Pad Section + DOWN = 75, + LEFT = 76, + RIGHT = 77, + UP = 78, + + // Numpad Section + NUM_LOCK = 79, + KP_0 = 80, + KP_1 = 81, + KP_2 = 82, + KP_3 = 83, + KP_4 = 84, + KP_5 = 85, + KP_6 = 86, + KP_7 = 87, + KP_8 = 88, + KP_9 = 89, + KP_PLUS = 90, // Keypad + + KP_BACKSPACE = 91, + KP_CLEAR = 92, + KP_CLEAR_ENTRY = 93, + KP_COMMA = 94, + KP_PERIOD = 95, // Keypad . + KP_DIVIDE = 96, // Keypad / + KP_ENTER = 97, // Keypad Enter + KP_EQUAL = 98, + KP_MEMORY_ADD = 99, + KP_MEMORY_CLEAR = 100, + KP_MEMORY_RECALL = 101, + KP_MEMORY_STORE = 102, + KP_MEMORY_SUBTRACT = 103, + KP_MULTIPLY = 104, // Keypad * + KP_PAREN_LEFT = 105, + KP_PAREN_RIGHT = 106, + KP_MINUS = 107, // Keypad - + KP_SEPARATOR = 108, + NUMPAD_UP = 109, + NUMPAD_DOWN = 110, + NUMPAD_RIGHT = 111, + NUMPAD_LEFT = 112, + NUMPAD_BEGIN = 113, + NUMPAD_HOME = 114, + NUMPAD_END = 115, + NUMPAD_INSERT = 116, + NUMPAD_DELETE = 117, + NUMPAD_PAGE_UP = 118, + NUMPAD_PAGE_DOWN = 119, + + // Function Keys + ESCAPE = 120, + F1 = 121, + F2 = 122, + F3 = 123, + F4 = 124, + F5 = 125, + F6 = 126, + F7 = 127, + F8 = 128, + F9 = 129, + F10 = 130, + F11 = 131, + F12 = 132, + F13 = 133, + F14 = 134, + F15 = 135, + F16 = 136, + F17 = 137, + F18 = 138, + F19 = 139, + F20 = 140, + F21 = 141, + F22 = 142, + F23 = 143, + F24 = 144, + F25 = 145, + FN_LOCK = 146, + PRINT_SCREEN = 147, + SCROLL_LOCK = 148, + PAUSE = 149, + + // Media Keys + BROWSER_BACK = 150, + BROWSER_FAVORITES = 151, + BROWSER_FORWARD = 152, + BROWSER_HOME = 153, + BROWSER_REFRESH = 154, + BROWSER_SEARCH = 155, + BROWSER_STOP = 156, + EJECT = 157, + LAUNCH_APP_1 = 158, + LAUNCH_APP_2 = 159, + LAUNCH_MAIL = 160, + MEDIA_PLAY_PAUSE = 161, + MEDIA_SELECT = 162, + MEDIA_STOP = 163, + MEDIA_TRACK_NEXT = 164, + MEDIA_TRACK_PREVIOUS = 165, + POWER = 166, + SLEEP = 167, + AUDIO_VOLUME_DOWN = 168, + AUDIO_VOLUME_MUTE = 169, + AUDIO_VOLUME_UP = 170, + WAKE_UP = 171, + + // Clipboard Keys + COPY = 172, + CUT = 173, + PASTE = 174, +} + +/** The modifier bits of a key event, as Ghostty's key encoder reads them. */ +export enum Mods { + NONE = 0, + SHIFT = 1 << 0, + CTRL = 1 << 1, + ALT = 1 << 2, + SUPER = 1 << 3, // Windows/Command key + CAPSLOCK = 1 << 4, + NUMLOCK = 1 << 5, +} + +/** One key event as the input handler hands it to the key encoder. */ +export interface KeyEvent { + action: KeyAction; + key: Key; + mods: Mods; + utf8?: string; +} diff --git a/src/client/vendor/ghostty-web/link-types.ts b/src/client/vendor/ghostty-web/link-types.ts new file mode 100644 index 0000000..9fa77ec --- /dev/null +++ b/src/client/vendor/ghostty-web/link-types.ts @@ -0,0 +1,46 @@ +// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The link detection contract (types): buffer positions and ranges, a detected link, and the +// provider interface the OSC 8 and URL regex providers implement (ADR 061). + +/** A cell in the buffer, by absolute row, where links are found and hovered. */ +export interface IBufferCellPosition { + x: number; // Column (0-based) + y: number; // Row (0-based, absolute buffer position) +} + +/** The cells a link covers; it can span rows when the link wraps. */ +export interface IBufferRange { + start: IBufferCellPosition; + end: IBufferCellPosition; // Inclusive +} + +/** A detected link: what the renderer underlines and a modifier click opens. */ +export interface ILink { + /** The URL or text of the link */ + text: string; + + /** The range of the link in the buffer (may span multiple lines) */ + range: IBufferRange; + + /** Called when the link is activated (clicked with modifier) */ + activate(event: MouseEvent): void; + + /** Optional: called when mouse enters/leaves the link */ + hover?(isHovered: boolean): void; +} + +/** + * Finds one kind of link on a row (OSC 8 hyperlinks, URLs by regex), so the link detector + * can ask each provider in turn. + */ +export interface ILinkProvider { + /** + * Provide links for a given row + * @param y Absolute row in buffer (0-based) + * @param callback Called with detected links (or undefined if none) + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; + + /** Optional: called when terminal is disposed */ + dispose?(): void; +} diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts index 4ed0d6b..7b11a1b 100644 --- a/src/client/vendor/ghostty-web/types.ts +++ b/src/client/vendor/ghostty-web/types.ts @@ -1,399 +1,11 @@ -/** - * TypeScript type definitions for libghostty-vt WASM API - * Based on include/ghostty/vt/*.h from Ghostty repository - */ - -// ============================================================================ -// Key Encoder Types -// ============================================================================ - -/** - * Key encoder options - */ -export enum KeyEncoderOption { - CURSOR_KEY_APPLICATION = 0, // DEC mode 1 - KEYPAD_KEY_APPLICATION = 1, // DEC mode 66 - IGNORE_KEYPAD_WITH_NUMLOCK = 2, // DEC mode 1035 - ALT_ESC_PREFIX = 3, // DEC mode 1036 - MODIFY_OTHER_KEYS_STATE_2 = 4, // xterm modifyOtherKeys - KITTY_KEYBOARD_FLAGS = 5, // Kitty protocol flags -} - -/** - * Key action - */ -export enum KeyAction { - RELEASE = 0, - PRESS = 1, - REPEAT = 2, -} - -/** - * Physical key codes matching Ghostty's internal Key enum. - * These values are used by Ghostty's key encoder to produce correct escape sequences. - * Reference: ghostty/src/input/key.zig - */ -export enum Key { - // Unidentified key - UNIDENTIFIED = 0, - - // Writing System Keys - GRAVE = 1, // ` and ~ - BACKSLASH = 2, // \ and | - BRACKET_LEFT = 3, // [ and { - BRACKET_RIGHT = 4, // ] and } - COMMA = 5, // , and < - ZERO = 6, - ONE = 7, - TWO = 8, - THREE = 9, - FOUR = 10, - FIVE = 11, - SIX = 12, - SEVEN = 13, - EIGHT = 14, - NINE = 15, - EQUAL = 16, // = and + - INTL_BACKSLASH = 17, - INTL_RO = 18, - INTL_YEN = 19, - A = 20, - B = 21, - C = 22, - D = 23, - E = 24, - F = 25, - G = 26, - H = 27, - I = 28, - J = 29, - K = 30, - L = 31, - M = 32, - N = 33, - O = 34, - P = 35, - Q = 36, - R = 37, - S = 38, - T = 39, - U = 40, - V = 41, - W = 42, - X = 43, - Y = 44, - Z = 45, - MINUS = 46, // - and _ - PERIOD = 47, // . and > - QUOTE = 48, // ' and " - SEMICOLON = 49, // ; and : - SLASH = 50, // / and ? - - // Functional Keys - ALT_LEFT = 51, - ALT_RIGHT = 52, - BACKSPACE = 53, - CAPS_LOCK = 54, - CONTEXT_MENU = 55, - CONTROL_LEFT = 56, - CONTROL_RIGHT = 57, - ENTER = 58, - META_LEFT = 59, - META_RIGHT = 60, - SHIFT_LEFT = 61, - SHIFT_RIGHT = 62, - SPACE = 63, - TAB = 64, - CONVERT = 65, - KANA_MODE = 66, - NON_CONVERT = 67, - - // Control Pad Section - DELETE = 68, - END = 69, - HELP = 70, - HOME = 71, - INSERT = 72, - PAGE_DOWN = 73, - PAGE_UP = 74, - - // Arrow Pad Section - DOWN = 75, - LEFT = 76, - RIGHT = 77, - UP = 78, - - // Numpad Section - NUM_LOCK = 79, - KP_0 = 80, - KP_1 = 81, - KP_2 = 82, - KP_3 = 83, - KP_4 = 84, - KP_5 = 85, - KP_6 = 86, - KP_7 = 87, - KP_8 = 88, - KP_9 = 89, - KP_PLUS = 90, // Keypad + - KP_BACKSPACE = 91, - KP_CLEAR = 92, - KP_CLEAR_ENTRY = 93, - KP_COMMA = 94, - KP_PERIOD = 95, // Keypad . - KP_DIVIDE = 96, // Keypad / - KP_ENTER = 97, // Keypad Enter - KP_EQUAL = 98, - KP_MEMORY_ADD = 99, - KP_MEMORY_CLEAR = 100, - KP_MEMORY_RECALL = 101, - KP_MEMORY_STORE = 102, - KP_MEMORY_SUBTRACT = 103, - KP_MULTIPLY = 104, // Keypad * - KP_PAREN_LEFT = 105, - KP_PAREN_RIGHT = 106, - KP_MINUS = 107, // Keypad - - KP_SEPARATOR = 108, - NUMPAD_UP = 109, - NUMPAD_DOWN = 110, - NUMPAD_RIGHT = 111, - NUMPAD_LEFT = 112, - NUMPAD_BEGIN = 113, - NUMPAD_HOME = 114, - NUMPAD_END = 115, - NUMPAD_INSERT = 116, - NUMPAD_DELETE = 117, - NUMPAD_PAGE_UP = 118, - NUMPAD_PAGE_DOWN = 119, - - // Function Keys - ESCAPE = 120, - F1 = 121, - F2 = 122, - F3 = 123, - F4 = 124, - F5 = 125, - F6 = 126, - F7 = 127, - F8 = 128, - F9 = 129, - F10 = 130, - F11 = 131, - F12 = 132, - F13 = 133, - F14 = 134, - F15 = 135, - F16 = 136, - F17 = 137, - F18 = 138, - F19 = 139, - F20 = 140, - F21 = 141, - F22 = 142, - F23 = 143, - F24 = 144, - F25 = 145, - FN_LOCK = 146, - PRINT_SCREEN = 147, - SCROLL_LOCK = 148, - PAUSE = 149, - - // Media Keys - BROWSER_BACK = 150, - BROWSER_FAVORITES = 151, - BROWSER_FORWARD = 152, - BROWSER_HOME = 153, - BROWSER_REFRESH = 154, - BROWSER_SEARCH = 155, - BROWSER_STOP = 156, - EJECT = 157, - LAUNCH_APP_1 = 158, - LAUNCH_APP_2 = 159, - LAUNCH_MAIL = 160, - MEDIA_PLAY_PAUSE = 161, - MEDIA_SELECT = 162, - MEDIA_STOP = 163, - MEDIA_TRACK_NEXT = 164, - MEDIA_TRACK_PREVIOUS = 165, - POWER = 166, - SLEEP = 167, - AUDIO_VOLUME_DOWN = 168, - AUDIO_VOLUME_MUTE = 169, - AUDIO_VOLUME_UP = 170, - WAKE_UP = 171, - - // Clipboard Keys - COPY = 172, - CUT = 173, - PASTE = 174, -} - -/** - * Modifier keys - */ -export enum Mods { - NONE = 0, - SHIFT = 1 << 0, - CTRL = 1 << 1, - ALT = 1 << 2, - SUPER = 1 << 3, // Windows/Command key - CAPSLOCK = 1 << 4, - NUMLOCK = 1 << 5, -} - -/** - * Key event structure - */ -export interface KeyEvent { - action: KeyAction; - key: Key; - mods: Mods; - utf8?: string; -} - -// ============================================================================ -// Terminal Types -// ============================================================================ - -/** - * Dirty state from RenderState - */ -export enum DirtyState { - NONE = 0, - PARTIAL = 1, - FULL = 2, -} - -/** - * Cursor state from RenderState - */ -export interface RenderStateCursor { - x: number; - y: number; - viewportX: number; // -1 if not in viewport - viewportY: number; - visible: boolean; - blinking: boolean; - style: 'block' | 'underline' | 'bar'; -} - -/** - * Terminal configuration, applied when the terminal is created. Colours are - * 0xRRGGBB; undefined leaves one unset, so 0 is black. (webtty, ADR 047) - */ -export interface GhosttyTerminalConfig { - /** - * History in lines, with a 32 MiB ceiling; 0 = no line limit. Default 10,000. - * (webtty, ADR 046) - */ - scrollbackLines?: number; - fgColor?: number; - bgColor?: number; - cursorColor?: number; - /** The 16 ANSI colours; an undefined entry keeps Ghostty's default. */ - palette?: (number | undefined)[]; - /** - * The default cursor shape and blink, which DECSCUSR Ps 0 and RIS restore. Default - * block, not blinking. (webtty, ADR 049) - */ - cursorStyle?: 'block' | 'underline' | 'bar'; - cursorBlink?: boolean; - /** - * Whether programs may write the clipboard (OSC 52, OSC 1337 Copy, OSC 5522): the - * writes are reported by `readEffects`. Off, Ghostty's clipboard write callback is - * left unset and the writes are ignored. Default true. (webtty, ADR 052) - */ - clipboardWrite?: boolean; - /** - * Whether programs may ask for desktop notifications (OSC 9, OSC 777 `notify`): they - * are reported by `readEffects`. Off, Ghostty's desktop notification callback is left - * unset and the requests are ignored. Default true. (webtty, ADR 056) - */ - desktopNotifications?: boolean; -} - -/** - * A cell as the binding decodes it from Ghostty's render state (webtty: the field - * comments give ghostty-web's original C sizes) - */ -export interface GhosttyCell { - codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) - fg_r: number; // u8 (foreground red) - fg_g: number; // u8 (foreground green) - fg_b: number; // u8 (foreground blue) - bg_r: number; // u8 (background red) - bg_g: number; // u8 (background green) - bg_b: number; // u8 (background blue) - flags: number; // u8 (style flags bitfield) - width: number; // u8 (character width: 1=normal, 2=wide, etc.) - hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set) - grapheme_len: number; // u8 (number of extra codepoints beyond first) -} - -/** - * Cell style flags (bitfield) - */ -export enum CellFlags { - BOLD = 1 << 0, - ITALIC = 1 << 1, - UNDERLINE = 1 << 2, - STRIKETHROUGH = 1 << 3, - INVERSE = 1 << 4, - INVISIBLE = 1 << 5, - BLINK = 1 << 6, - FAINT = 1 << 7, -} - -// ============================================================================ -// Link Detection System -// ============================================================================ - -/** - * Represents a coordinate in the terminal buffer - */ -export interface IBufferCellPosition { - x: number; // Column (0-based) - y: number; // Row (0-based, absolute buffer position) -} - -/** - * Represents a range in the terminal buffer - * Can span multiple lines for wrapped links - */ -export interface IBufferRange { - start: IBufferCellPosition; - end: IBufferCellPosition; // Inclusive -} - -/** - * Represents a detected link in the terminal - */ -export interface ILink { - /** The URL or text of the link */ - text: string; - - /** The range of the link in the buffer (may span multiple lines) */ - range: IBufferRange; - - /** Called when the link is activated (clicked with modifier) */ - activate(event: MouseEvent): void; - - /** Optional: called when mouse enters/leaves the link */ - hover?(isHovered: boolean): void; -} - -/** - * Provides link detection for a specific type of link - * Examples: OSC 8 hyperlinks, URL regex detection - */ -export interface ILinkProvider { - /** - * Provide links for a given row - * @param y Absolute row in buffer (0-based) - * @param callback Called with detected links (or undefined if none) - */ - provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; - - /** Optional: called when terminal is disposed */ - dispose?(): void; -} +// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// A re-export shim (types) for the files that still import './types': the definitions live +// in input/key-types.ts, engine/cell-types.ts, engine/engine-types.ts and link-types.ts. +// V15 moves the importers to those paths and removes this file (ADR 061). + +export { Key, KeyAction, KeyEncoderOption, Mods } from './input/key-types'; +export type { KeyEvent } from './input/key-types'; +export { CellFlags, DirtyState } from './engine/cell-types'; +export type { GhosttyCell, RenderStateCursor } from './engine/cell-types'; +export type { GhosttyTerminalConfig } from './engine/engine-types'; +export type { IBufferCellPosition, IBufferRange, ILink, ILinkProvider } from './link-types'; From e44f9ff23f3c91a25ce7e82e2136e0e4724076d6 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:23:36 -0400 Subject: [PATCH 228/295] refactor(client): event emitter docs, with its behaviour pinned by tests event-emitter.ts gets the Coder derivation header and a JSDoc line per member (ADR 061); the code is unchanged. Its new tests pin subscription order, unsubscribe (once, and a second dispose a no-op), a listener subscribed twice, dispose during a fire, and upstream's self-unsubscribe during a fire, which makes the next listener miss that fire because fire walks the live list; that stays as it is, since behaviour is frozen. --- .../vendor/ghostty-web/event-emitter.ts | 14 +++- src/client/vendor/tests/event-emitter.test.ts | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/client/vendor/tests/event-emitter.test.ts diff --git a/src/client/vendor/ghostty-web/event-emitter.ts b/src/client/vendor/ghostty-web/event-emitter.ts index fa58ab5..7118671 100644 --- a/src/client/vendor/ghostty-web/event-emitter.ts +++ b/src/client/vendor/ghostty-web/event-emitter.ts @@ -1,15 +1,26 @@ +// Derived from coder/ghostty-web lib/event-emitter.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// A typed event source (object): the terminal and the selection manager expose each of +// their xterm-style `onX` events as one emitter's `event` (ADR 061). + import type { IDisposable, IEvent } from './interfaces'; +/** + * One event's listeners, called in subscription order. `fire` walks the live list, so a + * listener that unsubscribes itself while it runs makes the next listener miss that fire + * (as upstream does; pinned by vendor/tests/event-emitter.test.ts). + */ export class EventEmitter<T> { private listeners: Array<(arg: T) => void> = []; + /** Calls every listener with `arg`. */ fire(arg: T): void { for (const listener of this.listeners) { listener(arg); } } - event: IEvent<T> = (listener) => { + /** Subscribes a listener; the returned disposable removes that one subscription. */ + event: IEvent<T> = (listener): IDisposable => { this.listeners.push(listener); return { dispose: () => { @@ -21,6 +32,7 @@ export class EventEmitter<T> { }; }; + /** Drops every listener, for the owner's own `dispose`. */ dispose(): void { this.listeners = []; } diff --git a/src/client/vendor/tests/event-emitter.test.ts b/src/client/vendor/tests/event-emitter.test.ts new file mode 100644 index 0000000..c4ccff6 --- /dev/null +++ b/src/client/vendor/tests/event-emitter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test'; +import { EventEmitter } from '../ghostty-web/event-emitter'; + +describe('EventEmitter', () => { + test('fires to every listener in subscription order', () => { + const emitter = new EventEmitter<number>(); + const calls: string[] = []; + emitter.event((n) => calls.push(`a${n}`)); + emitter.event((n) => calls.push(`b${n}`)); + emitter.fire(1); + emitter.fire(2); + expect(calls).toEqual(['a1', 'b1', 'a2', 'b2']); + }); + + test('a subscription disposed stops only that listener, and a second dispose does nothing', () => { + const emitter = new EventEmitter<string>(); + const calls: string[] = []; + const a = emitter.event((s) => calls.push(`a:${s}`)); + emitter.event((s) => calls.push(`b:${s}`)); + a.dispose(); + a.dispose(); + emitter.fire('x'); + expect(calls).toEqual(['b:x']); + }); + + test('the same listener subscribed twice is called twice and unsubscribed once per dispose', () => { + const emitter = new EventEmitter<void>(); + let calls = 0; + const listener = () => calls++; + const first = emitter.event(listener); + emitter.event(listener); + emitter.fire(); + expect(calls).toBe(2); + first.dispose(); + emitter.fire(); + expect(calls).toBe(3); + }); + + test('a listener that unsubscribes itself during a fire makes the next one miss it', () => { + // Upstream's behaviour, kept: fire walks the live list, which the splice shifts. + const emitter = new EventEmitter<number>(); + const calls: string[] = []; + const self = emitter.event((n) => { + calls.push(`self${n}`); + self.dispose(); + }); + emitter.event((n) => calls.push(`next${n}`)); + emitter.event((n) => calls.push(`last${n}`)); + emitter.fire(1); + emitter.fire(2); + expect(calls).toEqual(['self1', 'last1', 'next2', 'last2']); + }); + + test('dispose drops every listener; a fire under way still reaches the rest', () => { + const emitter = new EventEmitter<number>(); + const calls: string[] = []; + emitter.event((n) => { + calls.push(`a${n}`); + emitter.dispose(); + }); + emitter.event((n) => calls.push(`b${n}`)); + emitter.fire(1); + emitter.fire(2); + expect(calls).toEqual(['a1', 'b1']); + }); + + test('a subscription disposed after the emitter is disposed does nothing', () => { + const emitter = new EventEmitter<number>(); + const sub = emitter.event(() => {}); + emitter.dispose(); + sub.dispose(); + const calls: number[] = []; + emitter.event((n) => calls.push(n)); + emitter.fire(7); + expect(calls).toEqual([7]); + }); +}); From b735dfa3edcbb796617a40b3477113f274a4fbed Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:32:46 -0400 Subject: [PATCH 229/295] refactor(client): engine split, part 1: pure engine modules out of ghostty.ts The parts of the binding that make no terminal calls leave ghostty.ts for engine/ (ADR 061), with their bodies unchanged: - engine-types.ts: GhosttyTerminal, KeyEncoder and the values they take (MouseInput, MouseGeometry, ScreenRange, SearchState, TerminalEffects...); - wasm-exports.ts: the OfficialWasmExports typing; - vt-layout.ts: readBits, VtLayout and the manifest read of the Ghostty constructor, now readVtLayout; - official-layout.ts: resolveOfficialLayout and OfficialLayout, plus a named Pick type per binding feature for the later steps; - key-tables.ts: the key and encoder option rename tables and translateEnum; - scratch.ts: Scratch, typed on the two allocator exports it calls; - cursor-blink.ts, text-mime.ts, kitty-keyboard-reply.ts: the pure helpers. ghostty.ts imports them and keeps exporting what it exported. PROVENANCE.md records the moves and lists the files as webtty-original; the SPEC tree gains kitty-keyboard-reply.ts; the ratchet entry for ghostty.ts drops to 2160. official-replay.test.ts, 5 runs each (readBits is on the decode path): before 905, 894, 899, 888, 902 ms; after 892, 873, 875, 910, 933 ms. --- docs/specs/architecture.md | 2 +- scripts/architecture/ratchet-allowlist.ts | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 12 +- .../vendor/ghostty-web/engine/cursor-blink.ts | 33 + .../vendor/ghostty-web/engine/engine-types.ts | 233 ++++- .../vendor/ghostty-web/engine/key-tables.ts | 78 ++ .../engine/kitty-keyboard-reply.ts | 18 + .../ghostty-web/engine/official-layout.ts | 388 +++++++ .../vendor/ghostty-web/engine/scratch.ts | 51 + .../vendor/ghostty-web/engine/text-mime.ts | 21 + .../vendor/ghostty-web/engine/vt-layout.ts | 102 ++ .../vendor/ghostty-web/engine/wasm-exports.ts | 135 +++ src/client/vendor/ghostty-web/ghostty.ts | 989 +----------------- 13 files changed, 1114 insertions(+), 950 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/cursor-blink.ts create mode 100644 src/client/vendor/ghostty-web/engine/key-tables.ts create mode 100644 src/client/vendor/ghostty-web/engine/kitty-keyboard-reply.ts create mode 100644 src/client/vendor/ghostty-web/engine/official-layout.ts create mode 100644 src/client/vendor/ghostty-web/engine/scratch.ts create mode 100644 src/client/vendor/ghostty-web/engine/text-mime.ts create mode 100644 src/client/vendor/ghostty-web/engine/vt-layout.ts create mode 100644 src/client/vendor/ghostty-web/engine/wasm-exports.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index e3dc21e..95f4d0d 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -198,7 +198,7 @@ ghostty-web/ wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts scrollback-pages.ts selection-binding.ts search-binding.ts mouse-encoder-binding.ts paste-focus-binding.ts key-encoder-binding.ts engine-effects.ts (FnTable callbacks, clipboard write, readTitle) grid-refs.ts (grid and screen refs, hyperlinkAt, graphemes) - engine-config.ts (applyConfig, colour reads and writes) cursor-blink.ts text-mime.ts + engine-config.ts (applyConfig, colour reads and writes) cursor-blink.ts text-mime.ts kitty-keyboard-reply.ts core/ Terminal's collaborators; import core/host-types.ts, never terminal.ts host-types.ts terminal-constants.ts terminal-geometry.ts options-proxy.ts dom-setup.ts viewport.ts focus-tracker.ts find-controller.ts write-pipeline.ts render-loop.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 1b5654d..eb1b5c2 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -17,7 +17,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 3061, step: ENGINE_SPLIT }, + 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 2160, step: ENGINE_SPLIT }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, step: 'vendor input split into input/ (ADR 061)', diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 8f34dd8..2cdef94 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -54,12 +54,13 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. +- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). -- `engine/`: the binding to the official wasm (`cell-types.ts`, `engine-types.ts`). +- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -73,3 +74,12 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `types.ts`: `DirtyState`, `RenderStateCursor`, `GhosttyCell`, `CellFlags` | `engine/cell-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | | `types.ts`: `GhosttyTerminalConfig` | `engine/engine-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | | `types.ts`: `IBufferCellPosition`, `IBufferRange`, `ILink`, `ILinkProvider` | `link-types.ts` | refactor(client): the vendor types.ts split into domain-named type files | +| `ghostty.ts`: `MouseButton`, `MouseInput`, `MouseGeometry`, `ScreenCell`, `ScreenRange`, `SearchState`, `DesktopNotification`, `TerminalEffects`, `GhosttyTerminal`, `KeyEncoder` (re-exported by `ghostty.ts`) | `engine/engine-types.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `OfficialWasmExports` (webtty) | `engine/wasm-exports.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `ManifestBits`, `ManifestType`, `BitField`, `readBits`, `VtLayout`, and the manifest read of the `Ghostty` constructor as `readVtLayout` (webtty) | `engine/vt-layout.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `resolveOfficialLayout`, `OfficialLayout`, now with a named type per feature (webtty) | `engine/official-layout.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `OFFICIAL_KEY_NAMES`, `OFFICIAL_ENCODER_OPTION_NAMES`, `translateEnum` (webtty) | `engine/key-tables.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `Scratch` (webtty) | `engine/scratch.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `CURSOR_BLINK_SEQ`, `CURSOR_BLINK_PREFIX`, `scanCursorBlink` (webtty, re-exported by `ghostty.ts`) | `engine/cursor-blink.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `TEXT_MIMES`, `isTextMime` (webtty, re-exported by `ghostty.ts`) | `engine/text-mime.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `isKittyKeyboardReply` (webtty) | `engine/kitty-keyboard-reply.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | diff --git a/src/client/vendor/ghostty-web/engine/cursor-blink.ts b/src/client/vendor/ghostty-web/engine/cursor-blink.ts new file mode 100644 index 0000000..1213865 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/cursor-blink.ts @@ -0,0 +1,33 @@ +// webtty (ADR 049, ADR 061), not from upstream. +// The cursor blink scanner (util): finds the sequences that set the blink while Ghostty's +// DEC mode 12 is ignored, carrying an unfinished one across output chunks. + +// What sets the cursor blink while mode 12 is ignored: DECSCUSR (CSI Ps SP q), DECSTR +// (CSI ! p) and RIS (ESC c). The last two restore the default, as Ps 0 does. +const CURSOR_BLINK_SEQ = /\x1b(?:\[(\d*) q|\[!p|c)/g; +const CURSOR_BLINK_PREFIX = /^\x1b(?:\[(?:\d{0,3} ?|!)?)?$/; + +/** + * Scans output for the sequences that set the cursor blink while DEC mode 12 is + * ignored, and returns the last one as a DECSCUSR Ps from 0 to 6 (DECSTR and RIS + * as 0; undefined if none), plus the unfinished sequence at the end of the chunk to + * pass back in as `carry` with the next one. Most chunks hold none of them and are + * not scanned. (webtty, ADR 049: Ghostty ignores mode 12 when `cursor-style-blink` + * is set, in its app's stream handler, which libghostty-vt does not include.) + */ +export function scanCursorBlink( + data: string, + carry: string = '' +): { ps: number | undefined; carry: string } { + const text = carry + data; + let ps: number | undefined; + if (carry !== '' || text.includes(' q') || text.includes('!p') || text.includes('\x1bc')) { + for (const match of text.matchAll(CURSOR_BLINK_SEQ)) { + const value = match[1] === undefined || match[1] === '' ? 0 : Number(match[1]); + if (value <= 6) ps = value; + } + } + const esc = text.lastIndexOf('\x1b'); + const tail = esc >= 0 && text.length - esc <= 6 ? text.slice(esc) : ''; + return { ps, carry: CURSOR_BLINK_PREFIX.test(tail) ? tail : '' }; +} diff --git a/src/client/vendor/ghostty-web/engine/engine-types.ts b/src/client/vendor/ghostty-web/engine/engine-types.ts index 61f5b34..a53adf3 100644 --- a/src/client/vendor/ghostty-web/engine/engine-types.ts +++ b/src/client/vendor/ghostty-web/engine/engine-types.ts @@ -1,6 +1,10 @@ // Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). -// The engine's narrow interfaces (types): what the split parts of the vendored layer import -// instead of the binding's classes (ADR 061). For now the terminal's creation config. +// The engine's narrow interfaces (types): the terminal's creation config, the +// `GhosttyTerminal` and `KeyEncoder` contracts the rest of the vendored layer uses, and the +// values they take and return. Split parts import these instead of the binding's classes (ADR 061). + +import type { DirtyState, GhosttyCell, RenderStateCursor } from './cell-types'; +import type { KeyEncoderOption, KeyEvent } from '../input/key-types'; /** * Terminal configuration, applied when the terminal is created. Colours are @@ -36,3 +40,228 @@ export interface GhosttyTerminalConfig { */ desktopNotifications?: boolean; } + +/** A mouse button as Ghostty names it: four/five are the wheel, eight/nine back/forward. */ +export type MouseButton = 'left' | 'middle' | 'right' | 'four' | 'five' | 'eight' | 'nine'; + +/** One mouse event for `encodeMouse` (ADR 049). */ +export interface MouseInput { + action: 'press' | 'release' | 'motion'; + /** The button pressed or released, or the lowest one held for motion; null for none. */ + button: MouseButton | null; + /** `Mods` bits; Ghostty reports Shift, Alt and Ctrl. */ + mods: number; + /** Position in the units of `MouseGeometry`, from the screen's top-left corner. */ + x: number; + y: number; + /** Whether a button is held after this event: motion outside the screen is reported only then. */ + anyButtonPressed: boolean; +} + +/** + * The surface the mouse encoder maps positions on (ADR 049): the screen is the + * grid plus the padding around it, in whole pixels, with a whole cell size. + */ +export interface MouseGeometry { + screenWidth: number; + screenHeight: number; + cellWidth: number; + cellHeight: number; + paddingTop: number; + paddingRight: number; + paddingBottom: number; + paddingLeft: number; +} + +/** + * A cell by screen row (ADR 050): row 0 is the oldest scrollback row and + * `getScrollbackLength()` the top row of the active screen, as Ghostty's `SCREEN` points. + */ +export interface ScreenCell { + col: number; + row: number; +} + +/** A range of cells in screen rows, both ends inclusive. */ +export interface ScreenRange { + start: ScreenCell; + end: ScreenCell; +} + +/** Where a search is (ADR 055): its match count and the selected match's index, 0 the newest. */ +export interface SearchState { + total: number; + selected: number | null; +} + +/** A desktop notification a program asked for (OSC 9, OSC 777). (webtty, ADR 056) */ +export interface DesktopNotification { + /** `''` when the sequence has none (OSC 9). */ + title: string; + body: string; +} + +/** + * Bell, title, clipboard writes and desktop notifications since the last `readEffects` + * (ADR 042, ADR 052, ADR 056). + */ +export interface TerminalEffects { + bell: boolean; + title: string | null; + /** + * The text of the last clipboard write the program made (OSC 52, OSC 1337 Copy or + * OSC 5522) to the standard clipboard, `''` for a clear, or null for none. Only set + * while the terminal was created with `clipboardWrite` on. (webtty, ADR 052) + */ + clipboard: string | null; + /** + * The desktop notifications the program asked for (OSC 9 and OSC 777 `notify`), in + * order. Only set while the terminal was created with `desktopNotifications` on. + * (webtty, ADR 056) + */ + notifications: DesktopNotification[]; +} + +/** + * The terminal surface the renderer, selection, buffer, input handler and + * terminal.ts use. `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` + * use active-screen rows, and `getScrollbackLine(i)` counts from the oldest + * scrollback row. + */ +export interface GhosttyTerminal { + readonly cols: number; + readonly rows: number; + + // Lifecycle + write(data: string | Uint8Array): void; + /** + * Writes the shortest start of `data` that brings the parser to ground, outside any + * sequence (ESC, CSI, OSC, DCS, UTF-8), and returns its length: 0 when already there. + * Null when all of `data` was written without reaching it. (ADR 054) + */ + writeUntilGround(data: Uint8Array): number | null; + /** Grid resize, with the device-pixel cell size for size reports when known. */ + resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void; + /** Device-pixel cell size for size reports. */ + setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void; + /** + * The default cursor shape and blink (DECSCUSR Ps 0, RIS). They show at once only + * while the app has not set a shape. (ADR 049) + */ + setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void; + /** + * The view's `viewportY` (lines scrolled up from the bottom). The renderer calls + * it first in every render. + */ + setViewportOffset(linesFromBottom: number): void; + free(): void; + + // Render state + update(): DirtyState; + getCursor(): RenderStateCursor; + isRowDirty(y: number): boolean; + markClean(): void; + getViewport(): GhosttyCell[]; + getLine(y: number): GhosttyCell[] | null; + needsFullRedraw(): boolean; + clearDirty(): void; + getDimensions(): { cols: number; rows: number }; + + // Modes + isAlternateScreen(): boolean; + hasMouseTracking(): boolean; + getMode(mode: number, isAnsi?: boolean): boolean; + /** + * Whether the cursor is at a shell prompt or its input, from OSC 133 marks; false + * without them and on the alternate screen. (ADR 054) + */ + cursorAtPrompt(): boolean; + /** + * Ghostty's `jump_to_prompt`: the top row (a screen row, 0 = oldest scrollback row) of + * a view whose top row is `top` after moving it by `delta` OSC 133 prompts (negative + * is up). Ghostty's own viewport is left at the bottom. (ADR 057) + */ + promptJumpRow(top: number, delta: number): number; + + // Scrollback, graphemes, hyperlinks + getScrollbackLength(): number; + getScrollbackLine(offset: number): GhosttyCell[] | null; + isRowWrapped(row: number): boolean; + getHyperlinkUri(row: number, col: number): string | null; + getScrollbackHyperlinkUri(offset: number, col: number): string | null; + getGraphemeString(row: number, col: number): string; + getScrollbackGraphemeString(offset: number, col: number): string; + + // Selection (ADR 050), in screen rows on the active screen + /** The word at a cell by Ghostty's word boundaries, soft wraps followed; null if empty. */ + selectWord(cell: ScreenCell): ScreenRange | null; + /** The line at a cell, soft wraps followed and outer whitespace left out; null when blank. */ + selectLine(cell: ScreenCell): ScreenRange | null; + /** + * The output of the command at a cell, from OSC 133 marks (Ghostty's `selectOutput`); + * null outside command output or without marks. (ADR 057) + */ + selectOutput(cell: ScreenCell): ScreenRange | null; + /** + * The text from `start` to `end` (inclusive, in either order) as Ghostty copies it: + * soft-wrapped rows joined, trailing whitespace trimmed, spacer cells skipped. + */ + selectionText(start: ScreenCell, end: ScreenCell): string; + + // Search (ADR 055), Ghostty's, on the active screen, scrollback included + /** + * Sets the text to find, matched as Ghostty does: ASCII letters case-insensitively, + * the rest exactly, soft-wrapped rows joined. `''` ends the search. + */ + setSearch(needle: string): void; + /** + * Catches the search up with the terminal, working for at most about `budgetMs`. + * Returns whether the results may have changed. + */ + searchStep(budgetMs: number): boolean; + /** Selects the next match (toward older output) or the previous one, wrapping; false if none. */ + searchSelect(direction: 'next' | 'previous'): boolean; + searchState(): SearchState; + /** The selected match in screen rows, or null. */ + searchSelectedMatch(): ScreenRange | null; + /** + * Every match, newest to oldest, as screen rows: start col, start row, end col, end + * row per match, both ends inclusive. + */ + searchMatches(): Int32Array; + + // Mouse reports (ADR 049) + /** The geometry `encodeMouse` positions are in. */ + setMouseGeometry(geometry: MouseGeometry): void; + /** + * A mouse event as the app asked for it (tracking mode and format), or no bytes when + * it is not reported: motion within the last reported cell, motion the mode does not + * report, or motion outside the screen with no button held. + */ + encodeMouse(input: MouseInput): Uint8Array; + + // Focus reports (ADR 053) + /** The mode 1004 report for a focus change, `CSI I` or `CSI O`, as Ghostty encodes it. */ + encodeFocus(focused: boolean): Uint8Array; + + // Paste (ADR 049) + /** + * Pastes text as Ghostty does (`ghostty_terminal_paste`): bracketed while mode 2004 + * is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004. + * The bytes are queued as a reply, read with `readResponse`. + */ + paste(text: string): void; + + // Replies and effects + hasResponse(): boolean; + readResponse(): string | null; + /** Bell, title, clipboard writes and desktop notifications since the last call. */ + readEffects(): TerminalEffects; +} + +/** Converts keyboard events into terminal escape sequences. */ +export interface KeyEncoder { + setOption(option: KeyEncoderOption, value: boolean | number): void; + encode(event: KeyEvent): Uint8Array; + dispose(): void; +} diff --git a/src/client/vendor/ghostty-web/engine/key-tables.ts b/src/client/vendor/ghostty-web/engine/key-tables.ts new file mode 100644 index 0000000..85ab688 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/key-tables.ts @@ -0,0 +1,78 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The key name tables (constants + util): ghostty-web's `Key` and `KeyEncoderOption` +// names that differ from libghostty-vt's, and the by-name translation of a TS enum to +// the official enum's values from the manifest. + +import type { VtLayout } from './vt-layout'; + +/** + * Ghostty's key names for the ghostty-web `Key` names that differ (ADR 042). Every + * other `Key` keeps its name; values are always translated by name because 29 keys + * have different numbers in the two builds. + */ +export const OFFICIAL_KEY_NAMES: Readonly<Record<string, string>> = { + GRAVE: 'BACKQUOTE', + ZERO: 'DIGIT_0', + ONE: 'DIGIT_1', + TWO: 'DIGIT_2', + THREE: 'DIGIT_3', + FOUR: 'DIGIT_4', + FIVE: 'DIGIT_5', + SIX: 'DIGIT_6', + SEVEN: 'DIGIT_7', + EIGHT: 'DIGIT_8', + NINE: 'DIGIT_9', + UP: 'ARROW_UP', + DOWN: 'ARROW_DOWN', + LEFT: 'ARROW_LEFT', + RIGHT: 'ARROW_RIGHT', + KP_0: 'NUMPAD_0', + KP_1: 'NUMPAD_1', + KP_2: 'NUMPAD_2', + KP_3: 'NUMPAD_3', + KP_4: 'NUMPAD_4', + KP_5: 'NUMPAD_5', + KP_6: 'NUMPAD_6', + KP_7: 'NUMPAD_7', + KP_8: 'NUMPAD_8', + KP_9: 'NUMPAD_9', + KP_PLUS: 'NUMPAD_ADD', + KP_BACKSPACE: 'NUMPAD_BACKSPACE', + KP_CLEAR: 'NUMPAD_CLEAR', + KP_CLEAR_ENTRY: 'NUMPAD_CLEAR_ENTRY', + KP_COMMA: 'NUMPAD_COMMA', + KP_PERIOD: 'NUMPAD_DECIMAL', + KP_DIVIDE: 'NUMPAD_DIVIDE', + KP_ENTER: 'NUMPAD_ENTER', + KP_EQUAL: 'NUMPAD_EQUAL', + KP_MEMORY_ADD: 'NUMPAD_MEMORY_ADD', + KP_MEMORY_CLEAR: 'NUMPAD_MEMORY_CLEAR', + KP_MEMORY_RECALL: 'NUMPAD_MEMORY_RECALL', + KP_MEMORY_STORE: 'NUMPAD_MEMORY_STORE', + KP_MEMORY_SUBTRACT: 'NUMPAD_MEMORY_SUBTRACT', + KP_MULTIPLY: 'NUMPAD_MULTIPLY', + KP_PAREN_LEFT: 'NUMPAD_PAREN_LEFT', + KP_PAREN_RIGHT: 'NUMPAD_PAREN_RIGHT', + KP_MINUS: 'NUMPAD_SUBTRACT', + KP_SEPARATOR: 'NUMPAD_SEPARATOR', +}; + +/** Ghostty's key encoder option names for the ghostty-web names that differ. */ +export const OFFICIAL_ENCODER_OPTION_NAMES: Readonly<Record<string, string>> = { + KITTY_KEYBOARD_FLAGS: 'KITTY_FLAGS', +}; + +/** Maps a numeric TS enum's values to the official enum's values, by name. */ +export function translateEnum( + ours: Record<string, string | number>, + layout: VtLayout, + official: string, + renames: Readonly<Record<string, string>> +): Map<number, number> { + const map = new Map<number, number>(); + for (const [name, value] of Object.entries(ours)) { + if (typeof value !== 'number') continue; + map.set(value, layout.value(official, renames[name] ?? name)); + } + return map; +} diff --git a/src/client/vendor/ghostty-web/engine/kitty-keyboard-reply.ts b/src/client/vendor/ghostty-web/engine/kitty-keyboard-reply.ts new file mode 100644 index 0000000..f3a8156 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/kitty-keyboard-reply.ts @@ -0,0 +1,18 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The kitty keyboard reply check (util): the one `WRITE_PTY` reply the binding drops, +// because webtty's input handler never encodes keys with the kitty flags. + +/** + * Whether one `WRITE_PTY` reply is the kitty keyboard query reply `ESC [ ? <flags> u` + * (`queryKittyKeyboard` in Ghostty's `stream_terminal.zig`). No other reply has + * this shape: DECRQM ends in `$y`, DA in `c`. + */ +export function isKittyKeyboardReply(reply: Uint8Array): boolean { + const n = reply.length; + if (n < 5 || reply[0] !== 0x1b || reply[1] !== 0x5b || reply[2] !== 0x3f) return false; + if (reply[n - 1] !== 0x75) return false; + for (let i = 3; i < n - 1; i++) { + if (reply[i] < 0x30 || reply[i] > 0x39) return false; + } + return true; +} diff --git a/src/client/vendor/ghostty-web/engine/official-layout.ts b/src/client/vendor/ghostty-web/engine/official-layout.ts new file mode 100644 index 0000000..cb8699b --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/official-layout.ts @@ -0,0 +1,388 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The binding's layout table (constants, a data table): every enum value, struct offset +// and bit field the official binding uses, resolved from the manifest once per instance, +// with a named type per feature so each part of the binding takes only its own. + +import type { MouseButton } from './engine-types'; +import type { RenderStateCursor } from './cell-types'; +import type { VtLayout } from './vt-layout'; + +/** Enum values, offsets and bit fields the official binding uses, resolved once per instance. */ +export function resolveOfficialLayout(layout: VtLayout) { + const opt = (k: string) => layout.value('GhosttyTerminalOption', k); + const tdata = (k: string) => layout.value('GhosttyTerminalData', k); + const cellBits = layout.type('GhosttyCell').bits; + const cp = layout.armBits('GhosttyCell', 'content', 'CODEPOINT'); + const cpg = layout.armBits('GhosttyCell', 'content', 'CODEPOINT_GRAPHEME'); + const pal = layout.armBits('GhosttyCell', 'content', 'BG_COLOR_PALETTE'); + const rgb = layout.armBits('GhosttyCell', 'content', 'BG_COLOR_RGB'); + const codepoint = layout.bitField(cp, 'codepoint'); + const graphemeCodepoint = layout.bitField(cpg, 'codepoint'); + if (codepoint.lsb !== graphemeCodepoint.lsb || codepoint.mask !== graphemeCodepoint.mask) { + throw new Error('libghostty-vt GhosttyCell codepoint arms differ'); + } + return { + opt: { + WRITE_PTY: opt('WRITE_PTY'), + BELL: opt('BELL'), + TITLE_CHANGED: opt('TITLE_CHANGED'), + SIZE: opt('SIZE'), + DEVICE_ATTRIBUTES: opt('DEVICE_ATTRIBUTES'), + COLOR_FOREGROUND: opt('COLOR_FOREGROUND'), + COLOR_BACKGROUND: opt('COLOR_BACKGROUND'), + COLOR_CURSOR: opt('COLOR_CURSOR'), + COLOR_PALETTE: opt('COLOR_PALETTE'), + SCROLLBACK_MAX_BYTES: opt('SCROLLBACK_MAX_BYTES'), + SCROLLBACK_MAX_LINES: opt('SCROLLBACK_MAX_LINES'), + MODE_DEFAULT: opt('MODE_DEFAULT'), + MODE: opt('MODE'), + DEFAULT_CURSOR_STYLE: opt('DEFAULT_CURSOR_STYLE'), + DEFAULT_CURSOR_BLINK: opt('DEFAULT_CURSOR_BLINK'), + CLIPBOARD_WRITE: opt('CLIPBOARD_WRITE'), + DESKTOP_NOTIFICATION: opt('DESKTOP_NOTIFICATION'), + }, + defaultCursorStyle: { + bar: layout.value('GhosttyTerminalCursorStyle', 'BAR'), + block: layout.value('GhosttyTerminalCursorStyle', 'BLOCK'), + underline: layout.value('GhosttyTerminalCursorStyle', 'UNDERLINE'), + } satisfies Record<RenderStateCursor['style'], number>, + tdata: { + CURSOR_X: tdata('CURSOR_X'), + CURSOR_Y: tdata('CURSOR_Y'), + ACTIVE_SCREEN: tdata('ACTIVE_SCREEN'), + MOUSE_TRACKING: tdata('MOUSE_TRACKING'), + CURSOR_AT_PROMPT: tdata('CURSOR_AT_PROMPT'), + SCROLLBAR: tdata('SCROLLBAR'), + TITLE: tdata('TITLE'), + SCROLLBACK_ROWS: tdata('SCROLLBACK_ROWS'), + COLOR_PALETTE_DEFAULT: tdata('COLOR_PALETTE_DEFAULT'), + MODE: tdata('MODE'), + }, + screenAlternate: layout.value('GhosttyTerminalScreen', 'ALTERNATE'), + rs: { + DIRTY: layout.value('GhosttyRenderStateData', 'DIRTY'), + ROW_ITERATOR: layout.value('GhosttyRenderStateData', 'ROW_ITERATOR'), + CURSOR: layout.value('GhosttyRenderStateData', 'CURSOR'), + COLORS: layout.value('GhosttyRenderStateData', 'COLORS'), + }, + dirty: { + FALSE: layout.value('GhosttyRenderStateDirty', 'FALSE'), + PARTIAL: layout.value('GhosttyRenderStateDirty', 'PARTIAL'), + FULL: layout.value('GhosttyRenderStateDirty', 'FULL'), + }, + row: { + CELLS: layout.value('GhosttyRenderStateRowData', 'CELLS'), + CELLS_RAW: layout.value('GhosttyRenderStateRowData', 'CELLS_RAW'), + VIEWPORT_Y: layout.value('GhosttyRenderStateRowData', 'VIEWPORT_Y'), + }, + cellData: { + STYLE: layout.value('GhosttyRenderStateRowCellsData', 'STYLE'), + GRAPHEMES_LEN: layout.value('GhosttyRenderStateRowCellsData', 'GRAPHEMES_LEN'), + GRAPHEMES_BUF: layout.value('GhosttyRenderStateRowCellsData', 'GRAPHEMES_BUF'), + }, + rowWrapContinuation: layout.value('GhosttyRowData', 'WRAP_CONTINUATION'), + cell: { + tag: layout.bitField(cellBits, 'content_tag'), + content: layout.bitField(cellBits, 'content'), + style: layout.bitField(cellBits, 'style_id'), + wide: layout.bitField(cellBits, 'wide'), + hyperlink: layout.bitField(cellBits, 'hyperlink'), + codepoint, + paletteIndex: layout.bitField(pal, 'index'), + r: layout.bitField(rgb, 'r'), + g: layout.bitField(rgb, 'g'), + b: layout.bitField(rgb, 'b'), + }, + tag: { + CODEPOINT: layout.value('GhosttyCellContentTag', 'CODEPOINT'), + CODEPOINT_GRAPHEME: layout.value('GhosttyCellContentTag', 'CODEPOINT_GRAPHEME'), + BG_COLOR_PALETTE: layout.value('GhosttyCellContentTag', 'BG_COLOR_PALETTE'), + BG_COLOR_RGB: layout.value('GhosttyCellContentTag', 'BG_COLOR_RGB'), + }, + wide: { + NARROW: layout.value('GhosttyCellWide', 'NARROW'), + WIDE: layout.value('GhosttyCellWide', 'WIDE'), + }, + styleColor: { + NONE: layout.value('GhosttyStyleColorTag', 'NONE'), + PALETTE: layout.value('GhosttyStyleColorTag', 'PALETTE'), + RGB: layout.value('GhosttyStyleColorTag', 'RGB'), + tag: layout.offset('GhosttyStyleColor', 'tag'), + value: layout.offset('GhosttyStyleColor', 'value'), + palette: layout.offset('GhosttyStyleColorValue', 'palette'), + rgb: layout.offset('GhosttyStyleColorValue', 'rgb'), + }, + style: { + size: layout.size('GhosttyStyle'), + sizeField: layout.offset('GhosttyStyle', 'size'), + fg: layout.offset('GhosttyStyle', 'fg_color'), + bg: layout.offset('GhosttyStyle', 'bg_color'), + bold: layout.offset('GhosttyStyle', 'bold'), + italic: layout.offset('GhosttyStyle', 'italic'), + faint: layout.offset('GhosttyStyle', 'faint'), + blink: layout.offset('GhosttyStyle', 'blink'), + inverse: layout.offset('GhosttyStyle', 'inverse'), + invisible: layout.offset('GhosttyStyle', 'invisible'), + strikethrough: layout.offset('GhosttyStyle', 'strikethrough'), + underline: layout.offset('GhosttyStyle', 'underline'), + }, + cursor: { + size: layout.size('GhosttyRenderStateCursor'), + sizeField: layout.offset('GhosttyRenderStateCursor', 'size'), + visible: layout.offset('GhosttyRenderStateCursor', 'visible'), + blinking: layout.offset('GhosttyRenderStateCursor', 'blinking'), + visualStyle: layout.offset('GhosttyRenderStateCursor', 'visual_style'), + }, + cursorStyle: { + BAR: layout.value('GhosttyRenderStateCursorVisualStyle', 'BAR'), + BLOCK: layout.value('GhosttyRenderStateCursorVisualStyle', 'BLOCK'), + UNDERLINE: layout.value('GhosttyRenderStateCursorVisualStyle', 'UNDERLINE'), + }, + colors: { + size: layout.size('GhosttyRenderStateColors'), + sizeField: layout.offset('GhosttyRenderStateColors', 'size'), + background: layout.offset('GhosttyRenderStateColors', 'background'), + foreground: layout.offset('GhosttyRenderStateColors', 'foreground'), + palette: layout.offset('GhosttyRenderStateColors', 'palette'), + rgbSize: layout.size('GhosttyColorRgb'), + r: layout.offset('GhosttyColorRgb', 'r'), + g: layout.offset('GhosttyColorRgb', 'g'), + b: layout.offset('GhosttyColorRgb', 'b'), + }, + cellsView: { + size: layout.size('GhosttyCellsView'), + ptr: layout.offset('GhosttyCellsView', 'ptr'), + len: layout.offset('GhosttyCellsView', 'len'), + }, + cellSize: layout.size('GhosttyCell'), + scroll: { + size: layout.size('GhosttyTerminalScrollViewport'), + tag: layout.offset('GhosttyTerminalScrollViewport', 'tag'), + value: layout.offset('GhosttyTerminalScrollViewport', 'value'), + BOTTOM: layout.value('GhosttyTerminalScrollViewportTag', 'BOTTOM'), + ROW: layout.value('GhosttyTerminalScrollViewportTag', 'ROW'), + DELTA_PROMPT: layout.value('GhosttyTerminalScrollViewportTag', 'DELTA_PROMPT'), + }, + scrollbar: { + size: layout.size('GhosttyTerminalScrollbar'), + offset: layout.offset('GhosttyTerminalScrollbar', 'offset'), + }, + point: { + size: layout.size('GhosttyPoint'), + tag: layout.offset('GhosttyPoint', 'tag'), + value: layout.offset('GhosttyPoint', 'value'), + x: layout.offset('GhosttyPointCoordinate', 'x'), + y: layout.offset('GhosttyPointCoordinate', 'y'), + ACTIVE: layout.value('GhosttyPointTag', 'ACTIVE'), + HISTORY: layout.value('GhosttyPointTag', 'HISTORY'), + SCREEN: layout.value('GhosttyPointTag', 'SCREEN'), + }, + gridRef: { + size: layout.size('GhosttyGridRef'), + sizeField: layout.offset('GhosttyGridRef', 'size'), + }, + selection: { + size: layout.size('GhosttySelection'), + sizeField: layout.offset('GhosttySelection', 'size'), + start: layout.offset('GhosttySelection', 'start'), + end: layout.offset('GhosttySelection', 'end'), + word: { + size: layout.size('GhosttyTerminalSelectWordOptions'), + sizeField: layout.offset('GhosttyTerminalSelectWordOptions', 'size'), + ref: layout.offset('GhosttyTerminalSelectWordOptions', 'ref'), + }, + line: { + size: layout.size('GhosttyTerminalSelectLineOptions'), + sizeField: layout.offset('GhosttyTerminalSelectLineOptions', 'size'), + ref: layout.offset('GhosttyTerminalSelectLineOptions', 'ref'), + }, + format: { + size: layout.size('GhosttyTerminalSelectionFormatOptions'), + sizeField: layout.offset('GhosttyTerminalSelectionFormatOptions', 'size'), + emit: layout.offset('GhosttyTerminalSelectionFormatOptions', 'emit'), + unwrap: layout.offset('GhosttyTerminalSelectionFormatOptions', 'unwrap'), + trim: layout.offset('GhosttyTerminalSelectionFormatOptions', 'trim'), + selection: layout.offset('GhosttyTerminalSelectionFormatOptions', 'selection'), + }, + PLAIN: layout.value('GhosttyFormatterFormat', 'PLAIN'), + }, + string: { + size: layout.size('GhosttyString'), + ptr: layout.offset('GhosttyString', 'ptr'), + len: layout.offset('GhosttyString', 'len'), + }, + mode: { + size: layout.size('GhosttyTerminalModeConfig'), + mode: layout.offset('GhosttyTerminalModeConfig', 'mode'), + value: layout.offset('GhosttyTerminalModeConfig', 'value'), + }, + sizeReport: { + rows: layout.offset('GhosttySizeReportSize', 'rows'), + columns: layout.offset('GhosttySizeReportSize', 'columns'), + cellWidth: layout.offset('GhosttySizeReportSize', 'cell_width'), + cellHeight: layout.offset('GhosttySizeReportSize', 'cell_height'), + }, + da: { + primary: layout.offset('GhosttyDeviceAttributes', 'primary'), + secondary: layout.offset('GhosttyDeviceAttributes', 'secondary'), + tertiary: layout.offset('GhosttyDeviceAttributes', 'tertiary'), + conformance: layout.offset('GhosttyDeviceAttributesPrimary', 'conformance_level'), + features: layout.offset('GhosttyDeviceAttributesPrimary', 'features'), + numFeatures: layout.offset('GhosttyDeviceAttributesPrimary', 'num_features'), + deviceType: layout.offset('GhosttyDeviceAttributesSecondary', 'device_type'), + firmware: layout.offset('GhosttyDeviceAttributesSecondary', 'firmware_version'), + rom: layout.offset('GhosttyDeviceAttributesSecondary', 'rom_cartridge'), + unitId: layout.offset('GhosttyDeviceAttributesTertiary', 'unit_id'), + }, + mouse: { + size: layout.size('GhosttyMouseEncoderSize'), + sizeField: layout.offset('GhosttyMouseEncoderSize', 'size'), + screenWidth: layout.offset('GhosttyMouseEncoderSize', 'screen_width'), + screenHeight: layout.offset('GhosttyMouseEncoderSize', 'screen_height'), + cellWidth: layout.offset('GhosttyMouseEncoderSize', 'cell_width'), + cellHeight: layout.offset('GhosttyMouseEncoderSize', 'cell_height'), + paddingTop: layout.offset('GhosttyMouseEncoderSize', 'padding_top'), + paddingBottom: layout.offset('GhosttyMouseEncoderSize', 'padding_bottom'), + paddingRight: layout.offset('GhosttyMouseEncoderSize', 'padding_right'), + paddingLeft: layout.offset('GhosttyMouseEncoderSize', 'padding_left'), + positionSize: layout.size('GhosttyMousePosition'), + x: layout.offset('GhosttyMousePosition', 'x'), + y: layout.offset('GhosttyMousePosition', 'y'), + SIZE: layout.value('GhosttyMouseEncoderOption', 'SIZE'), + ANY_BUTTON_PRESSED: layout.value('GhosttyMouseEncoderOption', 'ANY_BUTTON_PRESSED'), + TRACK_LAST_CELL: layout.value('GhosttyMouseEncoderOption', 'TRACK_LAST_CELL'), + action: { + press: layout.value('GhosttyMouseAction', 'PRESS'), + release: layout.value('GhosttyMouseAction', 'RELEASE'), + motion: layout.value('GhosttyMouseAction', 'MOTION'), + }, + button: { + left: layout.value('GhosttyMouseButton', 'LEFT'), + middle: layout.value('GhosttyMouseButton', 'MIDDLE'), + right: layout.value('GhosttyMouseButton', 'RIGHT'), + four: layout.value('GhosttyMouseButton', 'FOUR'), + five: layout.value('GhosttyMouseButton', 'FIVE'), + eight: layout.value('GhosttyMouseButton', 'EIGHT'), + nine: layout.value('GhosttyMouseButton', 'NINE'), + } satisfies Record<MouseButton, number>, + }, + paste: { + size: layout.size('GhosttyPaste'), + sizeField: layout.offset('GhosttyPaste', 'size'), + location: layout.offset('GhosttyPaste', 'location'), + source: layout.offset('GhosttyPaste', 'source'), + mimes: layout.offset('GhosttyPaste', 'mimes'), + mimesLen: layout.offset('GhosttyPaste', 'mimes_len'), + reader: layout.offset('GhosttyPaste', 'reader'), + allowUnsafe: layout.offset('GhosttyPaste', 'allow_unsafe'), + readerRead: layout.offset('GhosttyMimeReader', 'read'), + writerWrite: layout.offset('GhosttyWriter', 'write'), + writerUserdata: layout.offset('GhosttyWriter', 'userdata'), + STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), + CLIPBOARD: layout.value('GhosttyPasteSource', 'CLIPBOARD'), + }, + notification: { + size: layout.offset('GhosttyTerminalDesktopNotification', 'size'), + title: layout.offset('GhosttyTerminalDesktopNotification', 'title'), + body: layout.offset('GhosttyTerminalDesktopNotification', 'body'), + bodyEnd: layout.offset('GhosttyTerminalDesktopNotification', 'body') + layout.size('GhosttyString'), + }, + clipboardWrite: { + location: layout.offset('GhosttyClipboardWrite', 'location'), + contents: layout.offset('GhosttyClipboardWrite', 'contents'), + contentsLen: layout.offset('GhosttyClipboardWrite', 'contents_len'), + reply: layout.offset('GhosttyClipboardWrite', 'reply'), + contentSize: layout.size('GhosttyClipboardContent'), + mime: layout.offset('GhosttyClipboardContent', 'mime'), + data: layout.offset('GhosttyClipboardContent', 'data'), + replySize: layout.size('GhosttyClipboardWriteReply'), + replySizeField: layout.offset('GhosttyClipboardWriteReply', 'size'), + replyResult: layout.offset('GhosttyClipboardWriteReply', 'result'), + STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), + SUCCESS: layout.value('GhosttyClipboardWriteResult', 'SUCCESS'), + UNSUPPORTED: layout.value('GhosttyClipboardWriteResult', 'UNSUPPORTED'), + }, + search: { + bufferSize: layout.size('GhosttySelectionBuffer'), + ptr: layout.offset('GhosttySelectionBuffer', 'ptr'), + cap: layout.offset('GhosttySelectionBuffer', 'cap'), + len: layout.offset('GhosttySelectionBuffer', 'len'), + NEEDLE: layout.value('GhosttySearchOption', 'NEEDLE'), + SELECT_NEXT: layout.value('GhosttySearchOption', 'SELECT_NEXT'), + SELECT_PREV: layout.value('GhosttySearchOption', 'SELECT_PREV'), + SELECT_SCROLL: layout.value('GhosttySearchOption', 'SELECT_SCROLL'), + SCROLL_NONE: layout.value('GhosttySearchScroll', 'NONE'), + STATUS: layout.value('GhosttySearchData', 'STATUS'), + TOTAL_MATCHES: layout.value('GhosttySearchData', 'TOTAL_MATCHES'), + SELECTED_INDEX: layout.value('GhosttySearchData', 'SELECTED_INDEX'), + SELECTED_MATCH: layout.value('GhosttySearchData', 'SELECTED_MATCH'), + MATCHES: layout.value('GhosttySearchData', 'MATCHES'), + COMPLETE: layout.value('GhosttySearchStatus', 'COMPLETE'), + FEED_REQUIRED: layout.value('GhosttySearchStatus', 'FEED_REQUIRED'), + }, + focus: { + GAINED: layout.value('GhosttyFocusEvent', 'GAINED'), + LOST: layout.value('GhosttyFocusEvent', 'LOST'), + }, + result: { + SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), + OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), + }, + }; +} + +/** Everything the binding resolved from the manifest, one sub-object per feature. */ +export type OfficialLayout = ReturnType<typeof resolveOfficialLayout>; + +// The parts of the layout each feature of the binding reads, so a binding split out of +// `OfficialTerminal` (ADR 061) declares exactly the tables it depends on. + +/** Render state refresh, row and cell decoding, and scrollback pages. */ +export type RenderStateLayout = Pick< + OfficialLayout, + | 'rs' + | 'dirty' + | 'row' + | 'cellData' + | 'cell' + | 'tag' + | 'wide' + | 'styleColor' + | 'style' + | 'cursor' + | 'cursorStyle' + | 'colors' + | 'cellsView' + | 'cellSize' + | 'scroll' +>; + +/** Grid and screen refs, wrapped rows, hyperlinks and the title. */ +export type GridRefLayout = Pick< + OfficialLayout, + 'point' | 'gridRef' | 'rowWrapContinuation' | 'string' | 'tdata' | 'result' +>; + +/** Word, line and output selection and the selection text. */ +export type SelectionLayout = Pick<OfficialLayout, 'selection' | 'point' | 'gridRef' | 'string'>; + +/** Ghostty's search and its matches. */ +export type SearchLayout = Pick<OfficialLayout, 'search' | 'selection' | 'string' | 'result'>; + +/** The mouse encoder. */ +export type MouseLayout = Pick<OfficialLayout, 'mouse'>; + +/** The paste and focus encoders. */ +export type PasteFocusLayout = Pick<OfficialLayout, 'paste' | 'focus' | 'string'>; + +/** The terminal callbacks and their payloads: replies, device attributes, effects. */ +export type EffectsLayout = Pick< + OfficialLayout, + 'opt' | 'da' | 'sizeReport' | 'notification' | 'clipboardWrite' | 'string' | 'tdata' +>; + +/** Creation config, modes, colours and the default cursor. */ +export type EngineConfigLayout = Pick< + OfficialLayout, + 'opt' | 'tdata' | 'mode' | 'colors' | 'defaultCursorStyle' | 'screenAlternate' | 'scrollbar' +>; diff --git a/src/client/vendor/ghostty-web/engine/scratch.ts b/src/client/vendor/ghostty-web/engine/scratch.ts new file mode 100644 index 0000000..514967a --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/scratch.ts @@ -0,0 +1,51 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The scratch allocator (object): one wasm allocation per terminal, carved into aligned, +// named slots for the out-parameters and small structs of export calls. + +import type { OfficialWasmExports } from './wasm-exports'; + +/** The two exports a scratch block needs, so it can be tested on a fake allocator. */ +export type ScratchAllocator = Pick< + OfficialWasmExports, + 'ghostty_wasm_alloc' | 'ghostty_wasm_free' +>; + +/** Scratch memory: one wasm allocation carved into aligned, named slots. */ +export class Scratch { + readonly base: number; + readonly len: number; + private readonly ex: ScratchAllocator; + + private constructor(ex: ScratchAllocator, base: number, len: number) { + this.ex = ex; + this.base = base; + this.len = len; + } + + /** Allocates the slots; each value is [size, align]. Returns the slot addresses. */ + static create<K extends string>( + ex: ScratchAllocator, + slots: Record<K, readonly [number, number]> + ): { scratch: Scratch; at: Record<K, number> } { + let off = 0; + const offsets = {} as Record<K, number>; + for (const key of Object.keys(slots) as K[]) { + const [size, align] = slots[key]; + off = Math.ceil(off / align) * align; + offsets[key] = off; + off += size; + } + const len = off + 8; + const raw = ex.ghostty_wasm_alloc(len); + if (!raw) throw new Error('libghostty-vt: out of memory'); + const base = Math.ceil(raw / 8) * 8; + const at = {} as Record<K, number>; + for (const key of Object.keys(offsets) as K[]) at[key] = base + offsets[key]; + return { scratch: new Scratch(ex, raw, len), at }; + } + + /** Frees the one allocation; the slot addresses are invalid after. */ + free(): void { + this.ex.ghostty_wasm_free(this.base, this.len); + } +} diff --git a/src/client/vendor/ghostty-web/engine/text-mime.ts b/src/client/vendor/ghostty-web/engine/text-mime.ts new file mode 100644 index 0000000..2ca8034 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/text-mime.ts @@ -0,0 +1,21 @@ +// webtty (ADR 052, ADR 061), not from upstream. +// The clipboard text check (util): which MIME names of a program's clipboard write carry +// plain text, as Ghostty decides it. + +/** The MIME names Ghostty treats as plain text. */ +const TEXT_MIMES = new Set([ + 'text/plain', + 'text/plain;charset=utf-8', + 'UTF8_STRING', + 'TEXT', + 'STRING', +]); + +/** + * Whether a clipboard MIME type names plain text: Ghostty's `isTextMime` list + * (`src/terminal/clipboard.zig`), the names text goes by on every platform. (webtty, + * ADR 052) + */ +export function isTextMime(mime: string): boolean { + return TEXT_MIMES.has(mime); +} diff --git a/src/client/vendor/ghostty-web/engine/vt-layout.ts b/src/client/vendor/ghostty-web/engine/vt-layout.ts new file mode 100644 index 0000000..00e773f --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/vt-layout.ts @@ -0,0 +1,102 @@ +// webtty (ADR 041, ADR 042, ADR 061), not from upstream. +// The manifest reader (object + util): struct offsets, enum values and packed bit fields +// from the wasm's own `ghostty_type_json`, so nothing about the C layout is hard-coded. +// A missing name throws, so a pin bump that moves a field fails loudly. + +import type { OfficialWasmExports } from './wasm-exports'; + +/** One packed bit field in the manifest, with the arms of a packed union field. */ +export interface ManifestBits { + lsb: number; + width: number; + arms?: Record<string, { bits: Record<string, ManifestBits> } | null>; +} + +/** One type in the manifest: a struct, enum or packed value, with its size. */ +export interface ManifestType { + kind: string; + size: number; + fields?: Record<string, { offset: number; size: number }>; + values?: Record<string, number>; + bits?: Record<string, ManifestBits>; +} + +/** A bit field of a packed value: shift right by `lsb`, then mask. */ +export interface BitField { + lsb: number; + mask: number; +} + +/** Reads the bits at `lsb` (masked) from a 64-bit value split into two u32 words. */ +export function readBits(lo: number, hi: number, lsb: number, mask: number): number { + if (lsb >= 32) return (hi >>> (lsb - 32)) & mask; + if (lsb === 0) return lo & mask; + return ((lo >>> lsb) | (hi << (32 - lsb))) & mask; +} + +/** Struct layouts and enum values from the wasm's `ghostty_type_json` manifest. */ +export class VtLayout { + private readonly types: Record<string, ManifestType>; + + constructor(types: Record<string, ManifestType>) { + this.types = types; + } + + type(name: string): ManifestType { + const t = this.types[name]; + if (!t) throw new Error(`libghostty-vt manifest has no type ${name}`); + return t; + } + + size(name: string): number { + return this.type(name).size; + } + + offset(type: string, field: string): number { + const f = this.type(type).fields?.[field]; + if (!f) throw new Error(`libghostty-vt manifest has no field ${type}.${field}`); + return f.offset; + } + + enums(name: string): Record<string, number> { + const values = this.type(name).values; + if (!values) throw new Error(`libghostty-vt manifest type ${name} is not an enum`); + return values; + } + + value(name: string, key: string): number { + const v = this.enums(name)[key]; + if (v === undefined) throw new Error(`libghostty-vt manifest has no value ${name}.${key}`); + return v; + } + + bitField(bits: Record<string, ManifestBits> | undefined, path: string): BitField { + const b = bits?.[path]; + if (!b) throw new Error(`libghostty-vt manifest has no bit field ${path}`); + return { lsb: b.lsb, mask: b.width >= 32 ? 0xffffffff : 2 ** b.width - 1 }; + } + + /** The bits of one arm of a packed union field, e.g. GhosttyCell.content.CODEPOINT. */ + armBits(type: string, field: string, arm: string): Record<string, ManifestBits> { + const a = this.type(type).bits?.[field]?.arms?.[arm]; + if (!a) throw new Error(`libghostty-vt manifest has no arm ${type}.${field}.${arm}`); + return a.bits; + } +} + +/** + * Reads the wasm's `ghostty_type_json` manifest, a NUL-terminated JSON string in its + * memory, into a layout. Called once per instance, so the decode is not cached. + */ +export function readVtLayout( + ex: Pick<OfficialWasmExports, 'memory' | 'ghostty_type_json'> +): VtLayout { + const p = ex.ghostty_type_json(); + const u8 = new Uint8Array(ex.memory.buffer); + let end = p; + while (u8[end] !== 0) end++; + const manifest = JSON.parse(new TextDecoder().decode(u8.subarray(p, end))) as { + types: Record<string, ManifestType>; + }; + return new VtLayout(manifest.types); +} diff --git a/src/client/vendor/ghostty-web/engine/wasm-exports.ts b/src/client/vendor/ghostty-web/engine/wasm-exports.ts new file mode 100644 index 0000000..277c3e5 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/wasm-exports.ts @@ -0,0 +1,135 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The typing of the official libghostty-vt wasm's exports (types): one signature per C +// function the binding calls, with the by-value structs noted where wasm32 passes a pointer. + +/** + * Exports of the official wasm the binding calls (pointers, handles, size_t: i32), so + * every export call is type-checked against the C API it wraps. + */ +export interface OfficialWasmExports { + memory: WebAssembly.Memory; + __indirect_function_table: WebAssembly.Table; + ghostty_type_json(): number; + ghostty_wasm_alloc(len: number): number; + ghostty_wasm_free(ptr: number, len: number): void; + ghostty_wasm_alloc_opaque(): number; + ghostty_wasm_free_opaque(ptr: number): void; + ghostty_wasm_take_opaque(slot: number): number; + ghostty_terminal_new(allocator: number, out: number, cols: number, rows: number): number; + ghostty_terminal_free(term: number): void; + ghostty_terminal_resize( + term: number, + cols: number, + rows: number, + cellWidthPx: number, + cellHeightPx: number + ): number; + ghostty_terminal_set(term: number, option: number, value: number): number; + ghostty_terminal_get(term: number, data: number, out: number): number; + ghostty_terminal_get_multi( + term: number, + count: number, + keys: number, + values: number, + outWritten: number + ): number; + ghostty_terminal_vt_write(term: number, ptr: number, len: number): void; + ghostty_terminal_vt_write_until_ground( + term: number, + ptr: number, + len: number, + outConsumed: number + ): number; + ghostty_terminal_paste(term: number, paste: number, outWritten: number): number; + /** The GhosttyTerminalScrollViewport struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_scroll_viewport(term: number, behavior: number): void; + /** The GhosttyPoint struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_grid_ref(term: number, point: number, outRef: number): number; + ghostty_grid_ref_row(ref: number, outRow: number): number; + ghostty_terminal_point_from_grid_ref(term: number, ref: number, tag: number, out: number): number; + ghostty_terminal_select_word(term: number, options: number, outSelection: number): number; + ghostty_terminal_select_line(term: number, options: number, outSelection: number): number; + /** The GhosttyGridRef struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_select_output(term: number, ref: number, outSelection: number): number; + /** GhosttyTerminalSelectionFormatOptions is passed by value, i.e. by pointer on wasm32. */ + ghostty_terminal_selection_format_alloc( + term: number, + allocator: number, + options: number, + outPtr: number, + outLen: number + ): number; + ghostty_free(allocator: number, ptr: number, len: number): void; + ghostty_grid_ref_hyperlink_uri(ref: number, buf: number, len: number, outLen: number): number; + /** GhosttyRow is a u64 passed by value (a BigInt on the JS side). */ + ghostty_row_get(row: bigint, data: number, out: number): number; + ghostty_render_state_new(allocator: number, out: number): number; + ghostty_render_state_free(state: number): void; + ghostty_render_state_update(state: number, term: number): number; + ghostty_render_state_clean(state: number): number; + ghostty_render_state_get(state: number, data: number, out: number): number; + ghostty_render_state_get_multi( + state: number, + count: number, + keys: number, + values: number, + outWritten: number + ): number; + ghostty_render_state_row_iterator_new(allocator: number, out: number): number; + ghostty_render_state_row_iterator_free(iterator: number): void; + ghostty_render_state_row_iterator_next(iterator: number): number; + ghostty_render_state_row_iterator_next_dirty(iterator: number, outY: number): number; + ghostty_render_state_row_get_multi( + iterator: number, + count: number, + keys: number, + values: number, + outWritten: number + ): number; + ghostty_render_state_row_cells_new(allocator: number, out: number): number; + ghostty_render_state_row_cells_free(cells: number): void; + ghostty_render_state_row_cells_select(cells: number, x: number): number; + ghostty_render_state_row_cells_get(cells: number, data: number, out: number): number; + ghostty_key_encoder_new(allocator: number, out: number): number; + ghostty_key_encoder_free(encoder: number): void; + ghostty_key_encoder_setopt(encoder: number, option: number, value: number): void; + ghostty_key_encoder_encode( + encoder: number, + event: number, + buf: number, + len: number, + outLen: number + ): number; + ghostty_key_event_new(allocator: number, out: number): number; + ghostty_key_event_free(event: number): void; + ghostty_key_event_set_action(event: number, action: number): void; + ghostty_key_event_set_key(event: number, key: number): void; + ghostty_key_event_set_mods(event: number, mods: number): void; + ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; + ghostty_mouse_encoder_new(allocator: number, out: number): number; + ghostty_mouse_encoder_free(encoder: number): void; + ghostty_mouse_encoder_setopt(encoder: number, option: number, value: number): void; + ghostty_mouse_encoder_setopt_from_terminal(encoder: number, term: number): void; + ghostty_mouse_encoder_encode( + encoder: number, + event: number, + buf: number, + len: number, + outLen: number + ): number; + ghostty_mouse_event_new(allocator: number, out: number): number; + ghostty_mouse_event_free(event: number): void; + ghostty_mouse_event_set_action(event: number, action: number): void; + ghostty_mouse_event_set_button(event: number, button: number): void; + ghostty_mouse_event_clear_button(event: number): void; + ghostty_mouse_event_set_mods(event: number, mods: number): void; + /** The GhosttyMousePosition struct is passed by value, i.e. by pointer on wasm32. */ + ghostty_mouse_event_set_position(event: number, position: number): void; + ghostty_focus_encode(event: number, buf: number, len: number, outWritten: number): number; + ghostty_search_new(allocator: number, out: number, term: number): number; + ghostty_search_free(search: number): void; + ghostty_search_tick(search: number, outStatus: number): number; + ghostty_search_feed(search: number): number; + ghostty_search_set(search: number, option: number, value: number): number; + ghostty_search_get(search: number, data: number, out: number): number; +} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 7a8fc13..f79fd45 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -8,9 +8,33 @@ * webtty (ADR 041, ADR 042, ADR 047): the binding to Ghostty's official, * unpatched libghostty-vt wasm. `OfficialTerminal` implements the `GhosttyTerminal` * interface the rest of the terminal layer uses. Every wasm export call and wasm - * memory access is in this file. + * memory access is in this file and engine/, which holds the engine's interfaces, the + * export typing, the manifest layout and the pure helpers (ADR 061). */ +import { scanCursorBlink } from './engine/cursor-blink'; +import type { + DesktopNotification, + GhosttyTerminal, + KeyEncoder, + MouseGeometry, + MouseInput, + ScreenCell, + ScreenRange, + SearchState, + TerminalEffects, +} from './engine/engine-types'; +import { + OFFICIAL_ENCODER_OPTION_NAMES, + OFFICIAL_KEY_NAMES, + translateEnum, +} from './engine/key-tables'; +import { isKittyKeyboardReply } from './engine/kitty-keyboard-reply'; +import { type OfficialLayout, resolveOfficialLayout } from './engine/official-layout'; +import { Scratch } from './engine/scratch'; +import { isTextMime } from './engine/text-mime'; +import { readBits, readVtLayout } from './engine/vt-layout'; +import type { OfficialWasmExports } from './engine/wasm-exports'; import { FnTable, type InstalledFn, WasmValType } from './fn-table'; import { CellFlags, @@ -33,237 +57,24 @@ export { KeyEncoderOption, type RenderStateCursor, }; - -/** A mouse button as Ghostty names it: four/five are the wheel, eight/nine back/forward. */ -export type MouseButton = 'left' | 'middle' | 'right' | 'four' | 'five' | 'eight' | 'nine'; - -/** One mouse event for `encodeMouse` (ADR 049). */ -export interface MouseInput { - action: 'press' | 'release' | 'motion'; - /** The button pressed or released, or the lowest one held for motion; null for none. */ - button: MouseButton | null; - /** `Mods` bits; Ghostty reports Shift, Alt and Ctrl. */ - mods: number; - /** Position in the units of `MouseGeometry`, from the screen's top-left corner. */ - x: number; - y: number; - /** Whether a button is held after this event: motion outside the screen is reported only then. */ - anyButtonPressed: boolean; -} - -/** - * The surface the mouse encoder maps positions on (ADR 049): the screen is the - * grid plus the padding around it, in whole pixels, with a whole cell size. - */ -export interface MouseGeometry { - screenWidth: number; - screenHeight: number; - cellWidth: number; - cellHeight: number; - paddingTop: number; - paddingRight: number; - paddingBottom: number; - paddingLeft: number; -} - -/** - * A cell by screen row (ADR 050): row 0 is the oldest scrollback row and - * `getScrollbackLength()` the top row of the active screen, as Ghostty's `SCREEN` points. - */ -export interface ScreenCell { - col: number; - row: number; -} - -/** A range of cells in screen rows, both ends inclusive. */ -export interface ScreenRange { - start: ScreenCell; - end: ScreenCell; -} - -/** Where a search is (ADR 055): its match count and the selected match's index, 0 the newest. */ -export interface SearchState { - total: number; - selected: number | null; -} - -/** A desktop notification a program asked for (OSC 9, OSC 777). (webtty, ADR 056) */ -export interface DesktopNotification { - /** `''` when the sequence has none (OSC 9). */ - title: string; - body: string; -} - -/** - * Bell, title, clipboard writes and desktop notifications since the last `readEffects` - * (ADR 042, ADR 052, ADR 056). - */ -export interface TerminalEffects { - bell: boolean; - title: string | null; - /** - * The text of the last clipboard write the program made (OSC 52, OSC 1337 Copy or - * OSC 5522) to the standard clipboard, `''` for a clear, or null for none. Only set - * while the terminal was created with `clipboardWrite` on. (webtty, ADR 052) - */ - clipboard: string | null; - /** - * The desktop notifications the program asked for (OSC 9 and OSC 777 `notify`), in - * order. Only set while the terminal was created with `desktopNotifications` on. - * (webtty, ADR 056) - */ - notifications: DesktopNotification[]; -} - -/** - * The terminal surface the renderer, selection, buffer, input handler and - * terminal.ts use. `getLine(n)`, `getViewport`, `getCursor` and `isRowDirty(n)` - * use active-screen rows, and `getScrollbackLine(i)` counts from the oldest - * scrollback row. - */ -export interface GhosttyTerminal { - readonly cols: number; - readonly rows: number; - - // Lifecycle - write(data: string | Uint8Array): void; - /** - * Writes the shortest start of `data` that brings the parser to ground, outside any - * sequence (ESC, CSI, OSC, DCS, UTF-8), and returns its length: 0 when already there. - * Null when all of `data` was written without reaching it. (ADR 054) - */ - writeUntilGround(data: Uint8Array): number | null; - /** Grid resize, with the device-pixel cell size for size reports when known. */ - resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void; - /** Device-pixel cell size for size reports. */ - setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void; - /** - * The default cursor shape and blink (DECSCUSR Ps 0, RIS). They show at once only - * while the app has not set a shape. (ADR 049) - */ - setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void; - /** - * The view's `viewportY` (lines scrolled up from the bottom). The renderer calls - * it first in every render. - */ - setViewportOffset(linesFromBottom: number): void; - free(): void; - - // Render state - update(): DirtyState; - getCursor(): RenderStateCursor; - isRowDirty(y: number): boolean; - markClean(): void; - getViewport(): GhosttyCell[]; - getLine(y: number): GhosttyCell[] | null; - needsFullRedraw(): boolean; - clearDirty(): void; - getDimensions(): { cols: number; rows: number }; - - // Modes - isAlternateScreen(): boolean; - hasMouseTracking(): boolean; - getMode(mode: number, isAnsi?: boolean): boolean; - /** - * Whether the cursor is at a shell prompt or its input, from OSC 133 marks; false - * without them and on the alternate screen. (ADR 054) - */ - cursorAtPrompt(): boolean; - /** - * Ghostty's `jump_to_prompt`: the top row (a screen row, 0 = oldest scrollback row) of - * a view whose top row is `top` after moving it by `delta` OSC 133 prompts (negative - * is up). Ghostty's own viewport is left at the bottom. (ADR 057) - */ - promptJumpRow(top: number, delta: number): number; - - // Scrollback, graphemes, hyperlinks - getScrollbackLength(): number; - getScrollbackLine(offset: number): GhosttyCell[] | null; - isRowWrapped(row: number): boolean; - getHyperlinkUri(row: number, col: number): string | null; - getScrollbackHyperlinkUri(offset: number, col: number): string | null; - getGraphemeString(row: number, col: number): string; - getScrollbackGraphemeString(offset: number, col: number): string; - - // Selection (ADR 050), in screen rows on the active screen - /** The word at a cell by Ghostty's word boundaries, soft wraps followed; null if empty. */ - selectWord(cell: ScreenCell): ScreenRange | null; - /** The line at a cell, soft wraps followed and outer whitespace left out; null when blank. */ - selectLine(cell: ScreenCell): ScreenRange | null; - /** - * The output of the command at a cell, from OSC 133 marks (Ghostty's `selectOutput`); - * null outside command output or without marks. (ADR 057) - */ - selectOutput(cell: ScreenCell): ScreenRange | null; - /** - * The text from `start` to `end` (inclusive, in either order) as Ghostty copies it: - * soft-wrapped rows joined, trailing whitespace trimmed, spacer cells skipped. - */ - selectionText(start: ScreenCell, end: ScreenCell): string; - - // Search (ADR 055), Ghostty's, on the active screen, scrollback included - /** - * Sets the text to find, matched as Ghostty does: ASCII letters case-insensitively, - * the rest exactly, soft-wrapped rows joined. `''` ends the search. - */ - setSearch(needle: string): void; - /** - * Catches the search up with the terminal, working for at most about `budgetMs`. - * Returns whether the results may have changed. - */ - searchStep(budgetMs: number): boolean; - /** Selects the next match (toward older output) or the previous one, wrapping; false if none. */ - searchSelect(direction: 'next' | 'previous'): boolean; - searchState(): SearchState; - /** The selected match in screen rows, or null. */ - searchSelectedMatch(): ScreenRange | null; - /** - * Every match, newest to oldest, as screen rows: start col, start row, end col, end - * row per match, both ends inclusive. - */ - searchMatches(): Int32Array; - - // Mouse reports (ADR 049) - /** The geometry `encodeMouse` positions are in. */ - setMouseGeometry(geometry: MouseGeometry): void; - /** - * A mouse event as the app asked for it (tracking mode and format), or no bytes when - * it is not reported: motion within the last reported cell, motion the mode does not - * report, or motion outside the screen with no button held. - */ - encodeMouse(input: MouseInput): Uint8Array; - - // Focus reports (ADR 053) - /** The mode 1004 report for a focus change, `CSI I` or `CSI O`, as Ghostty encodes it. */ - encodeFocus(focused: boolean): Uint8Array; - - // Paste (ADR 049) - /** - * Pastes text as Ghostty does (`ghostty_terminal_paste`): bracketed while mode 2004 - * is on, unsafe control bytes replaced with spaces, newlines sent as CR without 2004. - * The bytes are queued as a reply, read with `readResponse`. - */ - paste(text: string): void; - - // Replies and effects - hasResponse(): boolean; - readResponse(): string | null; - /** Bell, title, clipboard writes and desktop notifications since the last call. */ - readEffects(): TerminalEffects; -} - -/** Converts keyboard events into terminal escape sequences. */ -export interface KeyEncoder { - setOption(option: KeyEncoderOption, value: boolean | number): void; - encode(event: KeyEvent): Uint8Array; - dispose(): void; -} - -// ============================================================================ -// Official libghostty-vt binding (ADR 041, ADR 042) -// ============================================================================ -// -// Rules (ADR 041): +// The engine's interfaces and pure helpers live in engine/ (ADR 061); this file keeps +// exporting them so its importers do not change. +export type { + DesktopNotification, + GhosttyTerminal, + KeyEncoder, + MouseButton, + MouseGeometry, + MouseInput, + ScreenCell, + ScreenRange, + SearchState, + TerminalEffects, +} from './engine/engine-types'; +export { scanCursorBlink } from './engine/cursor-blink'; +export { isTextMime } from './engine/text-mime'; + +// Rules of the official libghostty-vt binding (ADR 041, ADR 042): // - Struct offsets, enum values and the packed GhosttyCell bit positions come from // the wasm's own `ghostty_type_json` manifest at load. A missing name throws, so a // pin bump that renames or moves a field fails loudly instead of misreading memory. @@ -276,610 +87,6 @@ export interface KeyEncoder { // copy their payload before returning. // - Callbacks never re-enter `vt_write`. -/** Exports of the official wasm the binding calls (pointers, handles, size_t: i32). */ -interface OfficialWasmExports { - memory: WebAssembly.Memory; - __indirect_function_table: WebAssembly.Table; - ghostty_type_json(): number; - ghostty_wasm_alloc(len: number): number; - ghostty_wasm_free(ptr: number, len: number): void; - ghostty_wasm_alloc_opaque(): number; - ghostty_wasm_free_opaque(ptr: number): void; - ghostty_wasm_take_opaque(slot: number): number; - ghostty_terminal_new(allocator: number, out: number, cols: number, rows: number): number; - ghostty_terminal_free(term: number): void; - ghostty_terminal_resize( - term: number, - cols: number, - rows: number, - cellWidthPx: number, - cellHeightPx: number - ): number; - ghostty_terminal_set(term: number, option: number, value: number): number; - ghostty_terminal_get(term: number, data: number, out: number): number; - ghostty_terminal_get_multi( - term: number, - count: number, - keys: number, - values: number, - outWritten: number - ): number; - ghostty_terminal_vt_write(term: number, ptr: number, len: number): void; - ghostty_terminal_vt_write_until_ground( - term: number, - ptr: number, - len: number, - outConsumed: number - ): number; - ghostty_terminal_paste(term: number, paste: number, outWritten: number): number; - /** The GhosttyTerminalScrollViewport struct is passed by value, i.e. by pointer on wasm32. */ - ghostty_terminal_scroll_viewport(term: number, behavior: number): void; - /** The GhosttyPoint struct is passed by value, i.e. by pointer on wasm32. */ - ghostty_terminal_grid_ref(term: number, point: number, outRef: number): number; - ghostty_grid_ref_row(ref: number, outRow: number): number; - ghostty_terminal_point_from_grid_ref(term: number, ref: number, tag: number, out: number): number; - ghostty_terminal_select_word(term: number, options: number, outSelection: number): number; - ghostty_terminal_select_line(term: number, options: number, outSelection: number): number; - /** The GhosttyGridRef struct is passed by value, i.e. by pointer on wasm32. */ - ghostty_terminal_select_output(term: number, ref: number, outSelection: number): number; - /** GhosttyTerminalSelectionFormatOptions is passed by value, i.e. by pointer on wasm32. */ - ghostty_terminal_selection_format_alloc( - term: number, - allocator: number, - options: number, - outPtr: number, - outLen: number - ): number; - ghostty_free(allocator: number, ptr: number, len: number): void; - ghostty_grid_ref_hyperlink_uri(ref: number, buf: number, len: number, outLen: number): number; - /** GhosttyRow is a u64 passed by value (a BigInt on the JS side). */ - ghostty_row_get(row: bigint, data: number, out: number): number; - ghostty_render_state_new(allocator: number, out: number): number; - ghostty_render_state_free(state: number): void; - ghostty_render_state_update(state: number, term: number): number; - ghostty_render_state_clean(state: number): number; - ghostty_render_state_get(state: number, data: number, out: number): number; - ghostty_render_state_get_multi( - state: number, - count: number, - keys: number, - values: number, - outWritten: number - ): number; - ghostty_render_state_row_iterator_new(allocator: number, out: number): number; - ghostty_render_state_row_iterator_free(iterator: number): void; - ghostty_render_state_row_iterator_next(iterator: number): number; - ghostty_render_state_row_iterator_next_dirty(iterator: number, outY: number): number; - ghostty_render_state_row_get_multi( - iterator: number, - count: number, - keys: number, - values: number, - outWritten: number - ): number; - ghostty_render_state_row_cells_new(allocator: number, out: number): number; - ghostty_render_state_row_cells_free(cells: number): void; - ghostty_render_state_row_cells_select(cells: number, x: number): number; - ghostty_render_state_row_cells_get(cells: number, data: number, out: number): number; - ghostty_key_encoder_new(allocator: number, out: number): number; - ghostty_key_encoder_free(encoder: number): void; - ghostty_key_encoder_setopt(encoder: number, option: number, value: number): void; - ghostty_key_encoder_encode( - encoder: number, - event: number, - buf: number, - len: number, - outLen: number - ): number; - ghostty_key_event_new(allocator: number, out: number): number; - ghostty_key_event_free(event: number): void; - ghostty_key_event_set_action(event: number, action: number): void; - ghostty_key_event_set_key(event: number, key: number): void; - ghostty_key_event_set_mods(event: number, mods: number): void; - ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; - ghostty_mouse_encoder_new(allocator: number, out: number): number; - ghostty_mouse_encoder_free(encoder: number): void; - ghostty_mouse_encoder_setopt(encoder: number, option: number, value: number): void; - ghostty_mouse_encoder_setopt_from_terminal(encoder: number, term: number): void; - ghostty_mouse_encoder_encode( - encoder: number, - event: number, - buf: number, - len: number, - outLen: number - ): number; - ghostty_mouse_event_new(allocator: number, out: number): number; - ghostty_mouse_event_free(event: number): void; - ghostty_mouse_event_set_action(event: number, action: number): void; - ghostty_mouse_event_set_button(event: number, button: number): void; - ghostty_mouse_event_clear_button(event: number): void; - ghostty_mouse_event_set_mods(event: number, mods: number): void; - /** The GhosttyMousePosition struct is passed by value, i.e. by pointer on wasm32. */ - ghostty_mouse_event_set_position(event: number, position: number): void; - ghostty_focus_encode(event: number, buf: number, len: number, outWritten: number): number; - ghostty_search_new(allocator: number, out: number, term: number): number; - ghostty_search_free(search: number): void; - ghostty_search_tick(search: number, outStatus: number): number; - ghostty_search_feed(search: number): number; - ghostty_search_set(search: number, option: number, value: number): number; - ghostty_search_get(search: number, data: number, out: number): number; -} - -interface ManifestBits { - lsb: number; - width: number; - arms?: Record<string, { bits: Record<string, ManifestBits> } | null>; -} - -interface ManifestType { - kind: string; - size: number; - fields?: Record<string, { offset: number; size: number }>; - values?: Record<string, number>; - bits?: Record<string, ManifestBits>; -} - -/** A bit field of a packed value: shift right by `lsb`, then mask. */ -interface BitField { - lsb: number; - mask: number; -} - -/** Reads the bits at `lsb` (masked) from a 64-bit value split into two u32 words. */ -function readBits(lo: number, hi: number, lsb: number, mask: number): number { - if (lsb >= 32) return (hi >>> (lsb - 32)) & mask; - if (lsb === 0) return lo & mask; - return ((lo >>> lsb) | (hi << (32 - lsb))) & mask; -} - -/** Struct layouts and enum values from the wasm's `ghostty_type_json` manifest. */ -class VtLayout { - private readonly types: Record<string, ManifestType>; - - constructor(types: Record<string, ManifestType>) { - this.types = types; - } - - type(name: string): ManifestType { - const t = this.types[name]; - if (!t) throw new Error(`libghostty-vt manifest has no type ${name}`); - return t; - } - - size(name: string): number { - return this.type(name).size; - } - - offset(type: string, field: string): number { - const f = this.type(type).fields?.[field]; - if (!f) throw new Error(`libghostty-vt manifest has no field ${type}.${field}`); - return f.offset; - } - - enums(name: string): Record<string, number> { - const values = this.type(name).values; - if (!values) throw new Error(`libghostty-vt manifest type ${name} is not an enum`); - return values; - } - - value(name: string, key: string): number { - const v = this.enums(name)[key]; - if (v === undefined) throw new Error(`libghostty-vt manifest has no value ${name}.${key}`); - return v; - } - - bitField(bits: Record<string, ManifestBits> | undefined, path: string): BitField { - const b = bits?.[path]; - if (!b) throw new Error(`libghostty-vt manifest has no bit field ${path}`); - return { lsb: b.lsb, mask: b.width >= 32 ? 0xffffffff : 2 ** b.width - 1 }; - } - - /** The bits of one arm of a packed union field, e.g. GhosttyCell.content.CODEPOINT. */ - armBits(type: string, field: string, arm: string): Record<string, ManifestBits> { - const a = this.type(type).bits?.[field]?.arms?.[arm]; - if (!a) throw new Error(`libghostty-vt manifest has no arm ${type}.${field}.${arm}`); - return a.bits; - } -} - -/** - * Ghostty's key names for the ghostty-web `Key` names that differ (ADR 042). Every - * other `Key` keeps its name; values are always translated by name because 29 keys - * have different numbers in the two builds. - */ -const OFFICIAL_KEY_NAMES: Readonly<Record<string, string>> = { - GRAVE: 'BACKQUOTE', - ZERO: 'DIGIT_0', - ONE: 'DIGIT_1', - TWO: 'DIGIT_2', - THREE: 'DIGIT_3', - FOUR: 'DIGIT_4', - FIVE: 'DIGIT_5', - SIX: 'DIGIT_6', - SEVEN: 'DIGIT_7', - EIGHT: 'DIGIT_8', - NINE: 'DIGIT_9', - UP: 'ARROW_UP', - DOWN: 'ARROW_DOWN', - LEFT: 'ARROW_LEFT', - RIGHT: 'ARROW_RIGHT', - KP_0: 'NUMPAD_0', - KP_1: 'NUMPAD_1', - KP_2: 'NUMPAD_2', - KP_3: 'NUMPAD_3', - KP_4: 'NUMPAD_4', - KP_5: 'NUMPAD_5', - KP_6: 'NUMPAD_6', - KP_7: 'NUMPAD_7', - KP_8: 'NUMPAD_8', - KP_9: 'NUMPAD_9', - KP_PLUS: 'NUMPAD_ADD', - KP_BACKSPACE: 'NUMPAD_BACKSPACE', - KP_CLEAR: 'NUMPAD_CLEAR', - KP_CLEAR_ENTRY: 'NUMPAD_CLEAR_ENTRY', - KP_COMMA: 'NUMPAD_COMMA', - KP_PERIOD: 'NUMPAD_DECIMAL', - KP_DIVIDE: 'NUMPAD_DIVIDE', - KP_ENTER: 'NUMPAD_ENTER', - KP_EQUAL: 'NUMPAD_EQUAL', - KP_MEMORY_ADD: 'NUMPAD_MEMORY_ADD', - KP_MEMORY_CLEAR: 'NUMPAD_MEMORY_CLEAR', - KP_MEMORY_RECALL: 'NUMPAD_MEMORY_RECALL', - KP_MEMORY_STORE: 'NUMPAD_MEMORY_STORE', - KP_MEMORY_SUBTRACT: 'NUMPAD_MEMORY_SUBTRACT', - KP_MULTIPLY: 'NUMPAD_MULTIPLY', - KP_PAREN_LEFT: 'NUMPAD_PAREN_LEFT', - KP_PAREN_RIGHT: 'NUMPAD_PAREN_RIGHT', - KP_MINUS: 'NUMPAD_SUBTRACT', - KP_SEPARATOR: 'NUMPAD_SEPARATOR', -}; - -/** Ghostty's key encoder option names for the ghostty-web names that differ. */ -const OFFICIAL_ENCODER_OPTION_NAMES: Readonly<Record<string, string>> = { - KITTY_KEYBOARD_FLAGS: 'KITTY_FLAGS', -}; - -/** Maps a numeric TS enum's values to the official enum's values, by name. */ -function translateEnum( - ours: Record<string, string | number>, - layout: VtLayout, - official: string, - renames: Readonly<Record<string, string>> -): Map<number, number> { - const map = new Map<number, number>(); - for (const [name, value] of Object.entries(ours)) { - if (typeof value !== 'number') continue; - map.set(value, layout.value(official, renames[name] ?? name)); - } - return map; -} - -/** Enum values, offsets and bit fields the official binding uses, resolved once per instance. */ -function resolveOfficialLayout(layout: VtLayout) { - const opt = (k: string) => layout.value('GhosttyTerminalOption', k); - const tdata = (k: string) => layout.value('GhosttyTerminalData', k); - const cellBits = layout.type('GhosttyCell').bits; - const cp = layout.armBits('GhosttyCell', 'content', 'CODEPOINT'); - const cpg = layout.armBits('GhosttyCell', 'content', 'CODEPOINT_GRAPHEME'); - const pal = layout.armBits('GhosttyCell', 'content', 'BG_COLOR_PALETTE'); - const rgb = layout.armBits('GhosttyCell', 'content', 'BG_COLOR_RGB'); - const codepoint = layout.bitField(cp, 'codepoint'); - const graphemeCodepoint = layout.bitField(cpg, 'codepoint'); - if (codepoint.lsb !== graphemeCodepoint.lsb || codepoint.mask !== graphemeCodepoint.mask) { - throw new Error('libghostty-vt GhosttyCell codepoint arms differ'); - } - return { - opt: { - WRITE_PTY: opt('WRITE_PTY'), - BELL: opt('BELL'), - TITLE_CHANGED: opt('TITLE_CHANGED'), - SIZE: opt('SIZE'), - DEVICE_ATTRIBUTES: opt('DEVICE_ATTRIBUTES'), - COLOR_FOREGROUND: opt('COLOR_FOREGROUND'), - COLOR_BACKGROUND: opt('COLOR_BACKGROUND'), - COLOR_CURSOR: opt('COLOR_CURSOR'), - COLOR_PALETTE: opt('COLOR_PALETTE'), - SCROLLBACK_MAX_BYTES: opt('SCROLLBACK_MAX_BYTES'), - SCROLLBACK_MAX_LINES: opt('SCROLLBACK_MAX_LINES'), - MODE_DEFAULT: opt('MODE_DEFAULT'), - MODE: opt('MODE'), - DEFAULT_CURSOR_STYLE: opt('DEFAULT_CURSOR_STYLE'), - DEFAULT_CURSOR_BLINK: opt('DEFAULT_CURSOR_BLINK'), - CLIPBOARD_WRITE: opt('CLIPBOARD_WRITE'), - DESKTOP_NOTIFICATION: opt('DESKTOP_NOTIFICATION'), - }, - defaultCursorStyle: { - bar: layout.value('GhosttyTerminalCursorStyle', 'BAR'), - block: layout.value('GhosttyTerminalCursorStyle', 'BLOCK'), - underline: layout.value('GhosttyTerminalCursorStyle', 'UNDERLINE'), - } satisfies Record<RenderStateCursor['style'], number>, - tdata: { - CURSOR_X: tdata('CURSOR_X'), - CURSOR_Y: tdata('CURSOR_Y'), - ACTIVE_SCREEN: tdata('ACTIVE_SCREEN'), - MOUSE_TRACKING: tdata('MOUSE_TRACKING'), - CURSOR_AT_PROMPT: tdata('CURSOR_AT_PROMPT'), - SCROLLBAR: tdata('SCROLLBAR'), - TITLE: tdata('TITLE'), - SCROLLBACK_ROWS: tdata('SCROLLBACK_ROWS'), - COLOR_PALETTE_DEFAULT: tdata('COLOR_PALETTE_DEFAULT'), - MODE: tdata('MODE'), - }, - screenAlternate: layout.value('GhosttyTerminalScreen', 'ALTERNATE'), - rs: { - DIRTY: layout.value('GhosttyRenderStateData', 'DIRTY'), - ROW_ITERATOR: layout.value('GhosttyRenderStateData', 'ROW_ITERATOR'), - CURSOR: layout.value('GhosttyRenderStateData', 'CURSOR'), - COLORS: layout.value('GhosttyRenderStateData', 'COLORS'), - }, - dirty: { - FALSE: layout.value('GhosttyRenderStateDirty', 'FALSE'), - PARTIAL: layout.value('GhosttyRenderStateDirty', 'PARTIAL'), - FULL: layout.value('GhosttyRenderStateDirty', 'FULL'), - }, - row: { - CELLS: layout.value('GhosttyRenderStateRowData', 'CELLS'), - CELLS_RAW: layout.value('GhosttyRenderStateRowData', 'CELLS_RAW'), - VIEWPORT_Y: layout.value('GhosttyRenderStateRowData', 'VIEWPORT_Y'), - }, - cellData: { - STYLE: layout.value('GhosttyRenderStateRowCellsData', 'STYLE'), - GRAPHEMES_LEN: layout.value('GhosttyRenderStateRowCellsData', 'GRAPHEMES_LEN'), - GRAPHEMES_BUF: layout.value('GhosttyRenderStateRowCellsData', 'GRAPHEMES_BUF'), - }, - rowWrapContinuation: layout.value('GhosttyRowData', 'WRAP_CONTINUATION'), - cell: { - tag: layout.bitField(cellBits, 'content_tag'), - content: layout.bitField(cellBits, 'content'), - style: layout.bitField(cellBits, 'style_id'), - wide: layout.bitField(cellBits, 'wide'), - hyperlink: layout.bitField(cellBits, 'hyperlink'), - codepoint, - paletteIndex: layout.bitField(pal, 'index'), - r: layout.bitField(rgb, 'r'), - g: layout.bitField(rgb, 'g'), - b: layout.bitField(rgb, 'b'), - }, - tag: { - CODEPOINT: layout.value('GhosttyCellContentTag', 'CODEPOINT'), - CODEPOINT_GRAPHEME: layout.value('GhosttyCellContentTag', 'CODEPOINT_GRAPHEME'), - BG_COLOR_PALETTE: layout.value('GhosttyCellContentTag', 'BG_COLOR_PALETTE'), - BG_COLOR_RGB: layout.value('GhosttyCellContentTag', 'BG_COLOR_RGB'), - }, - wide: { - NARROW: layout.value('GhosttyCellWide', 'NARROW'), - WIDE: layout.value('GhosttyCellWide', 'WIDE'), - }, - styleColor: { - NONE: layout.value('GhosttyStyleColorTag', 'NONE'), - PALETTE: layout.value('GhosttyStyleColorTag', 'PALETTE'), - RGB: layout.value('GhosttyStyleColorTag', 'RGB'), - tag: layout.offset('GhosttyStyleColor', 'tag'), - value: layout.offset('GhosttyStyleColor', 'value'), - palette: layout.offset('GhosttyStyleColorValue', 'palette'), - rgb: layout.offset('GhosttyStyleColorValue', 'rgb'), - }, - style: { - size: layout.size('GhosttyStyle'), - sizeField: layout.offset('GhosttyStyle', 'size'), - fg: layout.offset('GhosttyStyle', 'fg_color'), - bg: layout.offset('GhosttyStyle', 'bg_color'), - bold: layout.offset('GhosttyStyle', 'bold'), - italic: layout.offset('GhosttyStyle', 'italic'), - faint: layout.offset('GhosttyStyle', 'faint'), - blink: layout.offset('GhosttyStyle', 'blink'), - inverse: layout.offset('GhosttyStyle', 'inverse'), - invisible: layout.offset('GhosttyStyle', 'invisible'), - strikethrough: layout.offset('GhosttyStyle', 'strikethrough'), - underline: layout.offset('GhosttyStyle', 'underline'), - }, - cursor: { - size: layout.size('GhosttyRenderStateCursor'), - sizeField: layout.offset('GhosttyRenderStateCursor', 'size'), - visible: layout.offset('GhosttyRenderStateCursor', 'visible'), - blinking: layout.offset('GhosttyRenderStateCursor', 'blinking'), - visualStyle: layout.offset('GhosttyRenderStateCursor', 'visual_style'), - }, - cursorStyle: { - BAR: layout.value('GhosttyRenderStateCursorVisualStyle', 'BAR'), - BLOCK: layout.value('GhosttyRenderStateCursorVisualStyle', 'BLOCK'), - UNDERLINE: layout.value('GhosttyRenderStateCursorVisualStyle', 'UNDERLINE'), - }, - colors: { - size: layout.size('GhosttyRenderStateColors'), - sizeField: layout.offset('GhosttyRenderStateColors', 'size'), - background: layout.offset('GhosttyRenderStateColors', 'background'), - foreground: layout.offset('GhosttyRenderStateColors', 'foreground'), - palette: layout.offset('GhosttyRenderStateColors', 'palette'), - rgbSize: layout.size('GhosttyColorRgb'), - r: layout.offset('GhosttyColorRgb', 'r'), - g: layout.offset('GhosttyColorRgb', 'g'), - b: layout.offset('GhosttyColorRgb', 'b'), - }, - cellsView: { - size: layout.size('GhosttyCellsView'), - ptr: layout.offset('GhosttyCellsView', 'ptr'), - len: layout.offset('GhosttyCellsView', 'len'), - }, - cellSize: layout.size('GhosttyCell'), - scroll: { - size: layout.size('GhosttyTerminalScrollViewport'), - tag: layout.offset('GhosttyTerminalScrollViewport', 'tag'), - value: layout.offset('GhosttyTerminalScrollViewport', 'value'), - BOTTOM: layout.value('GhosttyTerminalScrollViewportTag', 'BOTTOM'), - ROW: layout.value('GhosttyTerminalScrollViewportTag', 'ROW'), - DELTA_PROMPT: layout.value('GhosttyTerminalScrollViewportTag', 'DELTA_PROMPT'), - }, - scrollbar: { - size: layout.size('GhosttyTerminalScrollbar'), - offset: layout.offset('GhosttyTerminalScrollbar', 'offset'), - }, - point: { - size: layout.size('GhosttyPoint'), - tag: layout.offset('GhosttyPoint', 'tag'), - value: layout.offset('GhosttyPoint', 'value'), - x: layout.offset('GhosttyPointCoordinate', 'x'), - y: layout.offset('GhosttyPointCoordinate', 'y'), - ACTIVE: layout.value('GhosttyPointTag', 'ACTIVE'), - HISTORY: layout.value('GhosttyPointTag', 'HISTORY'), - SCREEN: layout.value('GhosttyPointTag', 'SCREEN'), - }, - gridRef: { - size: layout.size('GhosttyGridRef'), - sizeField: layout.offset('GhosttyGridRef', 'size'), - }, - selection: { - size: layout.size('GhosttySelection'), - sizeField: layout.offset('GhosttySelection', 'size'), - start: layout.offset('GhosttySelection', 'start'), - end: layout.offset('GhosttySelection', 'end'), - word: { - size: layout.size('GhosttyTerminalSelectWordOptions'), - sizeField: layout.offset('GhosttyTerminalSelectWordOptions', 'size'), - ref: layout.offset('GhosttyTerminalSelectWordOptions', 'ref'), - }, - line: { - size: layout.size('GhosttyTerminalSelectLineOptions'), - sizeField: layout.offset('GhosttyTerminalSelectLineOptions', 'size'), - ref: layout.offset('GhosttyTerminalSelectLineOptions', 'ref'), - }, - format: { - size: layout.size('GhosttyTerminalSelectionFormatOptions'), - sizeField: layout.offset('GhosttyTerminalSelectionFormatOptions', 'size'), - emit: layout.offset('GhosttyTerminalSelectionFormatOptions', 'emit'), - unwrap: layout.offset('GhosttyTerminalSelectionFormatOptions', 'unwrap'), - trim: layout.offset('GhosttyTerminalSelectionFormatOptions', 'trim'), - selection: layout.offset('GhosttyTerminalSelectionFormatOptions', 'selection'), - }, - PLAIN: layout.value('GhosttyFormatterFormat', 'PLAIN'), - }, - string: { - size: layout.size('GhosttyString'), - ptr: layout.offset('GhosttyString', 'ptr'), - len: layout.offset('GhosttyString', 'len'), - }, - mode: { - size: layout.size('GhosttyTerminalModeConfig'), - mode: layout.offset('GhosttyTerminalModeConfig', 'mode'), - value: layout.offset('GhosttyTerminalModeConfig', 'value'), - }, - sizeReport: { - rows: layout.offset('GhosttySizeReportSize', 'rows'), - columns: layout.offset('GhosttySizeReportSize', 'columns'), - cellWidth: layout.offset('GhosttySizeReportSize', 'cell_width'), - cellHeight: layout.offset('GhosttySizeReportSize', 'cell_height'), - }, - da: { - primary: layout.offset('GhosttyDeviceAttributes', 'primary'), - secondary: layout.offset('GhosttyDeviceAttributes', 'secondary'), - tertiary: layout.offset('GhosttyDeviceAttributes', 'tertiary'), - conformance: layout.offset('GhosttyDeviceAttributesPrimary', 'conformance_level'), - features: layout.offset('GhosttyDeviceAttributesPrimary', 'features'), - numFeatures: layout.offset('GhosttyDeviceAttributesPrimary', 'num_features'), - deviceType: layout.offset('GhosttyDeviceAttributesSecondary', 'device_type'), - firmware: layout.offset('GhosttyDeviceAttributesSecondary', 'firmware_version'), - rom: layout.offset('GhosttyDeviceAttributesSecondary', 'rom_cartridge'), - unitId: layout.offset('GhosttyDeviceAttributesTertiary', 'unit_id'), - }, - mouse: { - size: layout.size('GhosttyMouseEncoderSize'), - sizeField: layout.offset('GhosttyMouseEncoderSize', 'size'), - screenWidth: layout.offset('GhosttyMouseEncoderSize', 'screen_width'), - screenHeight: layout.offset('GhosttyMouseEncoderSize', 'screen_height'), - cellWidth: layout.offset('GhosttyMouseEncoderSize', 'cell_width'), - cellHeight: layout.offset('GhosttyMouseEncoderSize', 'cell_height'), - paddingTop: layout.offset('GhosttyMouseEncoderSize', 'padding_top'), - paddingBottom: layout.offset('GhosttyMouseEncoderSize', 'padding_bottom'), - paddingRight: layout.offset('GhosttyMouseEncoderSize', 'padding_right'), - paddingLeft: layout.offset('GhosttyMouseEncoderSize', 'padding_left'), - positionSize: layout.size('GhosttyMousePosition'), - x: layout.offset('GhosttyMousePosition', 'x'), - y: layout.offset('GhosttyMousePosition', 'y'), - SIZE: layout.value('GhosttyMouseEncoderOption', 'SIZE'), - ANY_BUTTON_PRESSED: layout.value('GhosttyMouseEncoderOption', 'ANY_BUTTON_PRESSED'), - TRACK_LAST_CELL: layout.value('GhosttyMouseEncoderOption', 'TRACK_LAST_CELL'), - action: { - press: layout.value('GhosttyMouseAction', 'PRESS'), - release: layout.value('GhosttyMouseAction', 'RELEASE'), - motion: layout.value('GhosttyMouseAction', 'MOTION'), - }, - button: { - left: layout.value('GhosttyMouseButton', 'LEFT'), - middle: layout.value('GhosttyMouseButton', 'MIDDLE'), - right: layout.value('GhosttyMouseButton', 'RIGHT'), - four: layout.value('GhosttyMouseButton', 'FOUR'), - five: layout.value('GhosttyMouseButton', 'FIVE'), - eight: layout.value('GhosttyMouseButton', 'EIGHT'), - nine: layout.value('GhosttyMouseButton', 'NINE'), - } satisfies Record<MouseButton, number>, - }, - paste: { - size: layout.size('GhosttyPaste'), - sizeField: layout.offset('GhosttyPaste', 'size'), - location: layout.offset('GhosttyPaste', 'location'), - source: layout.offset('GhosttyPaste', 'source'), - mimes: layout.offset('GhosttyPaste', 'mimes'), - mimesLen: layout.offset('GhosttyPaste', 'mimes_len'), - reader: layout.offset('GhosttyPaste', 'reader'), - allowUnsafe: layout.offset('GhosttyPaste', 'allow_unsafe'), - readerRead: layout.offset('GhosttyMimeReader', 'read'), - writerWrite: layout.offset('GhosttyWriter', 'write'), - writerUserdata: layout.offset('GhosttyWriter', 'userdata'), - STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), - CLIPBOARD: layout.value('GhosttyPasteSource', 'CLIPBOARD'), - }, - notification: { - size: layout.offset('GhosttyTerminalDesktopNotification', 'size'), - title: layout.offset('GhosttyTerminalDesktopNotification', 'title'), - body: layout.offset('GhosttyTerminalDesktopNotification', 'body'), - bodyEnd: layout.offset('GhosttyTerminalDesktopNotification', 'body') + layout.size('GhosttyString'), - }, - clipboardWrite: { - location: layout.offset('GhosttyClipboardWrite', 'location'), - contents: layout.offset('GhosttyClipboardWrite', 'contents'), - contentsLen: layout.offset('GhosttyClipboardWrite', 'contents_len'), - reply: layout.offset('GhosttyClipboardWrite', 'reply'), - contentSize: layout.size('GhosttyClipboardContent'), - mime: layout.offset('GhosttyClipboardContent', 'mime'), - data: layout.offset('GhosttyClipboardContent', 'data'), - replySize: layout.size('GhosttyClipboardWriteReply'), - replySizeField: layout.offset('GhosttyClipboardWriteReply', 'size'), - replyResult: layout.offset('GhosttyClipboardWriteReply', 'result'), - STANDARD: layout.value('GhosttyClipboardLocation', 'STANDARD'), - SUCCESS: layout.value('GhosttyClipboardWriteResult', 'SUCCESS'), - UNSUPPORTED: layout.value('GhosttyClipboardWriteResult', 'UNSUPPORTED'), - }, - search: { - bufferSize: layout.size('GhosttySelectionBuffer'), - ptr: layout.offset('GhosttySelectionBuffer', 'ptr'), - cap: layout.offset('GhosttySelectionBuffer', 'cap'), - len: layout.offset('GhosttySelectionBuffer', 'len'), - NEEDLE: layout.value('GhosttySearchOption', 'NEEDLE'), - SELECT_NEXT: layout.value('GhosttySearchOption', 'SELECT_NEXT'), - SELECT_PREV: layout.value('GhosttySearchOption', 'SELECT_PREV'), - SELECT_SCROLL: layout.value('GhosttySearchOption', 'SELECT_SCROLL'), - SCROLL_NONE: layout.value('GhosttySearchScroll', 'NONE'), - STATUS: layout.value('GhosttySearchData', 'STATUS'), - TOTAL_MATCHES: layout.value('GhosttySearchData', 'TOTAL_MATCHES'), - SELECTED_INDEX: layout.value('GhosttySearchData', 'SELECTED_INDEX'), - SELECTED_MATCH: layout.value('GhosttySearchData', 'SELECTED_MATCH'), - MATCHES: layout.value('GhosttySearchData', 'MATCHES'), - COMPLETE: layout.value('GhosttySearchStatus', 'COMPLETE'), - FEED_REQUIRED: layout.value('GhosttySearchStatus', 'FEED_REQUIRED'), - }, - focus: { - GAINED: layout.value('GhosttyFocusEvent', 'GAINED'), - LOST: layout.value('GhosttyFocusEvent', 'LOST'), - }, - result: { - SUCCESS: layout.value('GhosttyResult', 'SUCCESS'), - OUT_OF_SPACE: layout.value('GhosttyResult', 'OUT_OF_SPACE'), - }, - }; -} - -type OfficialLayout = ReturnType<typeof resolveOfficialLayout>; - /** * A loaded libghostty-vt wasm instance (ADR 041, ADR 042): creates terminals and * key encoders. @@ -902,14 +109,7 @@ export class Ghostty { constructor(exports: OfficialWasmExports) { this.ex = exports; - const p = exports.ghostty_type_json(); - const u8 = new Uint8Array(exports.memory.buffer); - let end = p; - while (u8[end] !== 0) end++; - const manifest = JSON.parse(new TextDecoder().decode(u8.subarray(p, end))) as { - types: Record<string, ManifestType>; - }; - const layout = new VtLayout(manifest.types); + const layout = readVtLayout(exports); this.L = resolveOfficialLayout(layout); this.fnTable = new FnTable(exports.__indirect_function_table); this.keys = translateEnum(Key, layout, 'GhosttyKey', OFFICIAL_KEY_NAMES); @@ -1033,45 +233,6 @@ export class Ghostty { } } -/** Scratch memory: one wasm allocation carved into aligned, named slots. */ -class Scratch { - readonly base: number; - readonly len: number; - private readonly ex: OfficialWasmExports; - - private constructor(ex: OfficialWasmExports, base: number, len: number) { - this.ex = ex; - this.base = base; - this.len = len; - } - - /** Allocates the slots; each value is [size, align]. Returns the slot addresses. */ - static create<K extends string>( - ex: OfficialWasmExports, - slots: Record<K, readonly [number, number]> - ): { scratch: Scratch; at: Record<K, number> } { - let off = 0; - const offsets = {} as Record<K, number>; - for (const key of Object.keys(slots) as K[]) { - const [size, align] = slots[key]; - off = Math.ceil(off / align) * align; - offsets[key] = off; - off += size; - } - const len = off + 8; - const raw = ex.ghostty_wasm_alloc(len); - if (!raw) throw new Error('libghostty-vt: out of memory'); - const base = Math.ceil(raw / 8) * 8; - const at = {} as Record<K, number>; - for (const key of Object.keys(offsets) as K[]) at[key] = base + offsets[key]; - return { scratch: new Scratch(ex, raw, len), at }; - } - - free(): void { - this.ex.ghostty_wasm_free(this.base, this.len); - } -} - /** A decoded row: JS-owned cells and the strings of its multi-codepoint graphemes. */ interface RowRef { cells: GhosttyCell[]; @@ -1114,36 +275,6 @@ const MODE_CURSOR_BLINK = 12; /** Bytes as one char each, for scanning ASCII sequences in byte output. */ const LATIN1 = new TextDecoder('latin1'); -// What sets the cursor blink while mode 12 is ignored: DECSCUSR (CSI Ps SP q), DECSTR -// (CSI ! p) and RIS (ESC c). The last two restore the default, as Ps 0 does. -const CURSOR_BLINK_SEQ = /\x1b(?:\[(\d*) q|\[!p|c)/g; -const CURSOR_BLINK_PREFIX = /^\x1b(?:\[(?:\d{0,3} ?|!)?)?$/; - -/** - * Scans output for the sequences that set the cursor blink while DEC mode 12 is - * ignored, and returns the last one as a DECSCUSR Ps from 0 to 6 (DECSTR and RIS - * as 0; undefined if none), plus the unfinished sequence at the end of the chunk to - * pass back in as `carry` with the next one. Most chunks hold none of them and are - * not scanned. (webtty, ADR 049: Ghostty ignores mode 12 when `cursor-style-blink` - * is set, in its app's stream handler, which libghostty-vt does not include.) - */ -export function scanCursorBlink( - data: string, - carry: string = '' -): { ps: number | undefined; carry: string } { - const text = carry + data; - let ps: number | undefined; - if (carry !== '' || text.includes(' q') || text.includes('!p') || text.includes('\x1bc')) { - for (const match of text.matchAll(CURSOR_BLINK_SEQ)) { - const value = match[1] === undefined || match[1] === '' ? 0 : Number(match[1]); - if (value <= 6) ps = value; - } - } - const esc = text.lastIndexOf('\x1b'); - const tail = esc >= 0 && text.length - esc <= 6 ? text.slice(esc) : ''; - return { ps, carry: CURSOR_BLINK_PREFIX.test(tail) ? tail : '' }; -} - function blankCell(): GhosttyCell { return { codepoint: 0, @@ -1169,38 +300,6 @@ function setRgb(cell: GhosttyCell, fg: number, bg: number): void { cell.bg_b = bg & 0xff; } -const TEXT_MIMES = new Set([ - 'text/plain', - 'text/plain;charset=utf-8', - 'UTF8_STRING', - 'TEXT', - 'STRING', -]); - -/** - * Whether a clipboard MIME type names plain text: Ghostty's `isTextMime` list - * (`src/terminal/clipboard.zig`), the names text goes by on every platform. (webtty, - * ADR 052) - */ -export function isTextMime(mime: string): boolean { - return TEXT_MIMES.has(mime); -} - -/** - * Whether one `WRITE_PTY` reply is the kitty keyboard query reply `ESC [ ? <flags> u` - * (`queryKittyKeyboard` in Ghostty's `stream_terminal.zig`). No other reply has - * this shape: DECRQM ends in `$y`, DA in `c`. - */ -function isKittyKeyboardReply(reply: Uint8Array): boolean { - const n = reply.length; - if (n < 5 || reply[0] !== 0x1b || reply[1] !== 0x5b || reply[2] !== 0x3f) return false; - if (reply[n - 1] !== 0x75) return false; - for (let i = 3; i < n - 1; i++) { - if (reply[i] < 0x30 || reply[i] > 0x39) return false; - } - return true; -} - /** * A terminal on the official libghostty-vt wasm (ADR 042). * From 80598e294ad0ce6da790d539f9cdef8f0fc6ab5e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:32:46 -0400 Subject: [PATCH 230/295] test(client): wasm-free unit tests for the pure engine modules readBits and VtLayout on a hand-written manifest (every missing-name throw, readVtLayout on a bare WebAssembly.Memory), translateEnum and the rename tables, Scratch on a fake allocator, scanCursorBlink across chunks, isTextMime and isKittyKeyboardReply as tables, and resolveOfficialLayout on the pinned manifest with its loud failures. Each module is at 100% lines. --- .../vendor/tests/engine/cursor-blink.test.ts | 34 +++++++ .../vendor/tests/engine/key-tables.test.ts | 44 +++++++++ .../tests/engine/kitty-keyboard-reply.test.ts | 22 +++++ .../tests/engine/official-layout.test.ts | 60 ++++++++++++ .../vendor/tests/engine/scratch.test.ts | 42 ++++++++ .../vendor/tests/engine/text-mime.test.ts | 20 ++++ .../vendor/tests/engine/vt-layout.test.ts | 97 +++++++++++++++++++ 7 files changed, 319 insertions(+) create mode 100644 src/client/vendor/tests/engine/cursor-blink.test.ts create mode 100644 src/client/vendor/tests/engine/key-tables.test.ts create mode 100644 src/client/vendor/tests/engine/kitty-keyboard-reply.test.ts create mode 100644 src/client/vendor/tests/engine/official-layout.test.ts create mode 100644 src/client/vendor/tests/engine/scratch.test.ts create mode 100644 src/client/vendor/tests/engine/text-mime.test.ts create mode 100644 src/client/vendor/tests/engine/vt-layout.test.ts diff --git a/src/client/vendor/tests/engine/cursor-blink.test.ts b/src/client/vendor/tests/engine/cursor-blink.test.ts new file mode 100644 index 0000000..9fdb9c4 --- /dev/null +++ b/src/client/vendor/tests/engine/cursor-blink.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { scanCursorBlink } from '../../ghostty-web/engine/cursor-blink'; + +// The cursor blink scan (ADR 049): the last DECSCUSR, DECSTR or RIS in output, across chunks. + +describe('scanCursorBlink', () => { + test.each([ + { name: 'plain text', data: 'hello', ps: undefined, carry: '' }, + { name: 'DECSCUSR Ps', data: 'a\x1b[5 qb', ps: 5, carry: '' }, + { name: 'DECSCUSR with no Ps is 0', data: '\x1b[ q', ps: 0, carry: '' }, + { name: 'DECSTR is 0', data: '\x1b[2 q\x1b[!p', ps: 0, carry: '' }, + { name: 'RIS is 0', data: '\x1b[1 q\x1bc', ps: 0, carry: '' }, + { name: 'the last one wins', data: '\x1b[1 q\x1b[4 q', ps: 4, carry: '' }, + { name: 'a Ps over 6 is ignored', data: '\x1b[3 q\x1b[7 q', ps: 3, carry: '' }, + { name: 'an unfinished sequence is carried', data: 'x\x1b[2', ps: undefined, carry: '\x1b[2' }, + { name: 'a lone ESC is carried', data: 'x\x1b', ps: undefined, carry: '\x1b' }, + { name: 'a finished other sequence is not', data: 'x\x1b[2J', ps: undefined, carry: '' }, + { name: 'a long tail is not', data: '\x1b[1234 ', ps: undefined, carry: '' }, + ])('$name', ({ data, ps, carry }) => { + expect(scanCursorBlink(data)).toEqual({ ps, carry }); + }); + + test('a sequence split across chunks is found with the carry', () => { + const first = scanCursorBlink('out\x1b[6'); + expect(first).toEqual({ ps: undefined, carry: '\x1b[6' }); + expect(scanCursorBlink(' qmore', first.carry)).toEqual({ ps: 6, carry: '' }); + }); + + test('a split DECSTR is found too', () => { + const first = scanCursorBlink('\x1b[!'); + expect(first.carry).toBe('\x1b[!'); + expect(scanCursorBlink('p', first.carry).ps).toBe(0); + }); +}); diff --git a/src/client/vendor/tests/engine/key-tables.test.ts b/src/client/vendor/tests/engine/key-tables.test.ts new file mode 100644 index 0000000..713a503 --- /dev/null +++ b/src/client/vendor/tests/engine/key-tables.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import { + OFFICIAL_ENCODER_OPTION_NAMES, + OFFICIAL_KEY_NAMES, + translateEnum, +} from '../../ghostty-web/engine/key-tables'; +import { VtLayout } from '../../ghostty-web/engine/vt-layout'; +import { Key, KeyEncoderOption } from '../../ghostty-web/input/key-types'; + +// The by-name translation of ghostty-web's enums to libghostty-vt's (ADR 042), without the wasm. + +enum Ours { + A = 0, + B = 1, + OLD_NAME = 2, +} + +const layout = new VtLayout({ + Official: { kind: 'enum', size: 4, values: { A: 10, B: 11, NEW_NAME: 12, EXTRA: 13 } }, +}); + +describe('translateEnum', () => { + test('maps each numeric value to the official value of the same or renamed name', () => { + const map = translateEnum(Ours, layout, 'Official', { OLD_NAME: 'NEW_NAME' }); + expect([...map]).toEqual([ + [0, 10], + [1, 11], + [2, 12], + ]); + }); + + test('a name the official enum lacks throws', () => { + expect(() => translateEnum(Ours, layout, 'Official', {})).toThrow('no value Official.OLD_NAME'); + }); +}); + +describe('the rename tables', () => { + test('rename only names the ghostty-web enums have', () => { + for (const name of Object.keys(OFFICIAL_KEY_NAMES)) expect(name in Key).toBe(true); + for (const name of Object.keys(OFFICIAL_ENCODER_OPTION_NAMES)) { + expect(name in KeyEncoderOption).toBe(true); + } + }); +}); diff --git a/src/client/vendor/tests/engine/kitty-keyboard-reply.test.ts b/src/client/vendor/tests/engine/kitty-keyboard-reply.test.ts new file mode 100644 index 0000000..f961788 --- /dev/null +++ b/src/client/vendor/tests/engine/kitty-keyboard-reply.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test'; +import { isKittyKeyboardReply } from '../../ghostty-web/engine/kitty-keyboard-reply'; + +// The one WRITE_PTY reply the binding drops (ADR 042): the kitty keyboard query reply. + +const bytes = (s: string) => new TextEncoder().encode(s); + +describe('isKittyKeyboardReply', () => { + test.each([ + ['ESC [ ? 0 u', '\x1b[?0u', true], + ['ESC [ ? 31 u', '\x1b[?31u', true], + ['no flags', '\x1b[?u', false], + ['DECRQM', '\x1b[?2004;2$y', false], + ['DA', '\x1b[?62;22c', false], + ['no ?', '\x1b[10u', false], + ['not ESC', 'x[?1u', false], + ['not CSI', '\x1bO?1u', false], + ['a non-digit flag', '\x1b[?1;u', false], + ])('%s', (_name, reply, kitty) => { + expect(isKittyKeyboardReply(bytes(reply))).toBe(kitty); + }); +}); diff --git a/src/client/vendor/tests/engine/official-layout.test.ts b/src/client/vendor/tests/engine/official-layout.test.ts new file mode 100644 index 0000000..c73d89e --- /dev/null +++ b/src/client/vendor/tests/engine/official-layout.test.ts @@ -0,0 +1,60 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { resolveOfficialLayout } from '../../ghostty-web/engine/official-layout'; +import { type ManifestType, readVtLayout, VtLayout } from '../../ghostty-web/engine/vt-layout'; +import type { OfficialWasmExports } from '../../ghostty-web/engine/wasm-exports'; +import { instantiateWasm } from '../support/wasm'; + +// The binding's layout table resolved from the pinned wasm's own manifest (ADR 041). + +let types: Record<string, ManifestType>; + +beforeAll(async () => { + const instance = await instantiateWasm(); + const ex = instance.exports as unknown as OfficialWasmExports; + const memory = new Uint8Array(ex.memory.buffer); + const p = ex.ghostty_type_json(); + const end = memory.indexOf(0, p); + types = JSON.parse(new TextDecoder().decode(memory.subarray(p, end))).types; + expect(readVtLayout(ex).size('GhosttyCell')).toBe(types.GhosttyCell.size); +}); + +/** The manifest with one type replaced, for the checks that must fail loudly. */ +function withType(name: string, type: ManifestType | undefined): VtLayout { + const copy = structuredClone(types); + if (type) copy[name] = type; + else delete copy[name]; + return new VtLayout(copy); +} + +describe('resolveOfficialLayout', () => { + test('resolves every name the binding uses from the pinned manifest', () => { + const L = resolveOfficialLayout(new VtLayout(types)); + expect(L.cellSize).toBe(8); + expect(L.point.SCREEN).toBe(new VtLayout(types).value('GhosttyPointTag', 'SCREEN')); + expect(Object.keys(L.mouse.button)).toEqual([ + 'left', + 'middle', + 'right', + 'four', + 'five', + 'eight', + 'nine', + ]); + }); + + test('a type the binding needs missing from the manifest throws with its name', () => { + expect(() => resolveOfficialLayout(withType('GhosttySearchStatus', undefined))).toThrow( + 'no type GhosttySearchStatus', + ); + }); + + test('codepoint arms that disagree throw', () => { + const cell = structuredClone(types.GhosttyCell); + const arm = cell.bits?.content?.arms?.CODEPOINT_GRAPHEME; + if (!arm) throw new Error('the pinned manifest has no CODEPOINT_GRAPHEME arm'); + arm.bits.codepoint = { ...arm.bits.codepoint, lsb: arm.bits.codepoint.lsb + 1 }; + expect(() => resolveOfficialLayout(withType('GhosttyCell', cell))).toThrow( + 'codepoint arms differ', + ); + }); +}); diff --git a/src/client/vendor/tests/engine/scratch.test.ts b/src/client/vendor/tests/engine/scratch.test.ts new file mode 100644 index 0000000..0a083f6 --- /dev/null +++ b/src/client/vendor/tests/engine/scratch.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import { Scratch, type ScratchAllocator } from '../../ghostty-web/engine/scratch'; + +// The scratch allocator on a fake wasm allocator: slot layout, alignment and its one free. + +function fakeAllocator(address: number) { + const calls: string[] = []; + const ex: ScratchAllocator = { + ghostty_wasm_alloc: (len) => { + calls.push(`alloc ${len}`); + return address; + }, + ghostty_wasm_free: (ptr, len) => { + calls.push(`free ${ptr} ${len}`); + }, + }; + return { ex, calls }; +} + +describe('Scratch', () => { + test('lays the slots out in order, each at its alignment, from an 8-aligned base', () => { + const { ex, calls } = fakeAllocator(1003); + const { at } = Scratch.create(ex, { a: [1, 1], b: [4, 4], c: [8, 8], d: [2, 2] }); + // a at 0, b at 4, c at 8, d at 16: 18 bytes, plus 8 to align the base. + expect(calls).toEqual(['alloc 26']); + expect(at).toEqual({ a: 1008, b: 1012, c: 1016, d: 1024 }); + }); + + test('frees the allocation it made, at the raw address', () => { + const { ex, calls } = fakeAllocator(1003); + const { scratch } = Scratch.create(ex, { a: [4, 4] }); + scratch.free(); + expect(calls).toEqual(['alloc 12', 'free 1003 12']); + expect(scratch.base).toBe(1003); + expect(scratch.len).toBe(12); + }); + + test('throws when the wasm is out of memory', () => { + const { ex } = fakeAllocator(0); + expect(() => Scratch.create(ex, { a: [4, 4] })).toThrow('libghostty-vt: out of memory'); + }); +}); diff --git a/src/client/vendor/tests/engine/text-mime.test.ts b/src/client/vendor/tests/engine/text-mime.test.ts new file mode 100644 index 0000000..d49a01a --- /dev/null +++ b/src/client/vendor/tests/engine/text-mime.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { isTextMime } from '../../ghostty-web/engine/text-mime'; + +// Ghostty's plain-text MIME names for clipboard writes (ADR 052). + +describe('isTextMime', () => { + test.each([ + ['text/plain', true], + ['text/plain;charset=utf-8', true], + ['UTF8_STRING', true], + ['TEXT', true], + ['STRING', true], + ['text/html', false], + ['image/png', false], + ['Text/Plain', false], + ['', false], + ])('%p is %p', (mime, text) => { + expect(isTextMime(mime)).toBe(text); + }); +}); diff --git a/src/client/vendor/tests/engine/vt-layout.test.ts b/src/client/vendor/tests/engine/vt-layout.test.ts new file mode 100644 index 0000000..c962461 --- /dev/null +++ b/src/client/vendor/tests/engine/vt-layout.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { + type ManifestType, + readBits, + readVtLayout, + VtLayout, +} from '../../ghostty-web/engine/vt-layout'; + +// The manifest reader (ADR 041) on a hand-written manifest, without the wasm. + +const MANIFEST: Record<string, ManifestType> = { + GhosttyPoint: { + kind: 'struct', + size: 16, + fields: { tag: { offset: 0, size: 4 }, value: { offset: 8, size: 8 } }, + }, + GhosttyPointTag: { kind: 'enum', size: 4, values: { ACTIVE: 0, SCREEN: 2 } }, + GhosttyCell: { + kind: 'packed', + size: 8, + bits: { + content_tag: { lsb: 0, width: 2 }, + wide: { lsb: 62, width: 2 }, + whole: { lsb: 0, width: 32 }, + content: { + lsb: 2, + width: 24, + arms: { + CODEPOINT: { bits: { codepoint: { lsb: 0, width: 21 } } }, + NONE: null, + }, + }, + }, + }, +}; + +const layout = new VtLayout(MANIFEST); + +describe('readBits', () => { + test.each([ + { name: 'low word, at bit 0', lo: 0xabcd, hi: 0, lsb: 0, mask: 0xff, bits: 0xcd }, + { name: 'low word, shifted', lo: 0xabcd, hi: 0, lsb: 8, mask: 0xff, bits: 0xab }, + { name: 'high word', lo: 0, hi: 0b1100, lsb: 34, mask: 0b11, bits: 0b11 }, + { name: 'across the words', lo: 0xf0000000, hi: 0x3, lsb: 28, mask: 0x3f, bits: 0x3f }, + { name: 'a full low word', lo: 0xffffffff, hi: 0, lsb: 0, mask: 0xffffffff, bits: 0xffffffff }, + ])('$name', ({ lo, hi, lsb, mask, bits }) => { + expect(readBits(lo, hi, lsb, mask) >>> 0).toBe(bits); + }); +}); + +describe('VtLayout', () => { + test('reads sizes, offsets and enum values', () => { + expect(layout.size('GhosttyPoint')).toBe(16); + expect(layout.offset('GhosttyPoint', 'value')).toBe(8); + expect(layout.enums('GhosttyPointTag')).toEqual({ ACTIVE: 0, SCREEN: 2 }); + expect(layout.value('GhosttyPointTag', 'SCREEN')).toBe(2); + }); + + test('turns a bit width into a mask, a 32-bit field into all ones', () => { + const bits = layout.type('GhosttyCell').bits; + expect(layout.bitField(bits, 'content_tag')).toEqual({ lsb: 0, mask: 0b11 }); + expect(layout.bitField(bits, 'wide')).toEqual({ lsb: 62, mask: 0b11 }); + expect(layout.bitField(bits, 'whole')).toEqual({ lsb: 0, mask: 0xffffffff }); + }); + + test('reads the bits of one arm of a packed union field', () => { + const arm = layout.armBits('GhosttyCell', 'content', 'CODEPOINT'); + expect(layout.bitField(arm, 'codepoint')).toEqual({ lsb: 0, mask: 0x1fffff }); + }); + + test.each([ + ['a type', () => layout.type('GhosttyNope'), 'no type GhosttyNope'], + ['a field', () => layout.offset('GhosttyPoint', 'nope'), 'no field GhosttyPoint.nope'], + ['an enum', () => layout.enums('GhosttyPoint'), 'GhosttyPoint is not an enum'], + ['a value', () => layout.value('GhosttyPointTag', 'NOPE'), 'no value GhosttyPointTag.NOPE'], + ['a bit field', () => layout.bitField(undefined, 'wide'), 'no bit field wide'], + [ + 'an arm', + () => layout.armBits('GhosttyCell', 'content', 'NONE'), + 'no arm GhosttyCell.content.NONE', + ], + ])('a missing %s throws with its name', (_what, read, message) => { + expect(read).toThrow(message); + }); +}); + +describe('readVtLayout', () => { + test('decodes the NUL-terminated manifest at the address ghostty_type_json returns', () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const json = new TextEncoder().encode(JSON.stringify({ types: MANIFEST })); + const at = 100; + new Uint8Array(memory.buffer).set(json, at); + const read = readVtLayout({ memory, ghostty_type_json: () => at }); + expect(read.value('GhosttyPointTag', 'SCREEN')).toBe(2); + expect(read.offset('GhosttyPoint', 'value')).toBe(8); + }); +}); From 76f91879d58e456186e0e085c46d6c608444f712 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:38:29 -0400 Subject: [PATCH 231/295] refactor(client): engine/wasm-loader.ts, the wasm loading out of Ghostty.load Ghostty.load(path) keeps its signature and now builds a Ghostty on the exports engine/wasm-loader.ts returns (ADR 061): - loadWasmExports: the given path, or the default paths in order with the last error thrown, as Ghostty.load did; - defaultWasmPaths: upstream's path list, from the default URL, which is now ../../ghostty-vt/ghostty-vt-small.wasm for the file's depth; - readWasm: a local file through the injected reader, else the injected fetch, with upstream's status and empty-body errors. The reader and fetch are injected (WasmLoaderDeps; production: Bun.file where Bun runs, then the global fetch). Upstream's Node fs/promises fallback goes: the browser never reached it and tests run under Bun, so its client-node ratchet entry is removed. The client still passes /ghostty-vt-small.wasm explicitly. wasm-loader.test.ts covers the default URL, the path list, the read order, both errors, an injected fetch and the production reader (100% lines). --- scripts/architecture/ratchet-allowlist.ts | 3 - src/client/vendor/ghostty-web/PROVENANCE.md | 4 +- .../vendor/ghostty-web/engine/wasm-loader.ts | 99 +++++++++ src/client/vendor/ghostty-web/ghostty.ts | 87 +------- .../vendor/tests/engine/wasm-loader.test.ts | 196 ++++++++++++++++++ 5 files changed, 304 insertions(+), 85 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/wasm-loader.ts create mode 100644 src/client/vendor/tests/engine/wasm-loader.test.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index eb1b5c2..df02b8b 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -49,8 +49,5 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'banned-name src/pty/types.ts': 'server track: pty/pty-process.ts (ADR 062)', 'banned-name src/client/vendor/ghostty-web/types.ts': 'V15: the vendor types.ts shim is removed (ADR 061)', - // Upstream's Node fallback in Ghostty.loadFromPath; tests run under Bun, where Bun.file - // reads the wasm first, so engine/wasm-loader.ts can drop it. - 'client-node src/client/vendor/ghostty-web/ghostty.ts -> fs/promises': ENGINE_SPLIT, }, }; diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 2cdef94..eef23e3 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -46,6 +46,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `types.ts`, `terminal.ts`, `interfaces.ts`, `index.ts`: desktop notifications (ADR 056). The binding installs Ghostty's `DESKTOP_NOTIFICATION` callback when the new `GhosttyTerminalConfig.desktopNotifications` is on (default true): it copies the request's `title` (`''` for OSC 9) and `body` out as UTF-8, reading only what the struct's `size` covers. `TerminalEffects` gains `notifications`, every request since `readEffects`, in order, and `DesktopNotification` is exported. `Terminal` gains the `desktopNotifications` option, passed to the engine, and `onDesktopNotification`, fired for each. Upstream ignored OSC 9 and OSC 777. - `ghostty.ts`, `terminal.ts`, `selection-manager.ts`: OSC 133 prompt jumps and output selection (ADR 057). The binding adds `promptJumpRow` (Ghostty's viewport put on webtty's top row with `ROW`, moved with `GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`, read back with `GHOSTTY_TERMINAL_DATA_SCROLLBAR` and put back at the bottom; `scrollViewport` writes its value as an `isize`) and `selectOutput` (`ghostty_terminal_select_output`). `Terminal` adds `jumpToPrompt`, Ghostty's `jump_to_prompt`, scrolling the view with `scrollToLine`. A triple click with Ctrl or Cmd held selects the command's output instead of the line, as in Ghostty. Upstream had neither. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. +- `engine/wasm-loader.ts` (from `ghostty.ts`): the wasm is read through an injected reader and fetch (`WasmLoaderDeps`; production: `Bun.file` where Bun runs, then the global `fetch`), and upstream's Node `fs/promises` fallback is removed: the browser never reached it and tests run under Bun, where `Bun.file` reads first (ADR 061). The default URL is `../../ghostty-vt/ghostty-vt-small.wasm`, for the file's new depth. - `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `Glide`, `AT_ONCE`, `FRAME_MS`, `linear`, `easeOutCubic`, `Pace` and `Ease` (`glide.ts`), `watchDevicePixelRatio` (`renderer.ts`) and `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`), which the app imported by deep path or did not have. ## webtty-original files @@ -60,7 +61,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). -- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -83,3 +84,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `ghostty.ts`: `CURSOR_BLINK_SEQ`, `CURSOR_BLINK_PREFIX`, `scanCursorBlink` (webtty, re-exported by `ghostty.ts`) | `engine/cursor-blink.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | | `ghostty.ts`: `TEXT_MIMES`, `isTextMime` (webtty, re-exported by `ghostty.ts`) | `engine/text-mime.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | | `ghostty.ts`: `isKittyKeyboardReply` (webtty) | `engine/kitty-keyboard-reply.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | +| `ghostty.ts`: `Ghostty.load` (the default paths, now `defaultWasmPaths`, and the loop over them, now `loadWasmExports`) and `Ghostty.loadFromPath` (the read, now `readWasm`, and the compile) | `engine/wasm-loader.ts` | refactor(client): engine/wasm-loader.ts, the wasm loading out of Ghostty.load | diff --git a/src/client/vendor/ghostty-web/engine/wasm-loader.ts b/src/client/vendor/ghostty-web/engine/wasm-loader.ts new file mode 100644 index 0000000..15e3d71 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/wasm-loader.ts @@ -0,0 +1,99 @@ +// Derived from coder/ghostty-web lib/ghostty.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Reads and instantiates the official libghostty-vt wasm (service, ADR 041, ADR 047, +// ADR 061): from a given path, or from the pinned wasm beside this layer, through an +// injected file reader and fetch so the order and the errors can be tested. + +import type { OfficialWasmExports } from './wasm-exports'; + +/** The pinned wasm, relative to this file (`vendor/ghostty-vt/`, two folders up). */ +export const DEFAULT_WASM_URL = '../../ghostty-vt/ghostty-vt-small.wasm'; + +/** How the loader reads a wasm file, so tests can load without the network or the disk. */ +export interface WasmLoaderDeps { + /** A local file's bytes, or undefined when the path is not a readable file. */ + readFile(path: string): Promise<ArrayBuffer | undefined>; + fetch(path: string): Promise<Response>; +} + +/** Reads with `Bun.file` where Bun runs (tests, scripts); the browser has no local files. */ +async function readBunFile(path: string): Promise<ArrayBuffer | undefined> { + if (typeof Bun === 'undefined' || typeof Bun.file !== 'function') return undefined; + try { + const file = Bun.file(path); + if (await file.exists()) return await file.arrayBuffer(); + } catch { + // Not a readable file path; fetch it instead. + } + return undefined; +} + +/** The production reader: `Bun.file` when there is one, then the global `fetch`. */ +export const DEFAULT_WASM_LOADER_DEPS: WasmLoaderDeps = { + readFile: readBunFile, + fetch: (path) => fetch(path), +}; + +/** + * The paths tried when no path is given, in order: the default URL as a file path (for + * Bun), as a URL, then the page-relative and root names the build serves the wasm at. + */ +export function defaultWasmPaths(moduleUrl: URL): string[] { + const paths: string[] = []; + if (moduleUrl.protocol === 'file:') { + let filePath = moduleUrl.pathname; + // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) + if (filePath.match(/^\/[A-Za-z]:\//)) { + filePath = filePath.slice(1); + } + paths.push(filePath); + } + paths.push(moduleUrl.href, './ghostty-vt-small.wasm', '/ghostty-vt-small.wasm'); + return paths; +} + +/** + * The wasm bytes at `path`: a local file if the reader finds one, else a fetch, which + * must succeed and return a non-empty body. + */ +export async function readWasm(path: string, deps: WasmLoaderDeps): Promise<ArrayBuffer> { + const local = await deps.readFile(path); + if (local) return local; + const response = await deps.fetch(path); + if (!response.ok) { + throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); + } + const bytes = await response.arrayBuffer(); + if (bytes.byteLength === 0) { + throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); + } + return bytes; +} + +async function instantiateFrom(path: string, deps: WasmLoaderDeps): Promise<OfficialWasmExports> { + const wasmModule = await WebAssembly.compile(await readWasm(path, deps)); + // The official wasm imports nothing. + const instance = await WebAssembly.instantiate(wasmModule, {}); + return instance.exports as unknown as OfficialWasmExports; +} + +/** + * The exports of a new wasm instance, for `Ghostty.load`: from `wasmPath`, or else from + * the first of the default paths that loads, throwing the last path's error if none does. + */ +export async function loadWasmExports( + wasmPath?: string, + deps: WasmLoaderDeps = DEFAULT_WASM_LOADER_DEPS, + moduleUrl: URL = new URL(DEFAULT_WASM_URL, import.meta.url) +): Promise<OfficialWasmExports> { + if (wasmPath) return instantiateFrom(wasmPath, deps); + + let lastError: Error | null = null; + for (const path of defaultWasmPaths(moduleUrl)) { + try { + return await instantiateFrom(path, deps); + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + } + } + throw lastError || new Error('Failed to load Ghostty WASM'); +} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index f79fd45..9ab109e 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -33,6 +33,7 @@ import { isKittyKeyboardReply } from './engine/kitty-keyboard-reply'; import { type OfficialLayout, resolveOfficialLayout } from './engine/official-layout'; import { Scratch } from './engine/scratch'; import { isTextMime } from './engine/text-mime'; +import { loadWasmExports } from './engine/wasm-loader'; import { readBits, readVtLayout } from './engine/vt-layout'; import type { OfficialWasmExports } from './engine/wasm-exports'; import { FnTable, type InstalledFn, WasmValType } from './fn-table'; @@ -123,88 +124,12 @@ export class Ghostty { this.unidentifiedKey = layout.value('GhosttyKey', 'UNIDENTIFIED'); } + /** + * Loads the official wasm from `wasmPath`, or from the pinned copy beside this layer + * (engine/wasm-loader.ts, ADR 061). + */ static async load(wasmPath?: string): Promise<Ghostty> { - // If explicit path provided, use it - if (wasmPath) { - return Ghostty.loadFromPath(wasmPath); - } - - // Resolve path relative to this module - const moduleUrl = new URL('../ghostty-vt/ghostty-vt-small.wasm', import.meta.url); - - // Build paths to try, prioritizing file system paths for Node/Bun - const defaultPaths: string[] = []; - - // For Node/Bun: try absolute file path first (strip file:// protocol) - if (moduleUrl.protocol === 'file:') { - let filePath = moduleUrl.pathname; - // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) - if (filePath.match(/^\/[A-Za-z]:\//)) { - filePath = filePath.slice(1); - } - defaultPaths.push(filePath); - } - - // Also try other common paths - defaultPaths.push(moduleUrl.href, './ghostty-vt-small.wasm', '/ghostty-vt-small.wasm'); - - let lastError: Error | null = null; - for (const path of defaultPaths) { - try { - return await Ghostty.loadFromPath(path); - } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)); - } - } - throw lastError || new Error('Failed to load Ghostty WASM'); - } - - private static async loadFromPath(path: string): Promise<Ghostty> { - let wasmBytes: ArrayBuffer | undefined; - - // Try Bun.file first (for Bun environments) - if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { - try { - const file = Bun.file(path); - if (await file.exists()) { - wasmBytes = await file.arrayBuffer(); - } - } catch { - // Bun.file failed, try next method - } - } - - // Try Node.js fs module if Bun.file didn't work - if (!wasmBytes) { - try { - const fs = await import('fs/promises'); - const buffer = await fs.readFile(path); - wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); - } catch { - // fs failed, try fetch - } - } - - // Fall back to fetch (for browser environments) - if (!wasmBytes) { - const response = await fetch(path); - if (!response.ok) { - throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); - } - wasmBytes = await response.arrayBuffer(); - if (wasmBytes.byteLength === 0) { - throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); - } - } - - if (!wasmBytes) { - throw new Error(`Could not load WASM from path: ${path}`); - } - - const wasmModule = await WebAssembly.compile(wasmBytes); - // The official wasm imports nothing. - const instance = await WebAssembly.instantiate(wasmModule, {}); - return new Ghostty(instance.exports as unknown as OfficialWasmExports); + return new Ghostty(await loadWasmExports(wasmPath)); } createKeyEncoder(): KeyEncoder { diff --git a/src/client/vendor/tests/engine/wasm-loader.test.ts b/src/client/vendor/tests/engine/wasm-loader.test.ts new file mode 100644 index 0000000..1b3566c --- /dev/null +++ b/src/client/vendor/tests/engine/wasm-loader.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + DEFAULT_WASM_LOADER_DEPS, + DEFAULT_WASM_URL, + defaultWasmPaths, + loadWasmExports, + readWasm, + type WasmLoaderDeps, +} from '../../ghostty-web/engine/wasm-loader'; +import { WASM_PATH } from '../support/wasm'; + +// The wasm loader (ADR 061): the default path, the order it reads in, and its errors, +// through an injected reader and fetch. + +const LOADER_PATH = fileURLToPath( + new URL('../../ghostty-web/engine/wasm-loader.ts', import.meta.url), +); + +const wasmBytes = () => Bun.file(WASM_PATH).arrayBuffer(); + +/** A reader that finds no local file and a fetch that answers from `responses`, recording paths. */ +function fakeDeps(respond: (path: string) => Promise<Response>) { + const fetched: string[] = []; + const read: string[] = []; + const deps: WasmLoaderDeps = { + readFile: async (p) => { + read.push(p); + return undefined; + }, + fetch: (p) => { + fetched.push(p); + return respond(p); + }, + }; + return { deps, fetched, read }; +} + +describe('DEFAULT_WASM_URL', () => { + test('resolves from engine/ to the pinned wasm', () => { + const resolved = fileURLToPath(new URL(DEFAULT_WASM_URL, pathToFileURL(LOADER_PATH))); + expect(resolved).toBe(path.resolve(WASM_PATH)); + }); +}); + +describe('defaultWasmPaths', () => { + test.each([ + { + name: 'a file URL is tried as a path first', + url: 'file:///opt/w/ghostty-vt-small.wasm', + paths: [ + '/opt/w/ghostty-vt-small.wasm', + 'file:///opt/w/ghostty-vt-small.wasm', + './ghostty-vt-small.wasm', + '/ghostty-vt-small.wasm', + ], + }, + { + name: 'a Windows drive path loses its leading slash', + url: 'file:///C:/w/ghostty-vt-small.wasm', + paths: [ + 'C:/w/ghostty-vt-small.wasm', + 'file:///C:/w/ghostty-vt-small.wasm', + './ghostty-vt-small.wasm', + '/ghostty-vt-small.wasm', + ], + }, + { + name: 'an http URL is fetched as is', + url: 'http://h/a/ghostty-vt-small.wasm', + paths: [ + 'http://h/a/ghostty-vt-small.wasm', + './ghostty-vt-small.wasm', + '/ghostty-vt-small.wasm', + ], + }, + ])('$name', ({ url, paths }) => { + expect(defaultWasmPaths(new URL(url))).toEqual([...paths]); + }); +}); + +describe('readWasm', () => { + test('a local file is read without a fetch', async () => { + const bytes = new Uint8Array([1, 2, 3]).buffer; + const fetched: string[] = []; + const deps: WasmLoaderDeps = { + readFile: async () => bytes, + fetch: async (p) => { + fetched.push(p); + return new Response(''); + }, + }; + expect(await readWasm('/x.wasm', deps)).toBe(bytes); + expect(fetched).toEqual([]); + }); + + test('otherwise the injected fetch is used', async () => { + const { deps, fetched, read } = fakeDeps(async () => new Response(new Uint8Array([7]))); + expect(new Uint8Array(await readWasm('/ghostty-vt-small.wasm', deps))).toEqual( + new Uint8Array([7]), + ); + expect(read).toEqual(['/ghostty-vt-small.wasm']); + expect(fetched).toEqual(['/ghostty-vt-small.wasm']); + }); + + test('an empty body is an error naming the path', async () => { + const { deps } = fakeDeps(async () => new Response(new Uint8Array(0))); + await expect(readWasm('/empty.wasm', deps)).rejects.toThrow( + 'WASM file is empty (0 bytes). Check path: /empty.wasm', + ); + }); + + test('a failed fetch is an error with its status', async () => { + const { deps } = fakeDeps( + async () => new Response('nope', { status: 404, statusText: 'Not Found' }), + ); + await expect(readWasm('/missing.wasm', deps)).rejects.toThrow( + 'Failed to fetch WASM: 404 Not Found', + ); + }); +}); + +describe('loadWasmExports', () => { + test('a given path is loaded through the injected fetch', async () => { + const bytes = await wasmBytes(); + const { deps, fetched } = fakeDeps(async () => new Response(bytes)); + const ex = await loadWasmExports('/ghostty-vt-small.wasm', deps); + expect(fetched).toEqual(['/ghostty-vt-small.wasm']); + expect(typeof ex.ghostty_terminal_new).toBe('function'); + expect(ex.memory).toBeInstanceOf(WebAssembly.Memory); + }); + + test('with no path, the default paths are tried in order until one loads', async () => { + const bytes = await wasmBytes(); + const { deps, fetched } = fakeDeps(async (p) => + p === '/ghostty-vt-small.wasm' ? new Response(bytes) : new Response('', { status: 404 }), + ); + const ex = await loadWasmExports(undefined, deps, new URL('http://h/a/ghostty-vt-small.wasm')); + expect(fetched).toEqual([ + 'http://h/a/ghostty-vt-small.wasm', + './ghostty-vt-small.wasm', + '/ghostty-vt-small.wasm', + ]); + expect(typeof ex.ghostty_terminal_free).toBe('function'); + }); + + test('with no path and nothing loading, the last error is thrown', async () => { + const { deps } = fakeDeps(async (p) => + p === '/ghostty-vt-small.wasm' ? new Response(new Uint8Array(0)) : Promise.reject('offline'), + ); + await expect( + loadWasmExports(undefined, deps, new URL('http://h/ghostty-vt-small.wasm')), + ).rejects.toThrow('WASM file is empty (0 bytes). Check path: /ghostty-vt-small.wasm'); + }); + + test('a non-Error rejection is wrapped', async () => { + const { deps } = fakeDeps(() => Promise.reject('offline')); + await expect( + loadWasmExports(undefined, deps, new URL('http://h/ghostty-vt-small.wasm')), + ).rejects.toThrow('offline'); + }); +}); + +describe('the production reader', () => { + test('reads the pinned wasm from disk with Bun.file', async () => { + const bytes = await DEFAULT_WASM_LOADER_DEPS.readFile(WASM_PATH); + expect(bytes?.byteLength).toBe(Bun.file(WASM_PATH).size); + }); + + test('a missing file is undefined, for the fetch to try', async () => { + expect(await DEFAULT_WASM_LOADER_DEPS.readFile('/no/such/ghostty-vt.wasm')).toBeUndefined(); + }); + + test('a path Bun.file cannot open is undefined too', async () => { + expect(await DEFAULT_WASM_LOADER_DEPS.readFile('\0')).toBeUndefined(); + }); + + test('loads the default wasm with no path and no deps', async () => { + const ex = await loadWasmExports(); + expect(typeof ex.ghostty_terminal_new).toBe('function'); + }); + + test('fetches through the global fetch', async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response(new Uint8Array([9, 9])), + }); + try { + const response = await DEFAULT_WASM_LOADER_DEPS.fetch(`http://localhost:${server.port}/w`); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([9, 9])); + } finally { + server.stop(true); + } + }); +}); From a6d7e717438593b72bc0b62f0f11a26fdf308df7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:39:40 -0400 Subject: [PATCH 232/295] refactor(client): engine/key-encoder-binding.ts and the EngineHost interface OfficialKeyEncoder leaves ghostty.ts for engine/key-encoder-binding.ts with its body unchanged (ADR 061). The -binding suffix keeps it apart from input/key-encoding.ts, the DOM key table lookup. The encoder took the Ghostty facade; it now takes EngineHost, a new interface in engine/engine-types.ts naming what the bindings use of the loaded instance (the exports, the layout, the function table, the key translations and newHandle). Ghostty implements it, so split engine parts import the narrow interface instead of the facade. --- src/client/vendor/ghostty-web/PROVENANCE.md | 5 +- .../vendor/ghostty-web/engine/engine-types.ts | 26 ++++++ .../ghostty-web/engine/key-encoder-binding.ts | 89 +++++++++++++++++++ src/client/vendor/ghostty-web/ghostty.ts | 88 +----------------- 4 files changed, 122 insertions(+), 86 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/key-encoder-binding.ts diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index eef23e3..90fe3c9 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -55,13 +55,13 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. -- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. +- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts` (ADR 042): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). -- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -85,3 +85,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `ghostty.ts`: `TEXT_MIMES`, `isTextMime` (webtty, re-exported by `ghostty.ts`) | `engine/text-mime.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | | `ghostty.ts`: `isKittyKeyboardReply` (webtty) | `engine/kitty-keyboard-reply.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | | `ghostty.ts`: `Ghostty.load` (the default paths, now `defaultWasmPaths`, and the loop over them, now `loadWasmExports`) and `Ghostty.loadFromPath` (the read, now `readWasm`, and the compile) | `engine/wasm-loader.ts` | refactor(client): engine/wasm-loader.ts, the wasm loading out of Ghostty.load | +| `ghostty.ts`: `OfficialKeyEncoder` (webtty), now exported and typed on `EngineHost` | `engine/key-encoder-binding.ts` | refactor(client): engine/key-encoder-binding.ts and the EngineHost interface | diff --git a/src/client/vendor/ghostty-web/engine/engine-types.ts b/src/client/vendor/ghostty-web/engine/engine-types.ts index a53adf3..1dcd557 100644 --- a/src/client/vendor/ghostty-web/engine/engine-types.ts +++ b/src/client/vendor/ghostty-web/engine/engine-types.ts @@ -4,7 +4,10 @@ // values they take and return. Split parts import these instead of the binding's classes (ADR 061). import type { DirtyState, GhosttyCell, RenderStateCursor } from './cell-types'; +import type { FnTable } from '../fn-table'; import type { KeyEncoderOption, KeyEvent } from '../input/key-types'; +import type { OfficialLayout } from './official-layout'; +import type { OfficialWasmExports } from './wasm-exports'; /** * Terminal configuration, applied when the terminal is created. Colours are @@ -265,3 +268,26 @@ export interface KeyEncoder { encode(event: KeyEvent): Uint8Array; dispose(): void; } + +/** + * What the engine's bindings need from the loaded wasm instance, `Ghostty`, so they + * import this interface instead of the facade (ADR 061). + */ +export interface EngineHost { + /** The wasm exports. */ + readonly ex: OfficialWasmExports; + /** The manifest's offsets and enum values, resolved per feature. */ + readonly L: OfficialLayout; + /** One function table per instance; terminals install their callbacks in it. */ + readonly fnTable: FnTable; + /** ghostty-web `Key` value → official `GhosttyKey` value. */ + readonly keys: ReadonlyMap<number, number>; + /** ghostty-web `KeyEncoderOption` → official `GhosttyKeyEncoderOption`. */ + readonly encoderOptions: ReadonlyMap<number, number>; + /** ghostty-web `KeyAction` → official `GhosttyKeyAction`. */ + readonly keyActions: ReadonlyMap<number, number>; + /** The official `GhosttyKey` for a key with no translation. */ + readonly unidentifiedKey: number; + /** Creates an object through a `GhosttyFoo* out` slot and returns its handle. */ + newHandle(what: string, create: (slot: number) => number): number; +} diff --git a/src/client/vendor/ghostty-web/engine/key-encoder-binding.ts b/src/client/vendor/ghostty-web/engine/key-encoder-binding.ts new file mode 100644 index 0000000..bf37890 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/key-encoder-binding.ts @@ -0,0 +1,89 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The key encoder binding (object): wraps one libghostty-vt key encoder, turning +// ghostty-web key events into the bytes the app expects. Not to be confused with +// input/key-encoding.ts, the DOM key table lookup. + +import type { KeyEncoderOption, KeyEvent } from '../input/key-types'; +import type { EngineHost, KeyEncoder } from './engine-types'; +import type { OfficialWasmExports } from './wasm-exports'; + +/** + * Key encoder on the official wasm. ghostty-web's `Key`, `KeyAction` and + * `KeyEncoderOption` values are translated by name to the official enums. + */ +export class OfficialKeyEncoder implements KeyEncoder { + private readonly vt: EngineHost; + private readonly ex: OfficialWasmExports; + private encoder: number; + private readonly value: number; + private readonly written: number; + + constructor(vt: EngineHost) { + this.vt = vt; + this.ex = vt.ex; + this.encoder = vt.newHandle('key_encoder_new', (slot) => this.ex.ghostty_key_encoder_new(0, slot)); + this.value = this.ex.ghostty_wasm_alloc(8); + this.written = this.ex.ghostty_wasm_alloc(8); + } + + setOption(option: KeyEncoderOption, value: boolean | number): void { + const official = this.vt.encoderOptions.get(option); + if (official === undefined) return; + // Every option ghostty-web sets is a bool or the u8 kitty flags. + new DataView(this.ex.memory.buffer).setUint8( + this.value, + typeof value === 'boolean' ? (value ? 1 : 0) : value + ); + this.ex.ghostty_key_encoder_setopt(this.encoder, official, this.value); + } + + encode(event: KeyEvent): Uint8Array { + const ex = this.ex; + const vt = this.vt; + const ev = vt.newHandle('key_event_new', (slot) => ex.ghostty_key_event_new(0, slot)); + let utf8Ptr = 0; + let utf8Len = 0; + let buf = 0; + let cap = 64; + try { + ex.ghostty_key_event_set_action(ev, vt.keyActions.get(event.action) ?? event.action); + ex.ghostty_key_event_set_key(ev, vt.keys.get(event.key) ?? vt.unidentifiedKey); + ex.ghostty_key_event_set_mods(ev, event.mods); + if (event.utf8) { + const bytes = new TextEncoder().encode(event.utf8); + utf8Len = bytes.length; + utf8Ptr = ex.ghostty_wasm_alloc(utf8Len); + new Uint8Array(ex.memory.buffer).set(bytes, utf8Ptr); + // The event does not copy the text; it stays allocated until after encode. + ex.ghostty_key_event_set_utf8(ev, utf8Ptr, utf8Len); + } + for (let attempt = 0; attempt < 2; attempt++) { + buf = ex.ghostty_wasm_alloc(cap); + const rc = ex.ghostty_key_encoder_encode(this.encoder, ev, buf, cap, this.written); + const n = new DataView(ex.memory.buffer).getUint32(this.written, true); + if (rc === vt.L.result.OUT_OF_SPACE && attempt === 0) { + ex.ghostty_wasm_free(buf, cap); + buf = 0; + cap = n; + continue; + } + if (rc !== 0) throw new Error(`Failed to encode key: ${rc}`); + return new Uint8Array(ex.memory.buffer, buf, n).slice(); + } + throw new Error('Failed to encode key: out of space'); + } finally { + if (buf) ex.ghostty_wasm_free(buf, cap); + if (utf8Ptr) ex.ghostty_wasm_free(utf8Ptr, utf8Len); + ex.ghostty_key_event_free(ev); + } + } + + dispose(): void { + if (this.encoder) { + this.ex.ghostty_key_encoder_free(this.encoder); + this.ex.ghostty_wasm_free(this.value, 8); + this.ex.ghostty_wasm_free(this.written, 8); + this.encoder = 0; + } + } +} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index 9ab109e..a4f84da 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -15,6 +15,7 @@ import { scanCursorBlink } from './engine/cursor-blink'; import type { DesktopNotification, + EngineHost, GhosttyTerminal, KeyEncoder, MouseGeometry, @@ -24,6 +25,7 @@ import type { SearchState, TerminalEffects, } from './engine/engine-types'; +import { OfficialKeyEncoder } from './engine/key-encoder-binding'; import { OFFICIAL_ENCODER_OPTION_NAMES, OFFICIAL_KEY_NAMES, @@ -33,9 +35,9 @@ import { isKittyKeyboardReply } from './engine/kitty-keyboard-reply'; import { type OfficialLayout, resolveOfficialLayout } from './engine/official-layout'; import { Scratch } from './engine/scratch'; import { isTextMime } from './engine/text-mime'; -import { loadWasmExports } from './engine/wasm-loader'; import { readBits, readVtLayout } from './engine/vt-layout'; import type { OfficialWasmExports } from './engine/wasm-exports'; +import { loadWasmExports } from './engine/wasm-loader'; import { FnTable, type InstalledFn, WasmValType } from './fn-table'; import { CellFlags, @@ -45,7 +47,6 @@ import { Key, KeyAction, KeyEncoderOption, - type KeyEvent, type RenderStateCursor, } from './types'; @@ -92,7 +93,7 @@ export { isTextMime } from './engine/text-mime'; * A loaded libghostty-vt wasm instance (ADR 041, ADR 042): creates terminals and * key encoders. */ -export class Ghostty { +export class Ghostty implements EngineHost { /** @internal The wasm exports; only this file calls them. */ readonly ex: OfficialWasmExports; /** @internal */ @@ -2002,84 +2003,3 @@ export class OfficialTerminal implements GhosttyTerminal { this.pendingTitle = len ? new TextDecoder().decode(this.u8().slice(ptr, ptr + len)) : ''; } } - -/** - * Key encoder on the official wasm. ghostty-web's `Key`, `KeyAction` and - * `KeyEncoderOption` values are translated by name to the official enums. - */ -class OfficialKeyEncoder implements KeyEncoder { - private readonly vt: Ghostty; - private readonly ex: OfficialWasmExports; - private encoder: number; - private readonly value: number; - private readonly written: number; - - constructor(vt: Ghostty) { - this.vt = vt; - this.ex = vt.ex; - this.encoder = vt.newHandle('key_encoder_new', (slot) => this.ex.ghostty_key_encoder_new(0, slot)); - this.value = this.ex.ghostty_wasm_alloc(8); - this.written = this.ex.ghostty_wasm_alloc(8); - } - - setOption(option: KeyEncoderOption, value: boolean | number): void { - const official = this.vt.encoderOptions.get(option); - if (official === undefined) return; - // Every option ghostty-web sets is a bool or the u8 kitty flags. - new DataView(this.ex.memory.buffer).setUint8( - this.value, - typeof value === 'boolean' ? (value ? 1 : 0) : value - ); - this.ex.ghostty_key_encoder_setopt(this.encoder, official, this.value); - } - - encode(event: KeyEvent): Uint8Array { - const ex = this.ex; - const vt = this.vt; - const ev = vt.newHandle('key_event_new', (slot) => ex.ghostty_key_event_new(0, slot)); - let utf8Ptr = 0; - let utf8Len = 0; - let buf = 0; - let cap = 64; - try { - ex.ghostty_key_event_set_action(ev, vt.keyActions.get(event.action) ?? event.action); - ex.ghostty_key_event_set_key(ev, vt.keys.get(event.key) ?? vt.unidentifiedKey); - ex.ghostty_key_event_set_mods(ev, event.mods); - if (event.utf8) { - const bytes = new TextEncoder().encode(event.utf8); - utf8Len = bytes.length; - utf8Ptr = ex.ghostty_wasm_alloc(utf8Len); - new Uint8Array(ex.memory.buffer).set(bytes, utf8Ptr); - // The event does not copy the text; it stays allocated until after encode. - ex.ghostty_key_event_set_utf8(ev, utf8Ptr, utf8Len); - } - for (let attempt = 0; attempt < 2; attempt++) { - buf = ex.ghostty_wasm_alloc(cap); - const rc = ex.ghostty_key_encoder_encode(this.encoder, ev, buf, cap, this.written); - const n = new DataView(ex.memory.buffer).getUint32(this.written, true); - if (rc === vt.L.result.OUT_OF_SPACE && attempt === 0) { - ex.ghostty_wasm_free(buf, cap); - buf = 0; - cap = n; - continue; - } - if (rc !== 0) throw new Error(`Failed to encode key: ${rc}`); - return new Uint8Array(ex.memory.buffer, buf, n).slice(); - } - throw new Error('Failed to encode key: out of space'); - } finally { - if (buf) ex.ghostty_wasm_free(buf, cap); - if (utf8Ptr) ex.ghostty_wasm_free(utf8Ptr, utf8Len); - ex.ghostty_key_event_free(ev); - } - } - - dispose(): void { - if (this.encoder) { - this.ex.ghostty_key_encoder_free(this.encoder); - this.ex.ghostty_wasm_free(this.value, 8); - this.ex.ghostty_wasm_free(this.written, 8); - this.encoder = 0; - } - } -} From 9e8705548ed2cd1ec712b95e91ea6c92a1081d46 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:41:25 -0400 Subject: [PATCH 233/295] refactor(client): engine/official-terminal.ts, ghostty.ts becomes the factory facade OfficialTerminal moves whole out of ghostty.ts into engine/official-terminal.ts with its row types, constants, cell helpers and the binding's rules comment, bodies unchanged (ADR 061); `git blame -C` follows the lines. It now takes the EngineHost interface instead of the Ghostty class, so the engine imports no facade. ghostty.ts is the factory facade (118 lines, limit 300): Ghostty, which loads the wasm through engine/wasm-loader.ts and creates terminals and key encoders, plus the re-exports its importers use, OfficialTerminal among them. Its cell and key enums now come from engine/cell-types.ts and input/key-types.ts instead of the types.ts shim. The ratchet entry for ghostty.ts goes; engine/official-terminal.ts is listed at 1885 lines until the binding split brings it to its 500-line limit. PROVENANCE.md records the move. --- scripts/architecture/ratchet-allowlist.ts | 6 +- src/client/vendor/ghostty-web/PROVENANCE.md | 6 +- .../ghostty-web/engine/official-terminal.ts | 1884 ++++++++++++++++ src/client/vendor/ghostty-web/ghostty.ts | 1915 +---------------- 4 files changed, 1907 insertions(+), 1904 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/official-terminal.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index df02b8b..872fe88 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -17,7 +17,11 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/vendor/ghostty-web/ghostty.ts': { lines: 2160, step: ENGINE_SPLIT }, + // OfficialTerminal, moved whole out of ghostty.ts (2160 lines) before its own split. + 'src/client/vendor/ghostty-web/engine/official-terminal.ts': { + lines: 1885, + step: ENGINE_SPLIT, + }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, step: 'vendor input split into input/ (ADR 061)', diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 90fe3c9..a6a96ce 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -55,13 +55,14 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. -- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts` (ADR 042): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. +- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). -- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal (`official-terminal.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -86,3 +87,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `ghostty.ts`: `isKittyKeyboardReply` (webtty) | `engine/kitty-keyboard-reply.ts` | refactor(client): engine split, part 1: pure engine modules out of ghostty.ts | | `ghostty.ts`: `Ghostty.load` (the default paths, now `defaultWasmPaths`, and the loop over them, now `loadWasmExports`) and `Ghostty.loadFromPath` (the read, now `readWasm`, and the compile) | `engine/wasm-loader.ts` | refactor(client): engine/wasm-loader.ts, the wasm loading out of Ghostty.load | | `ghostty.ts`: `OfficialKeyEncoder` (webtty), now exported and typed on `EngineHost` | `engine/key-encoder-binding.ts` | refactor(client): engine/key-encoder-binding.ts and the EngineHost interface | +| `ghostty.ts`: `OfficialTerminal` (webtty) with its row types, constants and cell helpers, and the binding's rules comment; typed on `EngineHost`, re-exported by `ghostty.ts` (`git blame -C` follows the lines) | `engine/official-terminal.ts` | refactor(client): engine/official-terminal.ts, ghostty.ts becomes the factory facade | diff --git a/src/client/vendor/ghostty-web/engine/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts new file mode 100644 index 0000000..24b16dd --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -0,0 +1,1884 @@ +// webtty (ADR 042, ADR 047, ADR 061), not from upstream. +// The terminal on the official libghostty-vt wasm (facade): `OfficialTerminal` implements +// the `GhosttyTerminal` interface the rest of the vendored layer uses, on the wasm instance +// (`EngineHost`) that `Ghostty.createTerminal` passes it. Moved whole out of ghostty.ts (ADR 061). + +import { type InstalledFn, WasmValType } from '../fn-table'; +import { CellFlags, DirtyState, type GhosttyCell, type RenderStateCursor } from './cell-types'; +import { scanCursorBlink } from './cursor-blink'; +import type { + DesktopNotification, + EngineHost, + GhosttyTerminal, + GhosttyTerminalConfig, + MouseGeometry, + MouseInput, + ScreenCell, + ScreenRange, + SearchState, + TerminalEffects, +} from './engine-types'; +import { isKittyKeyboardReply } from './kitty-keyboard-reply'; +import type { OfficialLayout } from './official-layout'; +import { Scratch } from './scratch'; +import { isTextMime } from './text-mime'; +import { readBits } from './vt-layout'; +import type { OfficialWasmExports } from './wasm-exports'; + +// Rules of the official libghostty-vt binding (ADR 041, ADR 042): +// - Struct offsets, enum values and the packed GhosttyCell bit positions come from +// the wasm's own `ghostty_type_json` manifest at load. A missing name throws, so a +// pin bump that renames or moves a field fails loudly instead of misreading memory. +// The one hard-coded encoding is GhosttyMode (`modes.h` `ghostty_mode_new`: the +// mode number in bits 0-14, bit 15 set for ANSI modes), which the manifest only +// records as a u16. +// - No view of wasm memory outlives an export call: memory growth detaches the +// ArrayBuffer. Views are rebuilt after every call (`u8()`, `dv()`), CELLS_RAW rows +// are copied into a JS-owned array before anything else is called, and callbacks +// copy their payload before returning. +// - Callbacks never re-enter `vt_write`. + +/** A decoded row: JS-owned cells and the strings of its multi-codepoint graphemes. */ +interface RowRef { + cells: GhosttyCell[]; + /** Index of the row's first cell in `cells`. */ + base: number; + /** Column → grapheme string, for cells holding more than one codepoint. */ + graphemes: Map<number, string> | null; +} + +interface DecodedStyle { + /** 0xRRGGBB, or -1 for the default. */ + fg: number; + bg: number; + flags: number; +} + +const I32 = WasmValType.I32; +/** At most this many pages of scrollback rows stay cached per generation. */ +const PAGE_CACHE_SIZE = 16; +/** The scrollback byte ceiling under the line limit (ADR 046). */ +const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; +/** History in lines when the config gives none (ADR 046, ADR 047). */ +const DEFAULT_SCROLLBACK_LINES = 10000; +/** webtty's default colours, used when the theme leaves one unset. */ +const FALLBACK_FOREGROUND = 0xcccccc; +const FALLBACK_BACKGROUND = 0x000000; +/** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ +const GRAPHEME_SCRATCH = 32; +/** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ +const MOUSE_REPORT_MAX = 64; +/** Room for one focus report (`CSI I` / `CSI O`, 3 bytes). */ +const FOCUS_REPORT_MAX = 8; +/** + * The modes that set the mouse encoder's tracking mode and format: X10, normal, button, + * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. + */ +const MOUSE_MODES = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; +/** DEC private mode 12, AT&T cursor blink (ATT610). */ +const MODE_CURSOR_BLINK = 12; +/** Bytes as one char each, for scanning ASCII sequences in byte output. */ +const LATIN1 = new TextDecoder('latin1'); + +function blankCell(): GhosttyCell { + return { + codepoint: 0, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; +} + +function setRgb(cell: GhosttyCell, fg: number, bg: number): void { + cell.fg_r = (fg >>> 16) & 0xff; + cell.fg_g = (fg >>> 8) & 0xff; + cell.fg_b = fg & 0xff; + cell.bg_r = (bg >>> 16) & 0xff; + cell.bg_g = (bg >>> 8) & 0xff; + cell.bg_b = bg & 0xff; +} + +/** + * A terminal on the official libghostty-vt wasm (ADR 042). + * + * Rows are read through the render state: a refresh runs `render_state_update`, + * visits the dirty rows with `row_iterator_next_dirty`, and decodes each row's + * `CELLS_RAW` into JS-owned cells indexed by active row. Refreshes happen only + * when the generation (bumped by writes, resizes and pixel-size changes) has + * changed, so the many `update()` calls of one frame decode each row once. + * Ghostty's viewport stays at the bottom, so the primary copy is the active + * screen. Scrollback rows are read a page at a time through a second render + * state and cached until the content changes, so scrolling only records the + * offset and serves rows from the copy and the cache. + */ +export class OfficialTerminal implements GhosttyTerminal { + private readonly vt: EngineHost; + private readonly ex: OfficialWasmExports; + private readonly L: OfficialLayout; + private term = 0; + private rs = 0; + private iter = 0; + private rowCells = 0; + private pageRs = 0; + private readonly scratch: Scratch; + private readonly at: Record< + | 'iterH' + | 'cellsH' + | 'y' + | 'dirty' + | 'viewY' + | 'view' + | 'cursor' + | 'colors' + | 'style' + | 'rsKeys' + | 'rsVals' + | 'rowKeys' + | 'rowVals' + | 'tKeys' + | 'tVals' + | 'curX' + | 'curY' + | 'sbRows' + | 'screen' + | 'scroll' + | 'scrollbar' + | 'point' + | 'gridRef' + | 'sel' + | 'selOpts' + | 'coord' + | 'outPtr' + | 'outLen' + | 'row64' + | 'str' + | 'mode' + | 'u32' + | 'u8' + | 'gBuf' + | 'palette' + | 'mouseSize' + | 'mousePos' + | 'mouseOut' + | 'focusOut' + | 'clipReply' + | 'searchBuf', + number + >; + private readonly callbacks: InstalledFn[] = []; + private _cols: number; + private _rows: number; + private cellWidthPx = 0; + private cellHeightPx = 0; + + // Generation and refresh state. The generation is bumped by every content change + // (scrolling is not one) and also keys the page cache. + private generation = 1; + private refreshedGen = 0; + private infoGen = 0; + private decodeAll = true; + private readonly dirtyRows = new Set<number>(); + private dirtyMax: DirtyState = DirtyState.NONE; + + // The JS copy of the active screen, indexed by active row. + private viewCells: GhosttyCell[] = []; + private viewGraphemes: (Map<number, string> | null)[] = []; + private raw = new Uint32Array(0); + private readonly rowStyles = new Map<number, DecodedStyle>(); + + // Terminal info, read once per generation. + private cursorX = 0; + private cursorY = 0; + private sbRows = 0; + private altScreen = false; + + // Render-state cursor and colours from the last refresh. + private cursorVisible = true; + private cursorBlinking = false; + private cursorStyle: RenderStateCursor['style'] = 'block'; + // The blink DECSCUSR set, or the default while it has not set one; DEC mode 12 is + // ignored, so Ghostty's mode bit is put back to it after every write (ADR 049). + private blinkDefault = false; + private blinkIsDefault = true; + private blink = false; + private blinkCarry = ''; + private defFg = 0xffffff; + private defBg = 0; + private readonly palette = new Uint8Array(256 * 3); + + // The view's offset: lines scrolled up from the bottom. Ghostty's viewport stays at + // the bottom; only page reads move it, and they move it back. + private offset = 0; + + // Scrollback rows, keyed by the absolute row their page starts at. + private readonly pages = new Map<number, RowRef[]>(); + private pageGen = 0; + + // Mouse encoder and event, made on the first report (ADR 049). `mouseModes` is the + // MOUSE_MODES bitmask the encoder was last set from; `mouseGeometry` the last size. + private mouseEncoder = 0; + private mouseEvent = 0; + private mouseModes = -1; + private mouseGeometry = ''; + + // Paste: the mime reader callback, installed on the first paste, and the text it + // writes during a `paste` call (ADR 049). + private pasteReader = 0; + private pasteText = { ptr: 0, len: 0 }; + + // Effects. + private readonly responses: Uint8Array[] = []; + private bell = false; + private titleChanged = false; + private pendingTitle: string | null = null; + private pendingClipboard: string | null = null; + private pendingNotifications: DesktopNotification[] = []; + + // Search (ADR 055): the handle while a needle is set, the generation it last fed, and + // the wasm buffer matches are read into, grown as needed. + private search = 0; + private searchFedGen = 0; + private matchBuf = 0; + private matchCap = 0; + + constructor(vt: EngineHost, cols: number, rows: number, config?: GhosttyTerminalConfig) { + this.vt = vt; + this.ex = vt.ex; + this.L = vt.L; + this._cols = cols; + this._rows = rows; + const ex = this.ex; + const L = this.L; + + this.term = vt.newHandle('terminal_new', (slot) => ex.ghostty_terminal_new(0, slot, cols, rows)); + this.rs = vt.newHandle('render_state_new', (slot) => ex.ghostty_render_state_new(0, slot)); + this.iter = vt.newHandle('row_iterator_new', (slot) => + ex.ghostty_render_state_row_iterator_new(0, slot) + ); + this.rowCells = vt.newHandle('row_cells_new', (slot) => + ex.ghostty_render_state_row_cells_new(0, slot) + ); + + const { scratch, at } = Scratch.create(ex, { + iterH: [4, 4], + cellsH: [4, 4], + y: [4, 4], + dirty: [4, 4], + viewY: [4, 4], + view: [L.cellsView.size, 8], + cursor: [L.cursor.size, 8], + colors: [L.colors.size, 8], + style: [L.style.size, 8], + rsKeys: [12, 4], + rsVals: [12, 4], + rowKeys: [12, 4], + rowVals: [12, 4], + tKeys: [16, 4], + tVals: [16, 4], + curX: [4, 4], + curY: [4, 4], + sbRows: [4, 4], + screen: [4, 4], + scroll: [L.scroll.size, 8], + scrollbar: [L.scrollbar.size, 8], + point: [L.point.size, 8], + gridRef: [L.gridRef.size, 8], + sel: [L.selection.size, 8], + selOpts: [ + Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), + 8, + ], + coord: [8, 8], + outPtr: [4, 4], + outLen: [4, 4], + row64: [8, 8], + str: [L.string.size, 8], + mode: [L.mode.size, 8], + u32: [8, 8], + u8: [8, 8], + gBuf: [GRAPHEME_SCRATCH * 4, 8], + palette: [256 * L.colors.rgbSize, 8], + mouseSize: [L.mouse.size, 8], + mousePos: [L.mouse.positionSize, 8], + mouseOut: [MOUSE_REPORT_MAX, 8], + focusOut: [FOCUS_REPORT_MAX, 8], + clipReply: [L.clipboardWrite.replySize, 8], + searchBuf: [L.search.bufferSize, 8], + } as const); + this.scratch = scratch; + this.at = at; + + // Fixed scratch contents: handle slots and the key/value arrays of the multi-gets. + const dv = this.dv(); + dv.setUint32(at.iterH, this.iter, true); + dv.setUint32(at.cellsH, this.rowCells, true); + this.setU32s(at.rsKeys, [L.rs.DIRTY, L.rs.CURSOR, L.rs.COLORS]); + this.setU32s(at.rsVals, [at.dirty, at.cursor, at.colors]); + this.setU32s(at.rowKeys, [L.row.CELLS_RAW, L.row.VIEWPORT_Y, L.row.CELLS]); + this.setU32s(at.rowVals, [at.view, at.viewY, at.cellsH]); + this.setU32s(at.tKeys, [ + L.tdata.CURSOR_X, + L.tdata.CURSOR_Y, + L.tdata.SCROLLBACK_ROWS, + L.tdata.ACTIVE_SCREEN, + ]); + this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); + + this.installCallbacks( + config?.clipboardWrite ?? true, + config?.desktopNotifications ?? true + ); + this.applyConfig(config); + this.allocView(); + } + + get cols(): number { + return this._cols; + } + + get rows(): number { + return this._rows; + } + + // ========================================================================== + // Lifecycle + // ========================================================================== + + write(data: string | Uint8Array): void { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + this.feed(bytes, typeof data === 'string' ? data : undefined, false); + } + + writeUntilGround(data: Uint8Array): number | null { + return this.feed(data, undefined, true); + } + + /** vt_write, or vt_write_until_ground: the bytes consumed, null if ground was not reached. */ + private feed(bytes: Uint8Array, text: string | undefined, untilGround: boolean): number | null { + // Writes never leave the view scrolled (terminal.ts scrolls to the bottom after + // writing); the first frame at the bottom repaints every row. + if (this.offset !== 0) { + this.offset = 0; + this.dirtyMax = DirtyState.FULL; + } + const ex = this.ex; + let consumed: number | null = bytes.length; + // until_ground takes NULL for no bytes, and then only says whether it is at ground + const ptr = bytes.length > 0 ? ex.ghostty_wasm_alloc(bytes.length) : 0; + if (bytes.length > 0 && !ptr) throw new Error('libghostty-vt: out of memory'); + if (ptr) this.u8().set(bytes, ptr); + if (untilGround) { + const rc = ex.ghostty_terminal_vt_write_until_ground(this.term, ptr, bytes.length, this.at.u32); + consumed = rc === this.L.result.SUCCESS ? this.dv().getUint32(this.at.u32, true) : null; + } else if (ptr) { + ex.ghostty_terminal_vt_write(this.term, ptr, bytes.length); + } + if (ptr) ex.ghostty_wasm_free(ptr, bytes.length); + const fed = consumed ?? bytes.length; + if (fed > 0) { + const scan = scanCursorBlink( + text ?? LATIN1.decode(bytes.subarray(0, fed)), + this.blinkCarry + ); + this.blinkCarry = scan.carry; + if (scan.ps !== undefined) { + this.blinkIsDefault = scan.ps === 0; + this.blink = scan.ps === 0 ? this.blinkDefault : scan.ps % 2 === 1; + } + this.syncBlink(); + } + this.generation++; + if (this.titleChanged) this.readTitle(); + return consumed; + } + + resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { + if (cols === this._cols && rows === this._rows) { + if (cellWidthPx !== undefined && cellHeightPx !== undefined) { + this.setCellPixelSize(cellWidthPx, cellHeightPx); + } + return; + } + this._cols = cols; + this._rows = rows; + if (cellWidthPx !== undefined && cellHeightPx !== undefined) { + this.cellWidthPx = Math.max(0, Math.round(cellWidthPx)); + this.cellHeightPx = Math.max(0, Math.round(cellHeightPx)); + } + // One call per real change: with mode 2048 on, every call sends a size report. + const rc = this.ex.ghostty_terminal_resize( + this.term, + cols, + rows, + this.cellWidthPx, + this.cellHeightPx + ); + if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); + this.generation++; + this.decodeAll = true; + this.allocView(); + // Reflow keeps Ghostty's viewport at the bottom; only the offset is clamped. + if (this.offset > 0) this.setViewportOffset(this.offset); + } + + setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { + const w = Math.max(0, Math.round(cellWidthPx)); + const h = Math.max(0, Math.round(cellHeightPx)); + if (w === this.cellWidthPx && h === this.cellHeightPx) return; + this.cellWidthPx = w; + this.cellHeightPx = h; + const rc = this.ex.ghostty_terminal_resize(this.term, this._cols, this._rows, w, h); + if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); + this.generation++; + } + + setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { + const L = this.L; + const at = this.at; + this.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); + this.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); + this.u8()[at.u8] = blink ? 1 : 0; + this.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); + this.blinkDefault = blink; + if (this.blinkIsDefault) this.blink = blink; + this.syncBlink(); + // The cursor is read from the render state, which only a content change refreshes. + this.generation++; + } + + /** + * Records the view's offset. Ghostty's viewport is not moved and nothing is + * decoded: rows above the active screen come from the page cache, and the active + * rows from the primary copy, so a scroll step costs no export call on a page hit. + */ + setViewportOffset(linesFromBottom: number): void { + this.info(); + const k = this.altScreen + ? 0 + : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, this.sbRows)); + if (k === this.offset) return; + // Scrolling changes no row: the renderer repaints what the move needs (ADR 043). + this.offset = k; + } + + free(): void { + const ex = this.ex; + if (!this.term) return; + this.setSearch(''); + ex.ghostty_terminal_free(this.term); + this.term = 0; + // Slots are released only after nothing can call them (fn-table.ts rule 3). + for (const fn of this.callbacks) fn.release(); + this.callbacks.length = 0; + if (this.mouseEncoder) ex.ghostty_mouse_encoder_free(this.mouseEncoder); + if (this.mouseEvent) ex.ghostty_mouse_event_free(this.mouseEvent); + this.mouseEncoder = 0; + this.mouseEvent = 0; + if (this.pageRs) ex.ghostty_render_state_free(this.pageRs); + ex.ghostty_render_state_row_cells_free(this.rowCells); + ex.ghostty_render_state_row_iterator_free(this.iter); + ex.ghostty_render_state_free(this.rs); + this.pageRs = 0; + this.scratch.free(); + } + + // ========================================================================== + // Render state + // ========================================================================== + + update(): DirtyState { + this.refresh(); + return this.dirtyMax; + } + + getCursor(): RenderStateCursor { + this.refresh(); + return { + x: this.cursorX, + y: this.cursorY, + viewportX: this.cursorX, + viewportY: this.cursorY, + visible: this.cursorVisible, + blinking: this.cursorBlinking, + style: this.cursorStyle, + }; + } + + isRowDirty(y: number): boolean { + this.refresh(); + return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); + } + + markClean(): void { + this.dirtyRows.clear(); + this.dirtyMax = DirtyState.NONE; + } + + getViewport(): GhosttyCell[] { + this.refresh(); + // The active screen, scrolled or not. + return this.viewCells; + } + + getLine(y: number): GhosttyCell[] | null { + if (y < 0 || y >= this._rows) return null; + this.refresh(); + return this.copyRow(this.rowAt(this.sbRows + y)); + } + + needsFullRedraw(): boolean { + return this.update() === DirtyState.FULL; + } + + clearDirty(): void { + this.markClean(); + } + + getDimensions(): { cols: number; rows: number } { + return { cols: this._cols, rows: this._rows }; + } + + // ========================================================================== + // Modes + // ========================================================================== + + isAlternateScreen(): boolean { + this.info(); + return this.altScreen; + } + + hasMouseTracking(): boolean { + const at = this.at; + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MOUSE_TRACKING, at.u8); + return rc === 0 && this.u8()[at.u8] !== 0; + } + + cursorAtPrompt(): boolean { + const at = this.at; + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.CURSOR_AT_PROMPT, at.u8); + return rc === 0 && this.u8()[at.u8] !== 0; + } + + promptJumpRow(top: number, delta: number): number { + const at = this.at; + const S = this.L.scroll; + this.info(); + // Ghostty moves its viewport from its top row, so it is put on webtty's for the move + this.scrollViewport(S.ROW, top); + try { + this.scrollViewport(S.DELTA_PROMPT, delta); + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.SCROLLBAR, at.scrollbar); + if (rc !== 0) return top; + // The offset is a u64; a screen row fits in the low 32 bits + return this.dv().getUint32(at.scrollbar + this.L.scrollbar.offset, true); + } finally { + this.scrollViewport(S.BOTTOM, 0); + } + } + + getMode(mode: number, isAnsi: boolean = false): boolean { + const at = this.at; + const M = this.L.mode; + const dv = this.dv(); + // GhosttyMode (modes.h ghostty_mode_new): number in bits 0-14, bit 15 = ANSI. + dv.setUint16(at.mode + M.mode, (mode & 0x7fff) | (isAnsi ? 0x8000 : 0), true); + dv.setUint8(at.mode + M.value, 0); + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MODE, at.mode); + return rc === 0 && this.u8()[at.mode + M.value] !== 0; + } + + // ========================================================================== + // Scrollback, graphemes, hyperlinks + // ========================================================================== + + getScrollbackLength(): number { + this.info(); + return this.sbRows; + } + + getScrollbackLine(offset: number): GhosttyCell[] | null { + this.refresh(); + if (offset < 0 || offset >= this.sbRows) return null; + // A page's cells are never written after the page is read, so the row's cell + // objects are shared, not copied; only the array is new. + return this.readPage(offset).cells.slice(); + } + + isRowWrapped(row: number): boolean { + if (row < 0 || row >= this._rows) return false; + const at = this.at; + if (!this.gridRef(this.L.point.ACTIVE, 0, row)) return false; + if (this.ex.ghostty_grid_ref_row(at.gridRef, at.row64) !== 0) return false; + const rowValue = this.dv().getBigUint64(at.row64, true); + if (this.ex.ghostty_row_get(rowValue, this.L.rowWrapContinuation, at.u8) !== 0) return false; + return this.u8()[at.u8] !== 0; + } + + getHyperlinkUri(row: number, col: number): string | null { + if (row < 0 || row >= this._rows) return null; + return this.hyperlinkAt(this.L.point.ACTIVE, col, row); + } + + getScrollbackHyperlinkUri(offset: number, col: number): string | null { + return this.hyperlinkAt(this.L.point.HISTORY, col, offset); + } + + getGraphemeString(row: number, col: number): string { + if (row < 0 || row >= this._rows || col < 0 || col >= this._cols) return ' '; + this.refresh(); + return this.graphemeOf(this.rowAt(this.sbRows + row), col); + } + + getScrollbackGraphemeString(offset: number, col: number): string { + this.refresh(); + if (offset < 0 || offset >= this.sbRows || col < 0 || col >= this._cols) return ' '; + return this.graphemeOf(this.rowAt(offset), col); + } + + // ========================================================================== + // Selection (ADR 050) + // ========================================================================== + + selectWord(cell: ScreenCell): ScreenRange | null { + const W = this.L.selection.word; + return this.selectAt(W, cell, (opts, out) => + this.ex.ghostty_terminal_select_word(this.term, opts, out) + ); + } + + selectLine(cell: ScreenCell): ScreenRange | null { + const W = this.L.selection.line; + return this.selectAt(W, cell, (opts, out) => + this.ex.ghostty_terminal_select_line(this.term, opts, out) + ); + } + + selectOutput(cell: ScreenCell): ScreenRange | null { + const at = this.at; + const S = this.L.selection; + this.info(); + if (!this.screenRef(cell, at.gridRef)) return null; + this.clearSelectionStruct(); + if (this.ex.ghostty_terminal_select_output(this.term, at.gridRef, at.sel) !== 0) return null; + const start = this.screenCellOf(at.sel + S.start); + const end = this.screenCellOf(at.sel + S.end); + return start && end ? { start, end } : null; + } + + selectionText(start: ScreenCell, end: ScreenCell): string { + const ex = this.ex; + const at = this.at; + const S = this.L.selection; + const F = S.format; + this.info(); + // Rows that left history, or a selection made before a resize, are clamped. + const last = this.sbRows + this._rows - 1; + const clamp = (c: ScreenCell): ScreenCell => ({ + col: Math.max(0, Math.min(c.col, this._cols - 1)), + row: Math.max(0, Math.min(c.row, last)), + }); + this.clearSelectionStruct(); + if (!this.screenRef(clamp(start), at.sel + S.start)) return ''; + if (!this.screenRef(clamp(end), at.sel + S.end)) return ''; + this.u8().fill(0, at.selOpts, at.selOpts + F.size); + const dv = this.dv(); + dv.setUint32(at.selOpts + F.sizeField, F.size, true); + dv.setInt32(at.selOpts + F.emit, S.PLAIN, true); + // Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed. + dv.setUint8(at.selOpts + F.unwrap, 1); + dv.setUint8(at.selOpts + F.trim, 1); + dv.setUint32(at.selOpts + F.selection, at.sel, true); + dv.setUint32(at.outPtr, 0, true); + dv.setUint32(at.outLen, 0, true); + const rc = ex.ghostty_terminal_selection_format_alloc( + this.term, + 0, + at.selOpts, + at.outPtr, + at.outLen + ); + const ptr = this.dv().getUint32(at.outPtr, true); + const len = this.dv().getUint32(at.outLen, true); + if (rc !== 0 || !ptr) return ''; + const text = new TextDecoder().decode(this.u8().slice(ptr, ptr + len)); + ex.ghostty_free(0, ptr, len); + return text; + } + + // ========================================================================== + // Search (ADR 055) + // ========================================================================== + + setSearch(needle: string): void { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + if (!needle) { + if (this.search) ex.ghostty_search_free(this.search); + this.search = 0; + if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * this.L.selection.size); + this.matchBuf = 0; + this.matchCap = 0; + return; + } + if (!this.search) { + this.search = this.vt.newHandle('search_new', (slot) => + ex.ghostty_search_new(0, slot, this.term) + ); + // The view is webtty's own; Ghostty's viewport stays at the bottom + this.dv().setInt32(at.u32, S.SCROLL_NONE, true); + ex.ghostty_search_set(this.search, S.SELECT_SCROLL, at.u32); + } + const bytes = new TextEncoder().encode(needle); + const buf = ex.ghostty_wasm_alloc(bytes.length); + try { + this.u8().set(bytes, buf); + const dv = this.dv(); + dv.setUint32(at.str + this.L.string.ptr, buf, true); + dv.setUint32(at.str + this.L.string.len, bytes.length, true); + // An unchanged needle keeps its results + ex.ghostty_search_set(this.search, S.NEEDLE, at.str); + } finally { + ex.ghostty_wasm_free(buf, bytes.length); + } + this.searchFedGen = 0; + } + + searchStep(budgetMs: number): boolean { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + if (!this.search) return false; + let worked = false; + // Feeding is the only way the search sees output, resizes and resets + if (this.searchFedGen !== this.generation) { + ex.ghostty_search_feed(this.search); + this.searchFedGen = this.generation; + worked = true; + } + if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) return worked; + let status = this.dv().getInt32(at.u32, true); + const end = performance.now() + budgetMs; + while (status !== S.COMPLETE && performance.now() < end) { + if (status === S.FEED_REQUIRED) ex.ghostty_search_feed(this.search); + else ex.ghostty_search_tick(this.search, 0); + if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) break; + status = this.dv().getInt32(at.u32, true); + worked = true; + } + return worked; + } + + searchSelect(direction: 'next' | 'previous'): boolean { + const S = this.L.search; + if (!this.search) return false; + const option = direction === 'next' ? S.SELECT_NEXT : S.SELECT_PREV; + return this.ex.ghostty_search_set(this.search, option, 0) === 0; + } + + searchState(): SearchState { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + if (!this.search) return { total: 0, selected: null }; + const read = (data: number): number | null => + ex.ghostty_search_get(this.search, data, at.u32) === 0 + ? this.dv().getUint32(at.u32, true) + : null; + return { total: read(S.TOTAL_MATCHES) ?? 0, selected: read(S.SELECTED_INDEX) }; + } + + searchSelectedMatch(): ScreenRange | null { + const at = this.at; + const Sel = this.L.selection; + if (!this.search) return null; + this.clearSelectionStruct(); + if (this.ex.ghostty_search_get(this.search, this.L.search.SELECTED_MATCH, at.sel) !== 0) { + return null; + } + const start = this.screenCellOf(at.sel + Sel.start); + const end = this.screenCellOf(at.sel + Sel.end); + return start && end ? { start, end } : null; + } + + searchMatches(): Int32Array { + const ex = this.ex; + const at = this.at; + const S = this.L.search; + const Sel = this.L.selection; + if (!this.search) return new Int32Array(0); + // Matches are read where they are, before anything changes the terminal; the + // buffer is grown to the count Ghostty reports, then read again. + for (let attempt = 0; attempt < 2; attempt++) { + const dv = this.dv(); + dv.setUint32(at.searchBuf + S.ptr, this.matchBuf, true); + dv.setUint32(at.searchBuf + S.cap, this.matchCap, true); + dv.setUint32(at.searchBuf + S.len, 0, true); + for (let i = 0; i < this.matchCap; i++) { + dv.setUint32(this.matchBuf + i * Sel.size + Sel.sizeField, Sel.size, true); + } + const rc = ex.ghostty_search_get(this.search, S.MATCHES, at.searchBuf); + const len = this.dv().getUint32(at.searchBuf + S.len, true); + if (rc === this.L.result.OUT_OF_SPACE && attempt === 0) { + if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * Sel.size); + // Room for the matches output adds before the next read + this.matchCap = len + (len >> 2) + 16; + this.matchBuf = ex.ghostty_wasm_alloc(this.matchCap * Sel.size); + if (!this.matchBuf) { + this.matchCap = 0; + return new Int32Array(0); + } + continue; + } + if (rc !== 0) return new Int32Array(0); + const out = new Int32Array(len * 4); + for (let i = 0; i < len; i++) { + const sel = this.matchBuf + i * Sel.size; + const start = this.screenCellOf(sel + Sel.start); + const end = this.screenCellOf(sel + Sel.end); + // A match that has no screen point (none expected) is left at row -1 + out[i * 4] = start?.col ?? 0; + out[i * 4 + 1] = start?.row ?? -1; + out[i * 4 + 2] = end?.col ?? 0; + out[i * 4 + 3] = end?.row ?? -1; + } + return out; + } + return new Int32Array(0); + } + + // ========================================================================== + // Mouse reports (ADR 049) + // ========================================================================== + + setMouseGeometry(geometry: MouseGeometry): void { + const g = geometry; + // Setting the size clears the encoder's last cell, so only a change is set. + const key = [ + g.screenWidth, + g.screenHeight, + g.cellWidth, + g.cellHeight, + g.paddingTop, + g.paddingRight, + g.paddingBottom, + g.paddingLeft, + ].join(','); + if (key === this.mouseGeometry) return; + const enc = this.ensureMouseEncoder(); + const at = this.at; + const M = this.L.mouse; + const dv = this.dv(); + const u32 = (n: number) => Math.max(0, Math.round(n)) >>> 0; + dv.setUint32(at.mouseSize + M.sizeField, M.size, true); + dv.setUint32(at.mouseSize + M.screenWidth, u32(g.screenWidth), true); + dv.setUint32(at.mouseSize + M.screenHeight, u32(g.screenHeight), true); + // Ghostty ignores a zero cell size. + dv.setUint32(at.mouseSize + M.cellWidth, Math.max(1, u32(g.cellWidth)), true); + dv.setUint32(at.mouseSize + M.cellHeight, Math.max(1, u32(g.cellHeight)), true); + dv.setUint32(at.mouseSize + M.paddingTop, u32(g.paddingTop), true); + dv.setUint32(at.mouseSize + M.paddingRight, u32(g.paddingRight), true); + dv.setUint32(at.mouseSize + M.paddingBottom, u32(g.paddingBottom), true); + dv.setUint32(at.mouseSize + M.paddingLeft, u32(g.paddingLeft), true); + this.ex.ghostty_mouse_encoder_setopt(enc, M.SIZE, at.mouseSize); + this.mouseGeometry = key; + } + + encodeMouse(input: MouseInput): Uint8Array { + const ex = this.ex; + const at = this.at; + const M = this.L.mouse; + const enc = this.ensureMouseEncoder(); + // The tracking mode and format, from the terminal. That call also clears the + // encoder's last cell, so it is made only when a mouse mode changed: per-cell motion + // deduplication would otherwise reset on every event. + let modes = 0; + MOUSE_MODES.forEach((mode, i) => { + if (this.getMode(mode)) modes |= 1 << i; + }); + if (modes !== this.mouseModes) { + ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.term); + this.mouseModes = modes; + } + this.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; + ex.ghostty_mouse_encoder_setopt(enc, M.ANY_BUTTON_PRESSED, at.u8); + const ev = this.mouseEvent; + ex.ghostty_mouse_event_set_action(ev, M.action[input.action]); + if (input.button) ex.ghostty_mouse_event_set_button(ev, M.button[input.button]); + else ex.ghostty_mouse_event_clear_button(ev); + ex.ghostty_mouse_event_set_mods(ev, input.mods); + const dv = this.dv(); + dv.setFloat32(at.mousePos + M.x, input.x, true); + dv.setFloat32(at.mousePos + M.y, input.y, true); + ex.ghostty_mouse_event_set_position(ev, at.mousePos); + const rc = ex.ghostty_mouse_encoder_encode(enc, ev, at.mouseOut, MOUSE_REPORT_MAX, at.u32); + if (rc !== 0) return new Uint8Array(0); + const n = this.dv().getUint32(at.u32, true); + return this.u8().slice(at.mouseOut, at.mouseOut + n); + } + + /** The mouse encoder, made with per-cell motion deduplication on, and its event. */ + private ensureMouseEncoder(): number { + if (this.mouseEncoder) return this.mouseEncoder; + const ex = this.ex; + const vt = this.vt; + this.mouseEncoder = vt.newHandle('mouse_encoder_new', (slot) => + ex.ghostty_mouse_encoder_new(0, slot) + ); + this.mouseEvent = vt.newHandle('mouse_event_new', (slot) => + ex.ghostty_mouse_event_new(0, slot) + ); + this.u8()[this.at.u8] = 1; + ex.ghostty_mouse_encoder_setopt(this.mouseEncoder, this.L.mouse.TRACK_LAST_CELL, this.at.u8); + return this.mouseEncoder; + } + + // ========================================================================== + // Focus reports (ADR 053) + // ========================================================================== + + encodeFocus(focused: boolean): Uint8Array { + const at = this.at; + const F = this.L.focus; + const event = focused ? F.GAINED : F.LOST; + const rc = this.ex.ghostty_focus_encode(event, at.focusOut, FOCUS_REPORT_MAX, at.u32); + if (rc !== 0) return new Uint8Array(0); + const n = this.dv().getUint32(at.u32, true); + return this.u8().slice(at.focusOut, at.focusOut + n); + } + + // ========================================================================== + // Paste (ADR 049) + // ========================================================================== + + paste(text: string): void { + const ex = this.ex; + const P = this.L.paste; + const S = this.L.string; + const bytes = new TextEncoder().encode(text); + const mime = new TextEncoder().encode('text/plain'); + // One allocation: the paste struct, the one-entry mime array, the mime and the text. + const mimesAt = Math.ceil(P.size / 8) * 8; + const mimeAt = mimesAt + S.size; + const textAt = mimeAt + mime.length; + const len = textAt + bytes.length; + const base = ex.ghostty_wasm_alloc(len); + if (!base) throw new Error('libghostty-vt: out of memory'); + try { + const u8 = this.u8(); + u8.fill(0, base, base + P.size); + u8.set(mime, base + mimeAt); + u8.set(bytes, base + textAt); + const dv = this.dv(); + dv.setUint32(base + mimesAt + S.ptr, base + mimeAt, true); + dv.setUint32(base + mimesAt + S.len, mime.length, true); + dv.setUint32(base + P.sizeField, P.size, true); + dv.setInt32(base + P.location, P.STANDARD, true); + dv.setInt32(base + P.source, P.CLIPBOARD, true); + dv.setUint32(base + P.mimes, base + mimesAt, true); + dv.setUint32(base + P.mimesLen, 1, true); + dv.setUint32(base + P.reader + P.readerRead, this.ensurePasteReader(), true); + // Text that could run commands (a newline without 2004) is sent, never refused: + // ADR 037, and webtty has no confirmation dialog. + dv.setUint8(base + P.allowUnsafe, 1); + this.pasteText = { ptr: base + textAt, len: bytes.length }; + const rc = ex.ghostty_terminal_paste(this.term, base, 0); + if (rc !== 0) throw new Error(`libghostty-vt terminal_paste failed: ${rc}`); + } finally { + this.pasteText = { ptr: 0, len: 0 }; + ex.ghostty_wasm_free(base, len); + } + } + + /** + * The GhosttyMimeReader callback: `(userdata, mime, writer)`, the GhosttyString and + * GhosttyWriter structs passed by pointer on wasm32. It writes the text of the paste in + * progress through the writer, a function in the wasm's table, and returns its result. + */ + private ensurePasteReader(): number { + if (this.pasteReader) return this.pasteReader; + const P = this.L.paste; + const table = this.ex.__indirect_function_table; + const installed = this.vt.fnTable.install( + ((_userdata: number, _mime: number, writer: number) => { + const { ptr, len } = this.pasteText; + if (len === 0) return 1; + const dv = this.dv(); + const write = table.get(dv.getUint32(writer + P.writerWrite, true)) as ( + userdata: number, + data: number, + len: number + ) => number; + return write(dv.getUint32(writer + P.writerUserdata, true), ptr, len) ? 1 : 0; + }) as (...args: number[]) => number, + [I32, I32, I32], + [I32] + ); + this.callbacks.push(installed); + this.pasteReader = installed.index; + return this.pasteReader; + } + + // ========================================================================== + // Replies and effects + // ========================================================================== + + hasResponse(): boolean { + return this.responses.length > 0; + } + + readResponse(): string | null { + if (this.responses.length === 0) return null; + let total = 0; + for (const r of this.responses) total += r.length; + const all = new Uint8Array(total); + let off = 0; + for (const r of this.responses) { + all.set(r, off); + off += r.length; + } + this.responses.length = 0; + return new TextDecoder().decode(all); + } + + readEffects(): TerminalEffects { + const effects = { + bell: this.bell, + title: this.pendingTitle, + clipboard: this.pendingClipboard, + notifications: this.pendingNotifications, + }; + this.bell = false; + this.pendingTitle = null; + this.pendingClipboard = null; + this.pendingNotifications = []; + return effects; + } + + // ========================================================================== + // Private: memory helpers + // ========================================================================== + + /** A fresh byte view; never keep one across an export call. */ + private u8(): Uint8Array { + return new Uint8Array(this.ex.memory.buffer); + } + + /** A fresh DataView; never keep one across an export call. */ + private dv(): DataView { + return new DataView(this.ex.memory.buffer); + } + + private setU32s(ptr: number, values: number[]): void { + const dv = this.dv(); + values.forEach((v, i) => dv.setUint32(ptr + i * 4, v, true)); + } + + private rgbAt(ptr: number): number { + const u8 = this.u8(); + const C = this.L.colors; + return (u8[ptr + C.r] << 16) | (u8[ptr + C.g] << 8) | u8[ptr + C.b]; + } + + private writeRgb(ptr: number, rgb: number): void { + const u8 = this.u8(); + const C = this.L.colors; + u8[ptr + C.r] = (rgb >>> 16) & 0xff; + u8[ptr + C.g] = (rgb >>> 8) & 0xff; + u8[ptr + C.b] = rgb & 0xff; + } + + /** + * Puts Ghostty's mode 12 bit, which its render state reports as the cursor blink, + * back to the blink DECSCUSR set. Ghostty with `cursor-style-blink` set ignores mode + * 12 (`setMode` in its `termio/stream_handler.zig`), and webtty's `cursorStyleBlink` + * is always set, so a program's `CSI ? 12 l` (vim's `t_ve`) no longer stops the + * blink for good. (webtty, ADR 049) + */ + private syncBlink(): void { + if (this.getMode(MODE_CURSOR_BLINK) === this.blink) return; + const at = this.at; + const M = this.L.mode; + const dv = this.dv(); + dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); + dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); + this.setOption(this.L.opt.MODE, at.mode, 'MODE 12'); + } + + private setOption(option: number, value: number, what: string): void { + const rc = this.ex.ghostty_terminal_set(this.term, option, value); + if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); + } + + private scrollViewport(tag: number, value: number): void { + const at = this.at; + const S = this.L.scroll; + const u8 = this.u8(); + u8.fill(0, at.scroll, at.scroll + S.size); + const dv = this.dv(); + dv.setInt32(at.scroll + S.tag, tag, true); + // The ROW arm is a usize and DELTA_PROMPT an isize (32 bits on wasm32); the union is + // zeroed above. + dv.setInt32(at.scroll + S.value, value, true); + this.ex.ghostty_terminal_scroll_viewport(this.term, at.scroll); + } + + // ========================================================================== + // Private: setup + // ========================================================================== + + private installCallbacks(clipboardWrite: boolean, desktopNotifications: boolean): void { + const L = this.L; + const table = this.vt.fnTable; + const install = ( + option: number, + what: string, + fn: (...args: number[]) => number | undefined, + params: number, + returnsBool: boolean + ) => { + const installed = table.install( + fn as (...args: number[]) => number | void, + new Array(params).fill(I32), + returnsBool ? [I32] : [] + ); + this.callbacks.push(installed); + this.setOption(option, installed.index, what); + }; + + // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. + // Each call carries one whole reply. The kitty keyboard query reply is dropped: + // webtty's input handler never encodes keys with the kitty flags, so answering + // `CSI ? u` would tell apps the protocol is on when it is not (ADR 042). + install( + L.opt.WRITE_PTY, + 'WRITE_PTY', + (_t, _u, ptr, len) => { + const reply = this.u8().slice(ptr, ptr + len); + if (!isKittyKeyboardReply(reply)) this.responses.push(reply); + return undefined; + }, + 4, + false + ); + install( + L.opt.BELL, + 'BELL', + () => { + this.bell = true; + return undefined; + }, + 2, + false + ); + // The title is read after vt_write returns (DATA_TITLE), not inside the callback. + install( + L.opt.TITLE_CHANGED, + 'TITLE_CHANGED', + () => { + this.titleChanged = true; + return undefined; + }, + 2, + false + ); + // SIZE(terminal, userdata, out): XTWINOPS 14/16/18/19 t and mode 2048 reports. + install( + L.opt.SIZE, + 'SIZE', + (_t, _u, out) => { + const Z = L.sizeReport; + const dv = this.dv(); + dv.setUint16(out + Z.rows, this._rows, true); + dv.setUint16(out + Z.columns, this._cols, true); + dv.setUint32(out + Z.cellWidth, this.cellWidthPx, true); + dv.setUint32(out + Z.cellHeight, this.cellHeightPx, true); + return 1; + }, + 3, + true + ); + // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, the replies webtty gave on + // the Coder wasm (without it, Ghostty's secondary DA is ESC[>1;0;0c). + install( + L.opt.DEVICE_ATTRIBUTES, + 'DEVICE_ATTRIBUTES', + (_t, _u, out) => { + const D = L.da; + const dv = this.dv(); + const primary = out + D.primary; + const secondary = out + D.secondary; + dv.setUint16(primary + D.conformance, 62, true); + dv.setUint16(primary + D.features, 22, true); + dv.setUint32(primary + D.numFeatures, 1, true); + dv.setUint16(secondary + D.deviceType, 1, true); + dv.setUint16(secondary + D.firmware, 10, true); + dv.setUint16(secondary + D.rom, 0, true); + dv.setUint32(out + D.tertiary + D.unitId, 0, true); + return 1; + }, + 3, + true + ); + // CLIPBOARD_WRITE(terminal, userdata, write): left NULL with clipboardWrite off, so + // Ghostty ignores OSC 52 and OSC 1337 Copy and refuses OSC 5522 writes (ADR 052). + if (clipboardWrite) { + install( + L.opt.CLIPBOARD_WRITE, + 'CLIPBOARD_WRITE', + (_t, _u, write) => { + this.clipboardWrite(write); + return undefined; + }, + 3, + false + ); + } + // DESKTOP_NOTIFICATION(terminal, userdata, notification): OSC 9 (but not ConEmu's + // OSC 9;<n> forms, which Ghostty's parser takes first) and OSC 777;notify. The + // strings are borrowed, so they are copied out here. Left NULL with + // desktopNotifications off, so Ghostty ignores them (webtty, ADR 056). + if (desktopNotifications) { + install( + L.opt.DESKTOP_NOTIFICATION, + 'DESKTOP_NOTIFICATION', + (_t, _u, notification) => { + const N = this.L.notification; + // A sized struct: read only the fields its size covers. + if (this.dv().getUint32(notification + N.size, true) < N.bodyEnd) return undefined; + const text = (ptr: number) => new TextDecoder().decode(this.stringAt(ptr)); + this.pendingNotifications.push({ + title: text(notification + N.title), + body: text(notification + N.body), + }); + return undefined; + }, + 3, + false + ); + } + } + + /** + * Takes one clipboard write (`GhosttyClipboardWrite`, already base64-decoded by + * Ghostty) and answers it before returning, as the API requires. Only the standard + * clipboard is written: the browser has no other, and Ghostty on macOS keeps `s` in + * a private pasteboard and drops `p`. The text is the first plain-text + * representation, decoded as UTF-8 with invalid bytes replaced (U+FFFD); no + * representations is a clear, kept as `''`. The browser write happens later, after + * `readEffects`, so success here means the write was accepted. (webtty, ADR 052) + */ + private clipboardWrite(write: number): void { + const C = this.L.clipboardWrite; + const dv = this.dv(); + let result = C.UNSUPPORTED; + if (dv.getInt32(write + C.location, true) === C.STANDARD) { + const contents = dv.getUint32(write + C.contents, true); + const count = dv.getUint32(write + C.contentsLen, true); + if (count === 0) { + this.pendingClipboard = ''; + result = C.SUCCESS; + } + for (let i = 0; i < count; i++) { + const content = contents + i * C.contentSize; + const mime = this.stringAt(content + C.mime); + if (!isTextMime(new TextDecoder().decode(mime))) continue; + this.pendingClipboard = new TextDecoder().decode(this.stringAt(content + C.data)); + result = C.SUCCESS; + break; + } + } + const reply = this.at.clipReply; + const out = this.dv(); + new Uint8Array(this.ex.memory.buffer, reply, C.replySize).fill(0); + out.setUint32(reply + C.replySizeField, C.replySize, true); + out.setInt32(reply + C.replyResult, result, true); + const fn = this.ex.__indirect_function_table.get(out.getUint32(write + C.reply, true)) as ( + write: number, + reply: number + ) => void; + fn(write, reply); + } + + /** A copy of the bytes a `GhosttyString` at `ptr` points to. */ + private stringAt(ptr: number): Uint8Array { + const S = this.L.string; + const dv = this.dv(); + const data = dv.getUint32(ptr + S.ptr, true); + const len = dv.getUint32(ptr + S.len, true); + return len === 0 ? new Uint8Array(0) : this.u8().slice(data, data + len); + } + + private applyConfig(config: GhosttyTerminalConfig | undefined): void { + const L = this.L; + const at = this.at; + + // Scrollback in lines under a 32 MiB ceiling (ADR 046). Whichever limit is reached + // first applies. 0 bytes would disable scrollback and a NULL byte limit would + // remove the ceiling, so neither is ever passed; NULL lines removes the line limit. + const lines = config?.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES; + this.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); + this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + if (lines > 0) this.dv().setUint32(at.u32, lines, true); + this.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); + + // Grapheme clustering (mode 2027) on by default, so RIS keeps it. + const dv = this.dv(); + dv.setUint16(at.mode + L.mode.mode, 2027, true); + dv.setUint8(at.mode + L.mode.value, 1); + this.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); + + // The configured cursor, which DECSCUSR Ps 0 and RIS restore (ADR 049). + this.setDefaultCursor(config?.cursorStyle ?? 'block', config?.cursorBlink ?? false); + + const setColor = (option: number, value: number, what: string) => { + this.writeRgb(at.u32, value); + this.setOption(option, at.u32, what); + }; + // Foreground and background are always both set: Ghostty's render state copies + // the default colours only when both are set (render.zig `bg_fg`), otherwise it + // keeps white on black and ignores the given colour, OSC 10/11 and DECSCNM. + // An unset one falls back to webtty's default. + setColor( + L.opt.COLOR_FOREGROUND, + config?.fgColor ?? FALLBACK_FOREGROUND, + 'COLOR_FOREGROUND' + ); + setColor( + L.opt.COLOR_BACKGROUND, + config?.bgColor ?? FALLBACK_BACKGROUND, + 'COLOR_BACKGROUND' + ); + if (!config) return; + if (config.cursorColor !== undefined) { + setColor(L.opt.COLOR_CURSOR, config.cursorColor, 'COLOR_CURSOR'); + } + const palette = config.palette ?? []; + const entries = palette + .map((value, i) => ({ value, i })) + .filter((e): e is { value: number; i: number } => e.i < 16 && e.value !== undefined); + if (entries.length > 0) { + // Unset entries and 16-255 keep Ghostty's default palette. + const rc = this.ex.ghostty_terminal_get( + this.term, + L.tdata.COLOR_PALETTE_DEFAULT, + at.palette + ); + if (rc !== 0) throw new Error(`libghostty-vt COLOR_PALETTE_DEFAULT failed: ${rc}`); + for (const { value, i } of entries) this.writeRgb(at.palette + i * L.colors.rgbSize, value); + this.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); + } + } + + private allocView(): void { + const total = this._cols * this._rows; + while (this.viewCells.length < total) this.viewCells.push(blankCell()); + this.viewCells.length = total; + this.viewGraphemes = new Array(this._rows).fill(null); + } + + // ========================================================================== + // Private: refresh + // ========================================================================== + + /** Cursor, scrollback rows and active screen, read once per generation. */ + private info(): void { + if (this.infoGen === this.generation) return; + const at = this.at; + const rc = this.ex.ghostty_terminal_get_multi(this.term, 4, at.tKeys, at.tVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt terminal_get_multi failed: ${rc}`); + const dv = this.dv(); + this.cursorX = dv.getUint16(at.curX, true); + this.cursorY = dv.getUint16(at.curY, true); + this.sbRows = dv.getUint32(at.sbRows, true); + this.altScreen = dv.getInt32(at.screen, true) === this.L.screenAlternate; + this.infoGen = this.generation; + } + + /** + * Brings the active-screen copy up to date with the terminal, once per generation. Each + * refresh decodes only the rows Ghostty reports dirty (every row after a resize or + * colour change), adds them to the dirty set, and ends with `render_state_clean`, + * which clears Ghostty's global and per-row dirty state. The JS dirty state + * accumulates until `markClean()`. + */ + private refresh(): void { + if (this.refreshedGen === this.generation) return; + this.info(); + const ex = this.ex; + const L = this.L; + const at = this.at; + + let rc = ex.ghostty_render_state_update(this.rs, this.term); + if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); + let dv = this.dv(); + dv.setUint32(at.cursor + L.cursor.sizeField, L.cursor.size, true); + dv.setUint32(at.colors + L.colors.sizeField, L.colors.size, true); + rc = ex.ghostty_render_state_get_multi(this.rs, 3, at.rsKeys, at.rsVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt render_state_get_multi failed: ${rc}`); + // Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no + // row dirty when only the defaults change (OSC 10/11/110/111, DECSCNM): its own + // renderer applies them globally. Redecode every row when any colour changed. + const colorsChanged = this.readCursorAndColors(); + dv = this.dv(); + const dirty = dv.getInt32(at.dirty, true); + rc = ex.ghostty_render_state_get(this.rs, L.rs.ROW_ITERATOR, at.iterH); + if (rc !== 0) throw new Error(`libghostty-vt ROW_ITERATOR failed: ${rc}`); + + const all = this.decodeAll || colorsChanged; + const cols = this._cols; + while ( + all + ? ex.ghostty_render_state_row_iterator_next(this.iter) + : ex.ghostty_render_state_row_iterator_next_dirty(this.iter, at.y) + ) { + const len = this.readRowRaw(); + const y = this.dv().getInt32(at.viewY, true); + if (y < 0 || y >= this._rows) continue; + this.viewGraphemes[y] = this.decodeRow(this.viewCells, y * cols, len); + this.dirtyRows.add(y); + } + ex.ghostty_render_state_clean(this.rs); + + const level = all + ? DirtyState.FULL + : dirty === L.dirty.FULL + ? DirtyState.FULL + : dirty === L.dirty.PARTIAL + ? DirtyState.PARTIAL + : DirtyState.NONE; + if (level > this.dirtyMax) this.dirtyMax = level; + this.decodeAll = false; + this.refreshedGen = this.generation; + } + + /** Returns whether the default colours or the palette changed since the last read. */ + private readCursorAndColors(): boolean { + const L = this.L; + const at = this.at; + const u8 = this.u8(); + const dv = this.dv(); + const C = L.cursor; + this.cursorVisible = u8[at.cursor + C.visible] !== 0; + this.cursorBlinking = u8[at.cursor + C.blinking] !== 0; + const style = dv.getInt32(at.cursor + C.visualStyle, true); + this.cursorStyle = + style === L.cursorStyle.BAR ? 'bar' : style === L.cursorStyle.UNDERLINE ? 'underline' : 'block'; + const K = L.colors; + const defBg = this.rgbAt(at.colors + K.background); + const defFg = this.rgbAt(at.colors + K.foreground); + let changed = defBg !== this.defBg || defFg !== this.defFg; + this.defBg = defBg; + this.defFg = defFg; + const pal = at.colors + K.palette; + const out = this.palette; + for (let i = 0; i < 256; i++) { + const p = pal + i * K.rgbSize; + const r = u8[p + K.r]; + const g = u8[p + K.g]; + const b = u8[p + K.b]; + if (out[i * 3] !== r || out[i * 3 + 1] !== g || out[i * 3 + 2] !== b) { + changed = true; + out[i * 3] = r; + out[i * 3 + 1] = g; + out[i * 3 + 2] = b; + } + } + return changed; + } + + /** + * Reads the iterator's current row (CELLS_RAW, VIEWPORT_Y, and binds the row-cells + * handle) and copies the raw cells into the JS-owned `raw` array at once. + */ + private readRowRaw(): number { + const at = this.at; + const L = this.L; + const rc = this.ex.ghostty_render_state_row_get_multi(this.iter, 3, at.rowKeys, at.rowVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt render_state_row_get_multi failed: ${rc}`); + const dv = this.dv(); + const ptr = dv.getUint32(at.view + L.cellsView.ptr, true); + const len = dv.getUint32(at.view + L.cellsView.len, true); + const words = (len * L.cellSize) / 4; + if (this.raw.length < words) this.raw = new Uint32Array(words); + if (ptr && words) this.raw.set(new Uint32Array(this.ex.memory.buffer, ptr, words)); + return len; + } + + /** + * Decodes the copied raw row into `out[base..base+cols)`. Styles come from a + * per-row cache (`row_cells_get` STYLE only for an id not seen in the row), and + * grapheme codepoints are read only for cells tagged as graphemes. + */ + private decodeRow(out: GhosttyCell[], base: number, len: number): Map<number, string> | null { + const L = this.L; + const B = L.cell; + // Hoisted: this loop runs once per cell of every decoded row. + const tagLsb = B.tag.lsb; + const tagMask = B.tag.mask; + const contentLsb = B.content.lsb; + const contentMask = B.content.mask; + const styleLsb = B.style.lsb; + const styleMask = B.style.mask; + const wideLsb = B.wide.lsb; + const wideMask = B.wide.mask; + const linkLsb = B.hyperlink.lsb; + const linkMask = B.hyperlink.mask; + const cpLsb = B.codepoint.lsb; + const cpMask = B.codepoint.mask; + const TAG_CP = L.tag.CODEPOINT; + const TAG_GRAPHEME = L.tag.CODEPOINT_GRAPHEME; + const TAG_BG_PALETTE = L.tag.BG_COLOR_PALETTE; + const TAG_BG_RGB = L.tag.BG_COLOR_RGB; + const WIDE = L.wide.WIDE; + const NARROW = L.wide.NARROW; + const defFg = this.defFg; + const defBg = this.defBg; + const raw = this.raw; + const pal = this.palette; + const words = L.cellSize / 4; + const styles = this.rowStyles; + styles.clear(); + let graphemes: Map<number, string> | null = null; + const n = Math.min(len, this._cols); + for (let x = 0; x < n; x++) { + const lo = raw[x * words]; + const hi = raw[x * words + 1]; + const tag = readBits(lo, hi, tagLsb, tagMask); + const content = readBits(lo, hi, contentLsb, contentMask); + const styleId = readBits(lo, hi, styleLsb, styleMask); + const wide = readBits(lo, hi, wideLsb, wideMask); + const cell = out[base + x]; + let fg = defFg; + let bg = defBg; + let flags = 0; + let codepoint = 0; + let bgFromContent = false; + if (tag === TAG_CP || tag === TAG_GRAPHEME) { + codepoint = (content >>> cpLsb) & cpMask; + } else if (tag === TAG_BG_PALETTE) { + const i = readBits(content, 0, B.paletteIndex.lsb, B.paletteIndex.mask) * 3; + bg = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; + bgFromContent = true; + } else if (tag === TAG_BG_RGB) { + bg = + (readBits(content, 0, B.r.lsb, B.r.mask) << 16) | + (readBits(content, 0, B.g.lsb, B.g.mask) << 8) | + readBits(content, 0, B.b.lsb, B.b.mask); + bgFromContent = true; + } + if (styleId !== 0) { + let style = styles.get(styleId); + if (!style) { + style = this.readStyle(x); + styles.set(styleId, style); + } + if (style.fg >= 0) fg = style.fg; + if (style.bg >= 0 && !bgFromContent) bg = style.bg; + flags = style.flags; + } + cell.codepoint = codepoint; + cell.fg_r = (fg >>> 16) & 0xff; + cell.fg_g = (fg >>> 8) & 0xff; + cell.fg_b = fg & 0xff; + cell.bg_r = (bg >>> 16) & 0xff; + cell.bg_g = (bg >>> 8) & 0xff; + cell.bg_b = bg & 0xff; + cell.flags = flags; + cell.width = wide === WIDE ? 2 : wide === NARROW ? 1 : 0; + cell.hyperlink_id = readBits(lo, hi, linkLsb, linkMask); + cell.grapheme_len = 0; + if (tag === TAG_GRAPHEME) { + const cps = this.readGrapheme(x); + if (cps.length > 1) { + cell.grapheme_len = cps.length - 1; + (graphemes ??= new Map()).set(x, String.fromCodePoint(...cps)); + } + } + } + for (let x = n; x < this._cols; x++) { + const cell = out[base + x]; + cell.codepoint = 0; + setRgb(cell, defFg, defBg); + cell.flags = 0; + cell.width = 1; + cell.hyperlink_id = 0; + cell.grapheme_len = 0; + } + return graphemes; + } + + private readStyle(x: number): DecodedStyle { + const L = this.L; + const S = L.style; + const C = L.styleColor; + const p = this.at.style; + const ex = this.ex; + ex.ghostty_render_state_row_cells_select(this.rowCells, x); + new DataView(ex.memory.buffer).setUint32(p + S.sizeField, S.size, true); + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.STYLE, p) !== 0) { + return { fg: -1, bg: -1, flags: 0 }; + } + const dv = new DataView(ex.memory.buffer); + const pal = this.palette; + let fg = -1; + let bg = -1; + for (let side = 0; side < 2; side++) { + const at = p + (side === 0 ? S.fg : S.bg); + const tag = dv.getInt32(at + C.tag, true); + const v = at + C.value; + let rgb = -1; + if (tag === C.PALETTE) { + const i = dv.getUint8(v + C.palette) * 3; + rgb = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; + } else if (tag === C.RGB) { + const c = v + C.rgb; + const K = L.colors; + rgb = (dv.getUint8(c + K.r) << 16) | (dv.getUint8(c + K.g) << 8) | dv.getUint8(c + K.b); + } + if (side === 0) fg = rgb; + else bg = rgb; + } + const flags = + (dv.getUint8(p + S.bold) ? CellFlags.BOLD : 0) | + (dv.getUint8(p + S.italic) ? CellFlags.ITALIC : 0) | + (dv.getInt32(p + S.underline, true) ? CellFlags.UNDERLINE : 0) | + (dv.getUint8(p + S.strikethrough) ? CellFlags.STRIKETHROUGH : 0) | + (dv.getUint8(p + S.inverse) ? CellFlags.INVERSE : 0) | + (dv.getUint8(p + S.invisible) ? CellFlags.INVISIBLE : 0) | + (dv.getUint8(p + S.blink) ? CellFlags.BLINK : 0) | + (dv.getUint8(p + S.faint) ? CellFlags.FAINT : 0); + return { fg, bg, flags }; + } + + private readGrapheme(x: number): number[] { + const L = this.L; + const at = this.at; + const ex = this.ex; + ex.ghostty_render_state_row_cells_select(this.rowCells, x); + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_LEN, at.u32)) { + return []; + } + const n = this.dv().getUint32(at.u32, true); + if (n === 0) return []; + const big = n > GRAPHEME_SCRATCH; + const buf = big ? ex.ghostty_wasm_alloc(n * 4) : at.gBuf; + try { + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_BUF, buf)) { + return []; + } + const dv = this.dv(); + const cps: number[] = []; + for (let i = 0; i < n; i++) cps.push(dv.getUint32(buf + i * 4, true)); + return cps; + } finally { + if (big) ex.ghostty_wasm_free(buf, n * 4); + } + } + + // ========================================================================== + // Private: rows by absolute index (0 = oldest scrollback row) + // ========================================================================== + + /** A row by absolute index; call `refresh()` first. */ + private rowAt(abs: number): RowRef { + if (abs >= this.sbRows) { + const y = abs - this.sbRows; + return { cells: this.viewCells, base: y * this._cols, graphemes: this.viewGraphemes[y] }; + } + return this.readPage(abs); + } + + /** + * Reads the page of `rows` rows holding scrollback row `abs` through a second + * render state: moves Ghostty's viewport there, reads every row with CELLS_RAW, + * and moves it back to the bottom. The primary state has already been refreshed + * at this generation (callers refresh first), so it has captured every pending + * dirty bit before the second state's update consumes the terminal's dirty state. + * The second state cannot trust dirty bits the primary has cleared, so it is + * recreated whenever the content has changed, and every miss rebuilds every row + * it reads. Scrolling changes no row, so the state and the pages are kept. + */ + private readPage(abs: number): RowRef { + const rows = this._rows; + // abs < S, so the page starts in scrollback: scroll_viewport ROW n snaps to the + // active screen only for n >= S. A page may run on into active rows; rowAt + // serves those from the primary copy. + const start = Math.floor(abs / rows) * rows; + if (this.pageGen !== this.generation) { + if (this.pageRs) this.ex.ghostty_render_state_free(this.pageRs); + this.pageRs = this.vt.newHandle('render_state_new', (slot) => + this.ex.ghostty_render_state_new(0, slot) + ); + this.pages.clear(); + this.pageGen = this.generation; + } + let page = this.pages.get(start); + if (page) { + this.pages.delete(start); + this.pages.set(start, page); + return page[abs - start]; + } + + const ex = this.ex; + const L = this.L; + const at = this.at; + this.scrollViewport(L.scroll.ROW, start); + try { + const rc = ex.ghostty_render_state_update(this.pageRs, this.term); + if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); + if (ex.ghostty_render_state_get(this.pageRs, L.rs.ROW_ITERATOR, at.iterH) !== 0) { + throw new Error('libghostty-vt ROW_ITERATOR failed'); + } + page = []; + while (ex.ghostty_render_state_row_iterator_next(this.iter)) { + const len = this.readRowRaw(); + const y = this.dv().getInt32(at.viewY, true); + if (y < 0 || y >= rows) continue; + const cells: GhosttyCell[] = []; + for (let x = 0; x < this._cols; x++) cells.push(blankCell()); + page[y] = { cells, base: 0, graphemes: this.decodeRow(cells, 0, len) }; + } + } finally { + this.scrollViewport(L.scroll.BOTTOM, 0); + } + for (let y = 0; y < rows; y++) { + if (!page[y]) { + const cells: GhosttyCell[] = []; + for (let x = 0; x < this._cols; x++) cells.push(blankCell()); + page[y] = { cells, base: 0, graphemes: null }; + } + } + this.pages.set(start, page); + if (this.pages.size > PAGE_CACHE_SIZE) { + const oldest = this.pages.keys().next().value; + if (oldest !== undefined) this.pages.delete(oldest); + } + return page[abs - start]; + } + + private copyRow(ref: RowRef): GhosttyCell[] { + const out: GhosttyCell[] = new Array(this._cols); + for (let x = 0; x < this._cols; x++) out[x] = { ...ref.cells[ref.base + x] }; + return out; + } + + private graphemeOf(ref: RowRef, col: number): string { + const g = ref.graphemes?.get(col); + if (g !== undefined) return g; + const cp = ref.cells[ref.base + col].codepoint; + return cp ? String.fromCodePoint(cp) : ' '; + } + + // ========================================================================== + // Private: single-cell lookups (grid_ref) + // ========================================================================== + + /** Resolves a point to a grid reference at `out` (by default `at.gridRef`). */ + private gridRef(tag: number, x: number, y: number, out: number = this.at.gridRef): boolean { + const L = this.L; + const at = this.at; + const P = L.point; + this.u8().fill(0, at.point, at.point + P.size); + const dv = this.dv(); + dv.setInt32(at.point + P.tag, tag, true); + dv.setUint16(at.point + P.value + P.x, x, true); + dv.setUint32(at.point + P.value + P.y, y, true); + this.u8().fill(0, out, out + L.gridRef.size); + dv.setUint32(out + L.gridRef.sizeField, L.gridRef.size, true); + return this.ex.ghostty_terminal_grid_ref(this.term, at.point, out) === 0; + } + + // ========================================================================== + // Private: selection (ADR 050) + // ========================================================================== + + private screenRef(cell: ScreenCell, out: number): boolean { + if (cell.col < 0 || cell.col >= this._cols || cell.row < 0) return false; + return this.gridRef(this.L.point.SCREEN, cell.col, cell.row, out); + } + + /** The screen cell of the grid reference at `ref`, or null if it has none. */ + private screenCellOf(ref: number): ScreenCell | null { + const at = this.at; + const P = this.L.point; + const rc = this.ex.ghostty_terminal_point_from_grid_ref(this.term, ref, P.SCREEN, at.coord); + if (rc !== 0) return null; + const dv = this.dv(); + return { col: dv.getUint16(at.coord + P.x, true), row: dv.getUint32(at.coord + P.y, true) }; + } + + /** Zeroes the GhosttySelection at `at.sel` and sets its size field. */ + private clearSelectionStruct(): void { + const S = this.L.selection; + this.u8().fill(0, this.at.sel, this.at.sel + S.size); + this.dv().setUint32(this.at.sel + S.sizeField, S.size, true); + } + + /** + * Runs a `select_*` call whose options are a sized struct with one grid reference, + * set to `cell`, and returns the selection it derives in screen cells. + */ + private selectAt( + options: { size: number; sizeField: number; ref: number }, + cell: ScreenCell, + call: (options: number, outSelection: number) => number + ): ScreenRange | null { + const at = this.at; + const S = this.L.selection; + this.info(); + this.u8().fill(0, at.selOpts, at.selOpts + options.size); + this.dv().setUint32(at.selOpts + options.sizeField, options.size, true); + if (!this.screenRef(cell, at.selOpts + options.ref)) return null; + this.clearSelectionStruct(); + if (call(at.selOpts, at.sel) !== 0) return null; + const start = this.screenCellOf(at.sel + S.start); + const end = this.screenCellOf(at.sel + S.end); + return start && end ? { start, end } : null; + } + + private hyperlinkAt(tag: number, x: number, y: number): string | null { + if (x < 0 || x >= this._cols || y < 0) return null; + const ex = this.ex; + const at = this.at; + if (!this.gridRef(tag, x, y)) return null; + // Most URIs fit; a longer one (Ghostty keeps OSC 8 URIs up to about 2 KiB) is + // read again with the size Ghostty reports. + let cap = 256; + for (let attempt = 0; attempt < 2; attempt++) { + const size = cap; + const buf = ex.ghostty_wasm_alloc(size); + try { + const rc = ex.ghostty_grid_ref_hyperlink_uri(at.gridRef, buf, size, at.u32); + const n = this.dv().getUint32(at.u32, true); + if (rc === this.L.result.OUT_OF_SPACE) { + cap = n; + continue; + } + if (rc !== 0 || n === 0) return null; + return new TextDecoder().decode(this.u8().slice(buf, buf + n)); + } finally { + ex.ghostty_wasm_free(buf, size); + } + } + return null; + } + + private readTitle(): void { + this.titleChanged = false; + const at = this.at; + const S = this.L.string; + if (this.ex.ghostty_terminal_get(this.term, this.L.tdata.TITLE, at.str) !== 0) return; + const dv = this.dv(); + const ptr = dv.getUint32(at.str + S.ptr, true); + const len = dv.getUint32(at.str + S.len, true); + this.pendingTitle = len ? new TextDecoder().decode(this.u8().slice(ptr, ptr + len)) : ''; + } +} diff --git a/src/client/vendor/ghostty-web/ghostty.ts b/src/client/vendor/ghostty-web/ghostty.ts index a4f84da..eae79e6 100644 --- a/src/client/vendor/ghostty-web/ghostty.ts +++ b/src/client/vendor/ghostty-web/ghostty.ts @@ -1,54 +1,24 @@ -/** - * TypeScript wrapper for libghostty-vt WASM API - * - * High-performance terminal emulation using Ghostty's battle-tested VT100 parser. - * The key optimization is the RenderState API which provides a pre-computed - * snapshot of all render data in a single update call. - * - * webtty (ADR 041, ADR 042, ADR 047): the binding to Ghostty's official, - * unpatched libghostty-vt wasm. `OfficialTerminal` implements the `GhosttyTerminal` - * interface the rest of the terminal layer uses. Every wasm export call and wasm - * memory access is in this file and engine/, which holds the engine's interfaces, the - * export typing, the manifest layout and the pure helpers (ADR 061). - */ - -import { scanCursorBlink } from './engine/cursor-blink'; -import type { - DesktopNotification, - EngineHost, - GhosttyTerminal, - KeyEncoder, - MouseGeometry, - MouseInput, - ScreenCell, - ScreenRange, - SearchState, - TerminalEffects, -} from './engine/engine-types'; +// Derived from coder/ghostty-web lib/ghostty.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The engine's facade (factory): `Ghostty` loads Ghostty's official libghostty-vt wasm and +// creates terminals and key encoders on it, and this file re-exports the engine types the +// rest of the layer imports from here (ADR 041, ADR 042, ADR 047, ADR 061). Every wasm +// call is in engine/. + +import { CellFlags, DirtyState, type GhosttyCell, type RenderStateCursor } from './engine/cell-types'; +import type { EngineHost, GhosttyTerminalConfig, KeyEncoder } from './engine/engine-types'; import { OfficialKeyEncoder } from './engine/key-encoder-binding'; import { OFFICIAL_ENCODER_OPTION_NAMES, OFFICIAL_KEY_NAMES, translateEnum, } from './engine/key-tables'; -import { isKittyKeyboardReply } from './engine/kitty-keyboard-reply'; import { type OfficialLayout, resolveOfficialLayout } from './engine/official-layout'; -import { Scratch } from './engine/scratch'; -import { isTextMime } from './engine/text-mime'; -import { readBits, readVtLayout } from './engine/vt-layout'; +import { OfficialTerminal } from './engine/official-terminal'; +import { readVtLayout } from './engine/vt-layout'; import type { OfficialWasmExports } from './engine/wasm-exports'; import { loadWasmExports } from './engine/wasm-loader'; -import { FnTable, type InstalledFn, WasmValType } from './fn-table'; -import { - CellFlags, - DirtyState, - type GhosttyCell, - type GhosttyTerminalConfig, - Key, - KeyAction, - KeyEncoderOption, - type RenderStateCursor, -} from './types'; +import { FnTable } from './fn-table'; +import { Key, KeyAction, KeyEncoderOption } from './input/key-types'; // Re-export types for convenience export { @@ -57,6 +27,7 @@ export { type GhosttyCell, type GhosttyTerminalConfig, KeyEncoderOption, + OfficialTerminal, type RenderStateCursor, }; // The engine's interfaces and pure helpers live in engine/ (ADR 061); this file keeps @@ -76,25 +47,12 @@ export type { export { scanCursorBlink } from './engine/cursor-blink'; export { isTextMime } from './engine/text-mime'; -// Rules of the official libghostty-vt binding (ADR 041, ADR 042): -// - Struct offsets, enum values and the packed GhosttyCell bit positions come from -// the wasm's own `ghostty_type_json` manifest at load. A missing name throws, so a -// pin bump that renames or moves a field fails loudly instead of misreading memory. -// The one hard-coded encoding is GhosttyMode (`modes.h` `ghostty_mode_new`: the -// mode number in bits 0-14, bit 15 set for ANSI modes), which the manifest only -// records as a u16. -// - No view of wasm memory outlives an export call: memory growth detaches the -// ArrayBuffer. Views are rebuilt after every call (`u8()`, `dv()`), CELLS_RAW rows -// are copied into a JS-owned array before anything else is called, and callbacks -// copy their payload before returning. -// - Callbacks never re-enter `vt_write`. - /** * A loaded libghostty-vt wasm instance (ADR 041, ADR 042): creates terminals and * key encoders. */ export class Ghostty implements EngineHost { - /** @internal The wasm exports; only this file calls them. */ + /** @internal The wasm exports; only engine/ calls them. */ readonly ex: OfficialWasmExports; /** @internal */ readonly L: OfficialLayout; @@ -158,1848 +116,3 @@ export class Ghostty implements EngineHost { } } } - -/** A decoded row: JS-owned cells and the strings of its multi-codepoint graphemes. */ -interface RowRef { - cells: GhosttyCell[]; - /** Index of the row's first cell in `cells`. */ - base: number; - /** Column → grapheme string, for cells holding more than one codepoint. */ - graphemes: Map<number, string> | null; -} - -interface DecodedStyle { - /** 0xRRGGBB, or -1 for the default. */ - fg: number; - bg: number; - flags: number; -} - -const I32 = WasmValType.I32; -/** At most this many pages of scrollback rows stay cached per generation. */ -const PAGE_CACHE_SIZE = 16; -/** The scrollback byte ceiling under the line limit (ADR 046). */ -const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; -/** History in lines when the config gives none (ADR 046, ADR 047). */ -const DEFAULT_SCROLLBACK_LINES = 10000; -/** webtty's default colours, used when the theme leaves one unset. */ -const FALLBACK_FOREGROUND = 0xcccccc; -const FALLBACK_BACKGROUND = 0x000000; -/** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ -const GRAPHEME_SCRATCH = 32; -/** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ -const MOUSE_REPORT_MAX = 64; -/** Room for one focus report (`CSI I` / `CSI O`, 3 bytes). */ -const FOCUS_REPORT_MAX = 8; -/** - * The modes that set the mouse encoder's tracking mode and format: X10, normal, button, - * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. - */ -const MOUSE_MODES = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; -/** DEC private mode 12, AT&T cursor blink (ATT610). */ -const MODE_CURSOR_BLINK = 12; -/** Bytes as one char each, for scanning ASCII sequences in byte output. */ -const LATIN1 = new TextDecoder('latin1'); - -function blankCell(): GhosttyCell { - return { - codepoint: 0, - fg_r: 0, - fg_g: 0, - fg_b: 0, - bg_r: 0, - bg_g: 0, - bg_b: 0, - flags: 0, - width: 1, - hyperlink_id: 0, - grapheme_len: 0, - }; -} - -function setRgb(cell: GhosttyCell, fg: number, bg: number): void { - cell.fg_r = (fg >>> 16) & 0xff; - cell.fg_g = (fg >>> 8) & 0xff; - cell.fg_b = fg & 0xff; - cell.bg_r = (bg >>> 16) & 0xff; - cell.bg_g = (bg >>> 8) & 0xff; - cell.bg_b = bg & 0xff; -} - -/** - * A terminal on the official libghostty-vt wasm (ADR 042). - * - * Rows are read through the render state: a refresh runs `render_state_update`, - * visits the dirty rows with `row_iterator_next_dirty`, and decodes each row's - * `CELLS_RAW` into JS-owned cells indexed by active row. Refreshes happen only - * when the generation (bumped by writes, resizes and pixel-size changes) has - * changed, so the many `update()` calls of one frame decode each row once. - * Ghostty's viewport stays at the bottom, so the primary copy is the active - * screen. Scrollback rows are read a page at a time through a second render - * state and cached until the content changes, so scrolling only records the - * offset and serves rows from the copy and the cache. - */ -export class OfficialTerminal implements GhosttyTerminal { - private readonly vt: Ghostty; - private readonly ex: OfficialWasmExports; - private readonly L: OfficialLayout; - private term = 0; - private rs = 0; - private iter = 0; - private rowCells = 0; - private pageRs = 0; - private readonly scratch: Scratch; - private readonly at: Record< - | 'iterH' - | 'cellsH' - | 'y' - | 'dirty' - | 'viewY' - | 'view' - | 'cursor' - | 'colors' - | 'style' - | 'rsKeys' - | 'rsVals' - | 'rowKeys' - | 'rowVals' - | 'tKeys' - | 'tVals' - | 'curX' - | 'curY' - | 'sbRows' - | 'screen' - | 'scroll' - | 'scrollbar' - | 'point' - | 'gridRef' - | 'sel' - | 'selOpts' - | 'coord' - | 'outPtr' - | 'outLen' - | 'row64' - | 'str' - | 'mode' - | 'u32' - | 'u8' - | 'gBuf' - | 'palette' - | 'mouseSize' - | 'mousePos' - | 'mouseOut' - | 'focusOut' - | 'clipReply' - | 'searchBuf', - number - >; - private readonly callbacks: InstalledFn[] = []; - private _cols: number; - private _rows: number; - private cellWidthPx = 0; - private cellHeightPx = 0; - - // Generation and refresh state. The generation is bumped by every content change - // (scrolling is not one) and also keys the page cache. - private generation = 1; - private refreshedGen = 0; - private infoGen = 0; - private decodeAll = true; - private readonly dirtyRows = new Set<number>(); - private dirtyMax: DirtyState = DirtyState.NONE; - - // The JS copy of the active screen, indexed by active row. - private viewCells: GhosttyCell[] = []; - private viewGraphemes: (Map<number, string> | null)[] = []; - private raw = new Uint32Array(0); - private readonly rowStyles = new Map<number, DecodedStyle>(); - - // Terminal info, read once per generation. - private cursorX = 0; - private cursorY = 0; - private sbRows = 0; - private altScreen = false; - - // Render-state cursor and colours from the last refresh. - private cursorVisible = true; - private cursorBlinking = false; - private cursorStyle: RenderStateCursor['style'] = 'block'; - // The blink DECSCUSR set, or the default while it has not set one; DEC mode 12 is - // ignored, so Ghostty's mode bit is put back to it after every write (ADR 049). - private blinkDefault = false; - private blinkIsDefault = true; - private blink = false; - private blinkCarry = ''; - private defFg = 0xffffff; - private defBg = 0; - private readonly palette = new Uint8Array(256 * 3); - - // The view's offset: lines scrolled up from the bottom. Ghostty's viewport stays at - // the bottom; only page reads move it, and they move it back. - private offset = 0; - - // Scrollback rows, keyed by the absolute row their page starts at. - private readonly pages = new Map<number, RowRef[]>(); - private pageGen = 0; - - // Mouse encoder and event, made on the first report (ADR 049). `mouseModes` is the - // MOUSE_MODES bitmask the encoder was last set from; `mouseGeometry` the last size. - private mouseEncoder = 0; - private mouseEvent = 0; - private mouseModes = -1; - private mouseGeometry = ''; - - // Paste: the mime reader callback, installed on the first paste, and the text it - // writes during a `paste` call (ADR 049). - private pasteReader = 0; - private pasteText = { ptr: 0, len: 0 }; - - // Effects. - private readonly responses: Uint8Array[] = []; - private bell = false; - private titleChanged = false; - private pendingTitle: string | null = null; - private pendingClipboard: string | null = null; - private pendingNotifications: DesktopNotification[] = []; - - // Search (ADR 055): the handle while a needle is set, the generation it last fed, and - // the wasm buffer matches are read into, grown as needed. - private search = 0; - private searchFedGen = 0; - private matchBuf = 0; - private matchCap = 0; - - constructor(vt: Ghostty, cols: number, rows: number, config?: GhosttyTerminalConfig) { - this.vt = vt; - this.ex = vt.ex; - this.L = vt.L; - this._cols = cols; - this._rows = rows; - const ex = this.ex; - const L = this.L; - - this.term = vt.newHandle('terminal_new', (slot) => ex.ghostty_terminal_new(0, slot, cols, rows)); - this.rs = vt.newHandle('render_state_new', (slot) => ex.ghostty_render_state_new(0, slot)); - this.iter = vt.newHandle('row_iterator_new', (slot) => - ex.ghostty_render_state_row_iterator_new(0, slot) - ); - this.rowCells = vt.newHandle('row_cells_new', (slot) => - ex.ghostty_render_state_row_cells_new(0, slot) - ); - - const { scratch, at } = Scratch.create(ex, { - iterH: [4, 4], - cellsH: [4, 4], - y: [4, 4], - dirty: [4, 4], - viewY: [4, 4], - view: [L.cellsView.size, 8], - cursor: [L.cursor.size, 8], - colors: [L.colors.size, 8], - style: [L.style.size, 8], - rsKeys: [12, 4], - rsVals: [12, 4], - rowKeys: [12, 4], - rowVals: [12, 4], - tKeys: [16, 4], - tVals: [16, 4], - curX: [4, 4], - curY: [4, 4], - sbRows: [4, 4], - screen: [4, 4], - scroll: [L.scroll.size, 8], - scrollbar: [L.scrollbar.size, 8], - point: [L.point.size, 8], - gridRef: [L.gridRef.size, 8], - sel: [L.selection.size, 8], - selOpts: [ - Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), - 8, - ], - coord: [8, 8], - outPtr: [4, 4], - outLen: [4, 4], - row64: [8, 8], - str: [L.string.size, 8], - mode: [L.mode.size, 8], - u32: [8, 8], - u8: [8, 8], - gBuf: [GRAPHEME_SCRATCH * 4, 8], - palette: [256 * L.colors.rgbSize, 8], - mouseSize: [L.mouse.size, 8], - mousePos: [L.mouse.positionSize, 8], - mouseOut: [MOUSE_REPORT_MAX, 8], - focusOut: [FOCUS_REPORT_MAX, 8], - clipReply: [L.clipboardWrite.replySize, 8], - searchBuf: [L.search.bufferSize, 8], - } as const); - this.scratch = scratch; - this.at = at; - - // Fixed scratch contents: handle slots and the key/value arrays of the multi-gets. - const dv = this.dv(); - dv.setUint32(at.iterH, this.iter, true); - dv.setUint32(at.cellsH, this.rowCells, true); - this.setU32s(at.rsKeys, [L.rs.DIRTY, L.rs.CURSOR, L.rs.COLORS]); - this.setU32s(at.rsVals, [at.dirty, at.cursor, at.colors]); - this.setU32s(at.rowKeys, [L.row.CELLS_RAW, L.row.VIEWPORT_Y, L.row.CELLS]); - this.setU32s(at.rowVals, [at.view, at.viewY, at.cellsH]); - this.setU32s(at.tKeys, [ - L.tdata.CURSOR_X, - L.tdata.CURSOR_Y, - L.tdata.SCROLLBACK_ROWS, - L.tdata.ACTIVE_SCREEN, - ]); - this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); - - this.installCallbacks( - config?.clipboardWrite ?? true, - config?.desktopNotifications ?? true - ); - this.applyConfig(config); - this.allocView(); - } - - get cols(): number { - return this._cols; - } - - get rows(): number { - return this._rows; - } - - // ========================================================================== - // Lifecycle - // ========================================================================== - - write(data: string | Uint8Array): void { - const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; - this.feed(bytes, typeof data === 'string' ? data : undefined, false); - } - - writeUntilGround(data: Uint8Array): number | null { - return this.feed(data, undefined, true); - } - - /** vt_write, or vt_write_until_ground: the bytes consumed, null if ground was not reached. */ - private feed(bytes: Uint8Array, text: string | undefined, untilGround: boolean): number | null { - // Writes never leave the view scrolled (terminal.ts scrolls to the bottom after - // writing); the first frame at the bottom repaints every row. - if (this.offset !== 0) { - this.offset = 0; - this.dirtyMax = DirtyState.FULL; - } - const ex = this.ex; - let consumed: number | null = bytes.length; - // until_ground takes NULL for no bytes, and then only says whether it is at ground - const ptr = bytes.length > 0 ? ex.ghostty_wasm_alloc(bytes.length) : 0; - if (bytes.length > 0 && !ptr) throw new Error('libghostty-vt: out of memory'); - if (ptr) this.u8().set(bytes, ptr); - if (untilGround) { - const rc = ex.ghostty_terminal_vt_write_until_ground(this.term, ptr, bytes.length, this.at.u32); - consumed = rc === this.L.result.SUCCESS ? this.dv().getUint32(this.at.u32, true) : null; - } else if (ptr) { - ex.ghostty_terminal_vt_write(this.term, ptr, bytes.length); - } - if (ptr) ex.ghostty_wasm_free(ptr, bytes.length); - const fed = consumed ?? bytes.length; - if (fed > 0) { - const scan = scanCursorBlink( - text ?? LATIN1.decode(bytes.subarray(0, fed)), - this.blinkCarry - ); - this.blinkCarry = scan.carry; - if (scan.ps !== undefined) { - this.blinkIsDefault = scan.ps === 0; - this.blink = scan.ps === 0 ? this.blinkDefault : scan.ps % 2 === 1; - } - this.syncBlink(); - } - this.generation++; - if (this.titleChanged) this.readTitle(); - return consumed; - } - - resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { - if (cols === this._cols && rows === this._rows) { - if (cellWidthPx !== undefined && cellHeightPx !== undefined) { - this.setCellPixelSize(cellWidthPx, cellHeightPx); - } - return; - } - this._cols = cols; - this._rows = rows; - if (cellWidthPx !== undefined && cellHeightPx !== undefined) { - this.cellWidthPx = Math.max(0, Math.round(cellWidthPx)); - this.cellHeightPx = Math.max(0, Math.round(cellHeightPx)); - } - // One call per real change: with mode 2048 on, every call sends a size report. - const rc = this.ex.ghostty_terminal_resize( - this.term, - cols, - rows, - this.cellWidthPx, - this.cellHeightPx - ); - if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); - this.generation++; - this.decodeAll = true; - this.allocView(); - // Reflow keeps Ghostty's viewport at the bottom; only the offset is clamped. - if (this.offset > 0) this.setViewportOffset(this.offset); - } - - setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { - const w = Math.max(0, Math.round(cellWidthPx)); - const h = Math.max(0, Math.round(cellHeightPx)); - if (w === this.cellWidthPx && h === this.cellHeightPx) return; - this.cellWidthPx = w; - this.cellHeightPx = h; - const rc = this.ex.ghostty_terminal_resize(this.term, this._cols, this._rows, w, h); - if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); - this.generation++; - } - - setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { - const L = this.L; - const at = this.at; - this.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); - this.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); - this.u8()[at.u8] = blink ? 1 : 0; - this.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); - this.blinkDefault = blink; - if (this.blinkIsDefault) this.blink = blink; - this.syncBlink(); - // The cursor is read from the render state, which only a content change refreshes. - this.generation++; - } - - /** - * Records the view's offset. Ghostty's viewport is not moved and nothing is - * decoded: rows above the active screen come from the page cache, and the active - * rows from the primary copy, so a scroll step costs no export call on a page hit. - */ - setViewportOffset(linesFromBottom: number): void { - this.info(); - const k = this.altScreen - ? 0 - : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, this.sbRows)); - if (k === this.offset) return; - // Scrolling changes no row: the renderer repaints what the move needs (ADR 043). - this.offset = k; - } - - free(): void { - const ex = this.ex; - if (!this.term) return; - this.setSearch(''); - ex.ghostty_terminal_free(this.term); - this.term = 0; - // Slots are released only after nothing can call them (fn-table.ts rule 3). - for (const fn of this.callbacks) fn.release(); - this.callbacks.length = 0; - if (this.mouseEncoder) ex.ghostty_mouse_encoder_free(this.mouseEncoder); - if (this.mouseEvent) ex.ghostty_mouse_event_free(this.mouseEvent); - this.mouseEncoder = 0; - this.mouseEvent = 0; - if (this.pageRs) ex.ghostty_render_state_free(this.pageRs); - ex.ghostty_render_state_row_cells_free(this.rowCells); - ex.ghostty_render_state_row_iterator_free(this.iter); - ex.ghostty_render_state_free(this.rs); - this.pageRs = 0; - this.scratch.free(); - } - - // ========================================================================== - // Render state - // ========================================================================== - - update(): DirtyState { - this.refresh(); - return this.dirtyMax; - } - - getCursor(): RenderStateCursor { - this.refresh(); - return { - x: this.cursorX, - y: this.cursorY, - viewportX: this.cursorX, - viewportY: this.cursorY, - visible: this.cursorVisible, - blinking: this.cursorBlinking, - style: this.cursorStyle, - }; - } - - isRowDirty(y: number): boolean { - this.refresh(); - return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); - } - - markClean(): void { - this.dirtyRows.clear(); - this.dirtyMax = DirtyState.NONE; - } - - getViewport(): GhosttyCell[] { - this.refresh(); - // The active screen, scrolled or not. - return this.viewCells; - } - - getLine(y: number): GhosttyCell[] | null { - if (y < 0 || y >= this._rows) return null; - this.refresh(); - return this.copyRow(this.rowAt(this.sbRows + y)); - } - - needsFullRedraw(): boolean { - return this.update() === DirtyState.FULL; - } - - clearDirty(): void { - this.markClean(); - } - - getDimensions(): { cols: number; rows: number } { - return { cols: this._cols, rows: this._rows }; - } - - // ========================================================================== - // Modes - // ========================================================================== - - isAlternateScreen(): boolean { - this.info(); - return this.altScreen; - } - - hasMouseTracking(): boolean { - const at = this.at; - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MOUSE_TRACKING, at.u8); - return rc === 0 && this.u8()[at.u8] !== 0; - } - - cursorAtPrompt(): boolean { - const at = this.at; - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.CURSOR_AT_PROMPT, at.u8); - return rc === 0 && this.u8()[at.u8] !== 0; - } - - promptJumpRow(top: number, delta: number): number { - const at = this.at; - const S = this.L.scroll; - this.info(); - // Ghostty moves its viewport from its top row, so it is put on webtty's for the move - this.scrollViewport(S.ROW, top); - try { - this.scrollViewport(S.DELTA_PROMPT, delta); - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.SCROLLBAR, at.scrollbar); - if (rc !== 0) return top; - // The offset is a u64; a screen row fits in the low 32 bits - return this.dv().getUint32(at.scrollbar + this.L.scrollbar.offset, true); - } finally { - this.scrollViewport(S.BOTTOM, 0); - } - } - - getMode(mode: number, isAnsi: boolean = false): boolean { - const at = this.at; - const M = this.L.mode; - const dv = this.dv(); - // GhosttyMode (modes.h ghostty_mode_new): number in bits 0-14, bit 15 = ANSI. - dv.setUint16(at.mode + M.mode, (mode & 0x7fff) | (isAnsi ? 0x8000 : 0), true); - dv.setUint8(at.mode + M.value, 0); - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MODE, at.mode); - return rc === 0 && this.u8()[at.mode + M.value] !== 0; - } - - // ========================================================================== - // Scrollback, graphemes, hyperlinks - // ========================================================================== - - getScrollbackLength(): number { - this.info(); - return this.sbRows; - } - - getScrollbackLine(offset: number): GhosttyCell[] | null { - this.refresh(); - if (offset < 0 || offset >= this.sbRows) return null; - // A page's cells are never written after the page is read, so the row's cell - // objects are shared, not copied; only the array is new. - return this.readPage(offset).cells.slice(); - } - - isRowWrapped(row: number): boolean { - if (row < 0 || row >= this._rows) return false; - const at = this.at; - if (!this.gridRef(this.L.point.ACTIVE, 0, row)) return false; - if (this.ex.ghostty_grid_ref_row(at.gridRef, at.row64) !== 0) return false; - const rowValue = this.dv().getBigUint64(at.row64, true); - if (this.ex.ghostty_row_get(rowValue, this.L.rowWrapContinuation, at.u8) !== 0) return false; - return this.u8()[at.u8] !== 0; - } - - getHyperlinkUri(row: number, col: number): string | null { - if (row < 0 || row >= this._rows) return null; - return this.hyperlinkAt(this.L.point.ACTIVE, col, row); - } - - getScrollbackHyperlinkUri(offset: number, col: number): string | null { - return this.hyperlinkAt(this.L.point.HISTORY, col, offset); - } - - getGraphemeString(row: number, col: number): string { - if (row < 0 || row >= this._rows || col < 0 || col >= this._cols) return ' '; - this.refresh(); - return this.graphemeOf(this.rowAt(this.sbRows + row), col); - } - - getScrollbackGraphemeString(offset: number, col: number): string { - this.refresh(); - if (offset < 0 || offset >= this.sbRows || col < 0 || col >= this._cols) return ' '; - return this.graphemeOf(this.rowAt(offset), col); - } - - // ========================================================================== - // Selection (ADR 050) - // ========================================================================== - - selectWord(cell: ScreenCell): ScreenRange | null { - const W = this.L.selection.word; - return this.selectAt(W, cell, (opts, out) => - this.ex.ghostty_terminal_select_word(this.term, opts, out) - ); - } - - selectLine(cell: ScreenCell): ScreenRange | null { - const W = this.L.selection.line; - return this.selectAt(W, cell, (opts, out) => - this.ex.ghostty_terminal_select_line(this.term, opts, out) - ); - } - - selectOutput(cell: ScreenCell): ScreenRange | null { - const at = this.at; - const S = this.L.selection; - this.info(); - if (!this.screenRef(cell, at.gridRef)) return null; - this.clearSelectionStruct(); - if (this.ex.ghostty_terminal_select_output(this.term, at.gridRef, at.sel) !== 0) return null; - const start = this.screenCellOf(at.sel + S.start); - const end = this.screenCellOf(at.sel + S.end); - return start && end ? { start, end } : null; - } - - selectionText(start: ScreenCell, end: ScreenCell): string { - const ex = this.ex; - const at = this.at; - const S = this.L.selection; - const F = S.format; - this.info(); - // Rows that left history, or a selection made before a resize, are clamped. - const last = this.sbRows + this._rows - 1; - const clamp = (c: ScreenCell): ScreenCell => ({ - col: Math.max(0, Math.min(c.col, this._cols - 1)), - row: Math.max(0, Math.min(c.row, last)), - }); - this.clearSelectionStruct(); - if (!this.screenRef(clamp(start), at.sel + S.start)) return ''; - if (!this.screenRef(clamp(end), at.sel + S.end)) return ''; - this.u8().fill(0, at.selOpts, at.selOpts + F.size); - const dv = this.dv(); - dv.setUint32(at.selOpts + F.sizeField, F.size, true); - dv.setInt32(at.selOpts + F.emit, S.PLAIN, true); - // Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed. - dv.setUint8(at.selOpts + F.unwrap, 1); - dv.setUint8(at.selOpts + F.trim, 1); - dv.setUint32(at.selOpts + F.selection, at.sel, true); - dv.setUint32(at.outPtr, 0, true); - dv.setUint32(at.outLen, 0, true); - const rc = ex.ghostty_terminal_selection_format_alloc( - this.term, - 0, - at.selOpts, - at.outPtr, - at.outLen - ); - const ptr = this.dv().getUint32(at.outPtr, true); - const len = this.dv().getUint32(at.outLen, true); - if (rc !== 0 || !ptr) return ''; - const text = new TextDecoder().decode(this.u8().slice(ptr, ptr + len)); - ex.ghostty_free(0, ptr, len); - return text; - } - - // ========================================================================== - // Search (ADR 055) - // ========================================================================== - - setSearch(needle: string): void { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - if (!needle) { - if (this.search) ex.ghostty_search_free(this.search); - this.search = 0; - if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * this.L.selection.size); - this.matchBuf = 0; - this.matchCap = 0; - return; - } - if (!this.search) { - this.search = this.vt.newHandle('search_new', (slot) => - ex.ghostty_search_new(0, slot, this.term) - ); - // The view is webtty's own; Ghostty's viewport stays at the bottom - this.dv().setInt32(at.u32, S.SCROLL_NONE, true); - ex.ghostty_search_set(this.search, S.SELECT_SCROLL, at.u32); - } - const bytes = new TextEncoder().encode(needle); - const buf = ex.ghostty_wasm_alloc(bytes.length); - try { - this.u8().set(bytes, buf); - const dv = this.dv(); - dv.setUint32(at.str + this.L.string.ptr, buf, true); - dv.setUint32(at.str + this.L.string.len, bytes.length, true); - // An unchanged needle keeps its results - ex.ghostty_search_set(this.search, S.NEEDLE, at.str); - } finally { - ex.ghostty_wasm_free(buf, bytes.length); - } - this.searchFedGen = 0; - } - - searchStep(budgetMs: number): boolean { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - if (!this.search) return false; - let worked = false; - // Feeding is the only way the search sees output, resizes and resets - if (this.searchFedGen !== this.generation) { - ex.ghostty_search_feed(this.search); - this.searchFedGen = this.generation; - worked = true; - } - if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) return worked; - let status = this.dv().getInt32(at.u32, true); - const end = performance.now() + budgetMs; - while (status !== S.COMPLETE && performance.now() < end) { - if (status === S.FEED_REQUIRED) ex.ghostty_search_feed(this.search); - else ex.ghostty_search_tick(this.search, 0); - if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) break; - status = this.dv().getInt32(at.u32, true); - worked = true; - } - return worked; - } - - searchSelect(direction: 'next' | 'previous'): boolean { - const S = this.L.search; - if (!this.search) return false; - const option = direction === 'next' ? S.SELECT_NEXT : S.SELECT_PREV; - return this.ex.ghostty_search_set(this.search, option, 0) === 0; - } - - searchState(): SearchState { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - if (!this.search) return { total: 0, selected: null }; - const read = (data: number): number | null => - ex.ghostty_search_get(this.search, data, at.u32) === 0 - ? this.dv().getUint32(at.u32, true) - : null; - return { total: read(S.TOTAL_MATCHES) ?? 0, selected: read(S.SELECTED_INDEX) }; - } - - searchSelectedMatch(): ScreenRange | null { - const at = this.at; - const Sel = this.L.selection; - if (!this.search) return null; - this.clearSelectionStruct(); - if (this.ex.ghostty_search_get(this.search, this.L.search.SELECTED_MATCH, at.sel) !== 0) { - return null; - } - const start = this.screenCellOf(at.sel + Sel.start); - const end = this.screenCellOf(at.sel + Sel.end); - return start && end ? { start, end } : null; - } - - searchMatches(): Int32Array { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - const Sel = this.L.selection; - if (!this.search) return new Int32Array(0); - // Matches are read where they are, before anything changes the terminal; the - // buffer is grown to the count Ghostty reports, then read again. - for (let attempt = 0; attempt < 2; attempt++) { - const dv = this.dv(); - dv.setUint32(at.searchBuf + S.ptr, this.matchBuf, true); - dv.setUint32(at.searchBuf + S.cap, this.matchCap, true); - dv.setUint32(at.searchBuf + S.len, 0, true); - for (let i = 0; i < this.matchCap; i++) { - dv.setUint32(this.matchBuf + i * Sel.size + Sel.sizeField, Sel.size, true); - } - const rc = ex.ghostty_search_get(this.search, S.MATCHES, at.searchBuf); - const len = this.dv().getUint32(at.searchBuf + S.len, true); - if (rc === this.L.result.OUT_OF_SPACE && attempt === 0) { - if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * Sel.size); - // Room for the matches output adds before the next read - this.matchCap = len + (len >> 2) + 16; - this.matchBuf = ex.ghostty_wasm_alloc(this.matchCap * Sel.size); - if (!this.matchBuf) { - this.matchCap = 0; - return new Int32Array(0); - } - continue; - } - if (rc !== 0) return new Int32Array(0); - const out = new Int32Array(len * 4); - for (let i = 0; i < len; i++) { - const sel = this.matchBuf + i * Sel.size; - const start = this.screenCellOf(sel + Sel.start); - const end = this.screenCellOf(sel + Sel.end); - // A match that has no screen point (none expected) is left at row -1 - out[i * 4] = start?.col ?? 0; - out[i * 4 + 1] = start?.row ?? -1; - out[i * 4 + 2] = end?.col ?? 0; - out[i * 4 + 3] = end?.row ?? -1; - } - return out; - } - return new Int32Array(0); - } - - // ========================================================================== - // Mouse reports (ADR 049) - // ========================================================================== - - setMouseGeometry(geometry: MouseGeometry): void { - const g = geometry; - // Setting the size clears the encoder's last cell, so only a change is set. - const key = [ - g.screenWidth, - g.screenHeight, - g.cellWidth, - g.cellHeight, - g.paddingTop, - g.paddingRight, - g.paddingBottom, - g.paddingLeft, - ].join(','); - if (key === this.mouseGeometry) return; - const enc = this.ensureMouseEncoder(); - const at = this.at; - const M = this.L.mouse; - const dv = this.dv(); - const u32 = (n: number) => Math.max(0, Math.round(n)) >>> 0; - dv.setUint32(at.mouseSize + M.sizeField, M.size, true); - dv.setUint32(at.mouseSize + M.screenWidth, u32(g.screenWidth), true); - dv.setUint32(at.mouseSize + M.screenHeight, u32(g.screenHeight), true); - // Ghostty ignores a zero cell size. - dv.setUint32(at.mouseSize + M.cellWidth, Math.max(1, u32(g.cellWidth)), true); - dv.setUint32(at.mouseSize + M.cellHeight, Math.max(1, u32(g.cellHeight)), true); - dv.setUint32(at.mouseSize + M.paddingTop, u32(g.paddingTop), true); - dv.setUint32(at.mouseSize + M.paddingRight, u32(g.paddingRight), true); - dv.setUint32(at.mouseSize + M.paddingBottom, u32(g.paddingBottom), true); - dv.setUint32(at.mouseSize + M.paddingLeft, u32(g.paddingLeft), true); - this.ex.ghostty_mouse_encoder_setopt(enc, M.SIZE, at.mouseSize); - this.mouseGeometry = key; - } - - encodeMouse(input: MouseInput): Uint8Array { - const ex = this.ex; - const at = this.at; - const M = this.L.mouse; - const enc = this.ensureMouseEncoder(); - // The tracking mode and format, from the terminal. That call also clears the - // encoder's last cell, so it is made only when a mouse mode changed: per-cell motion - // deduplication would otherwise reset on every event. - let modes = 0; - MOUSE_MODES.forEach((mode, i) => { - if (this.getMode(mode)) modes |= 1 << i; - }); - if (modes !== this.mouseModes) { - ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.term); - this.mouseModes = modes; - } - this.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; - ex.ghostty_mouse_encoder_setopt(enc, M.ANY_BUTTON_PRESSED, at.u8); - const ev = this.mouseEvent; - ex.ghostty_mouse_event_set_action(ev, M.action[input.action]); - if (input.button) ex.ghostty_mouse_event_set_button(ev, M.button[input.button]); - else ex.ghostty_mouse_event_clear_button(ev); - ex.ghostty_mouse_event_set_mods(ev, input.mods); - const dv = this.dv(); - dv.setFloat32(at.mousePos + M.x, input.x, true); - dv.setFloat32(at.mousePos + M.y, input.y, true); - ex.ghostty_mouse_event_set_position(ev, at.mousePos); - const rc = ex.ghostty_mouse_encoder_encode(enc, ev, at.mouseOut, MOUSE_REPORT_MAX, at.u32); - if (rc !== 0) return new Uint8Array(0); - const n = this.dv().getUint32(at.u32, true); - return this.u8().slice(at.mouseOut, at.mouseOut + n); - } - - /** The mouse encoder, made with per-cell motion deduplication on, and its event. */ - private ensureMouseEncoder(): number { - if (this.mouseEncoder) return this.mouseEncoder; - const ex = this.ex; - const vt = this.vt; - this.mouseEncoder = vt.newHandle('mouse_encoder_new', (slot) => - ex.ghostty_mouse_encoder_new(0, slot) - ); - this.mouseEvent = vt.newHandle('mouse_event_new', (slot) => - ex.ghostty_mouse_event_new(0, slot) - ); - this.u8()[this.at.u8] = 1; - ex.ghostty_mouse_encoder_setopt(this.mouseEncoder, this.L.mouse.TRACK_LAST_CELL, this.at.u8); - return this.mouseEncoder; - } - - // ========================================================================== - // Focus reports (ADR 053) - // ========================================================================== - - encodeFocus(focused: boolean): Uint8Array { - const at = this.at; - const F = this.L.focus; - const event = focused ? F.GAINED : F.LOST; - const rc = this.ex.ghostty_focus_encode(event, at.focusOut, FOCUS_REPORT_MAX, at.u32); - if (rc !== 0) return new Uint8Array(0); - const n = this.dv().getUint32(at.u32, true); - return this.u8().slice(at.focusOut, at.focusOut + n); - } - - // ========================================================================== - // Paste (ADR 049) - // ========================================================================== - - paste(text: string): void { - const ex = this.ex; - const P = this.L.paste; - const S = this.L.string; - const bytes = new TextEncoder().encode(text); - const mime = new TextEncoder().encode('text/plain'); - // One allocation: the paste struct, the one-entry mime array, the mime and the text. - const mimesAt = Math.ceil(P.size / 8) * 8; - const mimeAt = mimesAt + S.size; - const textAt = mimeAt + mime.length; - const len = textAt + bytes.length; - const base = ex.ghostty_wasm_alloc(len); - if (!base) throw new Error('libghostty-vt: out of memory'); - try { - const u8 = this.u8(); - u8.fill(0, base, base + P.size); - u8.set(mime, base + mimeAt); - u8.set(bytes, base + textAt); - const dv = this.dv(); - dv.setUint32(base + mimesAt + S.ptr, base + mimeAt, true); - dv.setUint32(base + mimesAt + S.len, mime.length, true); - dv.setUint32(base + P.sizeField, P.size, true); - dv.setInt32(base + P.location, P.STANDARD, true); - dv.setInt32(base + P.source, P.CLIPBOARD, true); - dv.setUint32(base + P.mimes, base + mimesAt, true); - dv.setUint32(base + P.mimesLen, 1, true); - dv.setUint32(base + P.reader + P.readerRead, this.ensurePasteReader(), true); - // Text that could run commands (a newline without 2004) is sent, never refused: - // ADR 037, and webtty has no confirmation dialog. - dv.setUint8(base + P.allowUnsafe, 1); - this.pasteText = { ptr: base + textAt, len: bytes.length }; - const rc = ex.ghostty_terminal_paste(this.term, base, 0); - if (rc !== 0) throw new Error(`libghostty-vt terminal_paste failed: ${rc}`); - } finally { - this.pasteText = { ptr: 0, len: 0 }; - ex.ghostty_wasm_free(base, len); - } - } - - /** - * The GhosttyMimeReader callback: `(userdata, mime, writer)`, the GhosttyString and - * GhosttyWriter structs passed by pointer on wasm32. It writes the text of the paste in - * progress through the writer, a function in the wasm's table, and returns its result. - */ - private ensurePasteReader(): number { - if (this.pasteReader) return this.pasteReader; - const P = this.L.paste; - const table = this.ex.__indirect_function_table; - const installed = this.vt.fnTable.install( - ((_userdata: number, _mime: number, writer: number) => { - const { ptr, len } = this.pasteText; - if (len === 0) return 1; - const dv = this.dv(); - const write = table.get(dv.getUint32(writer + P.writerWrite, true)) as ( - userdata: number, - data: number, - len: number - ) => number; - return write(dv.getUint32(writer + P.writerUserdata, true), ptr, len) ? 1 : 0; - }) as (...args: number[]) => number, - [I32, I32, I32], - [I32] - ); - this.callbacks.push(installed); - this.pasteReader = installed.index; - return this.pasteReader; - } - - // ========================================================================== - // Replies and effects - // ========================================================================== - - hasResponse(): boolean { - return this.responses.length > 0; - } - - readResponse(): string | null { - if (this.responses.length === 0) return null; - let total = 0; - for (const r of this.responses) total += r.length; - const all = new Uint8Array(total); - let off = 0; - for (const r of this.responses) { - all.set(r, off); - off += r.length; - } - this.responses.length = 0; - return new TextDecoder().decode(all); - } - - readEffects(): TerminalEffects { - const effects = { - bell: this.bell, - title: this.pendingTitle, - clipboard: this.pendingClipboard, - notifications: this.pendingNotifications, - }; - this.bell = false; - this.pendingTitle = null; - this.pendingClipboard = null; - this.pendingNotifications = []; - return effects; - } - - // ========================================================================== - // Private: memory helpers - // ========================================================================== - - /** A fresh byte view; never keep one across an export call. */ - private u8(): Uint8Array { - return new Uint8Array(this.ex.memory.buffer); - } - - /** A fresh DataView; never keep one across an export call. */ - private dv(): DataView { - return new DataView(this.ex.memory.buffer); - } - - private setU32s(ptr: number, values: number[]): void { - const dv = this.dv(); - values.forEach((v, i) => dv.setUint32(ptr + i * 4, v, true)); - } - - private rgbAt(ptr: number): number { - const u8 = this.u8(); - const C = this.L.colors; - return (u8[ptr + C.r] << 16) | (u8[ptr + C.g] << 8) | u8[ptr + C.b]; - } - - private writeRgb(ptr: number, rgb: number): void { - const u8 = this.u8(); - const C = this.L.colors; - u8[ptr + C.r] = (rgb >>> 16) & 0xff; - u8[ptr + C.g] = (rgb >>> 8) & 0xff; - u8[ptr + C.b] = rgb & 0xff; - } - - /** - * Puts Ghostty's mode 12 bit, which its render state reports as the cursor blink, - * back to the blink DECSCUSR set. Ghostty with `cursor-style-blink` set ignores mode - * 12 (`setMode` in its `termio/stream_handler.zig`), and webtty's `cursorStyleBlink` - * is always set, so a program's `CSI ? 12 l` (vim's `t_ve`) no longer stops the - * blink for good. (webtty, ADR 049) - */ - private syncBlink(): void { - if (this.getMode(MODE_CURSOR_BLINK) === this.blink) return; - const at = this.at; - const M = this.L.mode; - const dv = this.dv(); - dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); - dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); - this.setOption(this.L.opt.MODE, at.mode, 'MODE 12'); - } - - private setOption(option: number, value: number, what: string): void { - const rc = this.ex.ghostty_terminal_set(this.term, option, value); - if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); - } - - private scrollViewport(tag: number, value: number): void { - const at = this.at; - const S = this.L.scroll; - const u8 = this.u8(); - u8.fill(0, at.scroll, at.scroll + S.size); - const dv = this.dv(); - dv.setInt32(at.scroll + S.tag, tag, true); - // The ROW arm is a usize and DELTA_PROMPT an isize (32 bits on wasm32); the union is - // zeroed above. - dv.setInt32(at.scroll + S.value, value, true); - this.ex.ghostty_terminal_scroll_viewport(this.term, at.scroll); - } - - // ========================================================================== - // Private: setup - // ========================================================================== - - private installCallbacks(clipboardWrite: boolean, desktopNotifications: boolean): void { - const L = this.L; - const table = this.vt.fnTable; - const install = ( - option: number, - what: string, - fn: (...args: number[]) => number | undefined, - params: number, - returnsBool: boolean - ) => { - const installed = table.install( - fn as (...args: number[]) => number | void, - new Array(params).fill(I32), - returnsBool ? [I32] : [] - ); - this.callbacks.push(installed); - this.setOption(option, installed.index, what); - }; - - // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. - // Each call carries one whole reply. The kitty keyboard query reply is dropped: - // webtty's input handler never encodes keys with the kitty flags, so answering - // `CSI ? u` would tell apps the protocol is on when it is not (ADR 042). - install( - L.opt.WRITE_PTY, - 'WRITE_PTY', - (_t, _u, ptr, len) => { - const reply = this.u8().slice(ptr, ptr + len); - if (!isKittyKeyboardReply(reply)) this.responses.push(reply); - return undefined; - }, - 4, - false - ); - install( - L.opt.BELL, - 'BELL', - () => { - this.bell = true; - return undefined; - }, - 2, - false - ); - // The title is read after vt_write returns (DATA_TITLE), not inside the callback. - install( - L.opt.TITLE_CHANGED, - 'TITLE_CHANGED', - () => { - this.titleChanged = true; - return undefined; - }, - 2, - false - ); - // SIZE(terminal, userdata, out): XTWINOPS 14/16/18/19 t and mode 2048 reports. - install( - L.opt.SIZE, - 'SIZE', - (_t, _u, out) => { - const Z = L.sizeReport; - const dv = this.dv(); - dv.setUint16(out + Z.rows, this._rows, true); - dv.setUint16(out + Z.columns, this._cols, true); - dv.setUint32(out + Z.cellWidth, this.cellWidthPx, true); - dv.setUint32(out + Z.cellHeight, this.cellHeightPx, true); - return 1; - }, - 3, - true - ); - // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, the replies webtty gave on - // the Coder wasm (without it, Ghostty's secondary DA is ESC[>1;0;0c). - install( - L.opt.DEVICE_ATTRIBUTES, - 'DEVICE_ATTRIBUTES', - (_t, _u, out) => { - const D = L.da; - const dv = this.dv(); - const primary = out + D.primary; - const secondary = out + D.secondary; - dv.setUint16(primary + D.conformance, 62, true); - dv.setUint16(primary + D.features, 22, true); - dv.setUint32(primary + D.numFeatures, 1, true); - dv.setUint16(secondary + D.deviceType, 1, true); - dv.setUint16(secondary + D.firmware, 10, true); - dv.setUint16(secondary + D.rom, 0, true); - dv.setUint32(out + D.tertiary + D.unitId, 0, true); - return 1; - }, - 3, - true - ); - // CLIPBOARD_WRITE(terminal, userdata, write): left NULL with clipboardWrite off, so - // Ghostty ignores OSC 52 and OSC 1337 Copy and refuses OSC 5522 writes (ADR 052). - if (clipboardWrite) { - install( - L.opt.CLIPBOARD_WRITE, - 'CLIPBOARD_WRITE', - (_t, _u, write) => { - this.clipboardWrite(write); - return undefined; - }, - 3, - false - ); - } - // DESKTOP_NOTIFICATION(terminal, userdata, notification): OSC 9 (but not ConEmu's - // OSC 9;<n> forms, which Ghostty's parser takes first) and OSC 777;notify. The - // strings are borrowed, so they are copied out here. Left NULL with - // desktopNotifications off, so Ghostty ignores them (webtty, ADR 056). - if (desktopNotifications) { - install( - L.opt.DESKTOP_NOTIFICATION, - 'DESKTOP_NOTIFICATION', - (_t, _u, notification) => { - const N = this.L.notification; - // A sized struct: read only the fields its size covers. - if (this.dv().getUint32(notification + N.size, true) < N.bodyEnd) return undefined; - const text = (ptr: number) => new TextDecoder().decode(this.stringAt(ptr)); - this.pendingNotifications.push({ - title: text(notification + N.title), - body: text(notification + N.body), - }); - return undefined; - }, - 3, - false - ); - } - } - - /** - * Takes one clipboard write (`GhosttyClipboardWrite`, already base64-decoded by - * Ghostty) and answers it before returning, as the API requires. Only the standard - * clipboard is written: the browser has no other, and Ghostty on macOS keeps `s` in - * a private pasteboard and drops `p`. The text is the first plain-text - * representation, decoded as UTF-8 with invalid bytes replaced (U+FFFD); no - * representations is a clear, kept as `''`. The browser write happens later, after - * `readEffects`, so success here means the write was accepted. (webtty, ADR 052) - */ - private clipboardWrite(write: number): void { - const C = this.L.clipboardWrite; - const dv = this.dv(); - let result = C.UNSUPPORTED; - if (dv.getInt32(write + C.location, true) === C.STANDARD) { - const contents = dv.getUint32(write + C.contents, true); - const count = dv.getUint32(write + C.contentsLen, true); - if (count === 0) { - this.pendingClipboard = ''; - result = C.SUCCESS; - } - for (let i = 0; i < count; i++) { - const content = contents + i * C.contentSize; - const mime = this.stringAt(content + C.mime); - if (!isTextMime(new TextDecoder().decode(mime))) continue; - this.pendingClipboard = new TextDecoder().decode(this.stringAt(content + C.data)); - result = C.SUCCESS; - break; - } - } - const reply = this.at.clipReply; - const out = this.dv(); - new Uint8Array(this.ex.memory.buffer, reply, C.replySize).fill(0); - out.setUint32(reply + C.replySizeField, C.replySize, true); - out.setInt32(reply + C.replyResult, result, true); - const fn = this.ex.__indirect_function_table.get(out.getUint32(write + C.reply, true)) as ( - write: number, - reply: number - ) => void; - fn(write, reply); - } - - /** A copy of the bytes a `GhosttyString` at `ptr` points to. */ - private stringAt(ptr: number): Uint8Array { - const S = this.L.string; - const dv = this.dv(); - const data = dv.getUint32(ptr + S.ptr, true); - const len = dv.getUint32(ptr + S.len, true); - return len === 0 ? new Uint8Array(0) : this.u8().slice(data, data + len); - } - - private applyConfig(config: GhosttyTerminalConfig | undefined): void { - const L = this.L; - const at = this.at; - - // Scrollback in lines under a 32 MiB ceiling (ADR 046). Whichever limit is reached - // first applies. 0 bytes would disable scrollback and a NULL byte limit would - // remove the ceiling, so neither is ever passed; NULL lines removes the line limit. - const lines = config?.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES; - this.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); - this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); - if (lines > 0) this.dv().setUint32(at.u32, lines, true); - this.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); - - // Grapheme clustering (mode 2027) on by default, so RIS keeps it. - const dv = this.dv(); - dv.setUint16(at.mode + L.mode.mode, 2027, true); - dv.setUint8(at.mode + L.mode.value, 1); - this.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); - - // The configured cursor, which DECSCUSR Ps 0 and RIS restore (ADR 049). - this.setDefaultCursor(config?.cursorStyle ?? 'block', config?.cursorBlink ?? false); - - const setColor = (option: number, value: number, what: string) => { - this.writeRgb(at.u32, value); - this.setOption(option, at.u32, what); - }; - // Foreground and background are always both set: Ghostty's render state copies - // the default colours only when both are set (render.zig `bg_fg`), otherwise it - // keeps white on black and ignores the given colour, OSC 10/11 and DECSCNM. - // An unset one falls back to webtty's default. - setColor( - L.opt.COLOR_FOREGROUND, - config?.fgColor ?? FALLBACK_FOREGROUND, - 'COLOR_FOREGROUND' - ); - setColor( - L.opt.COLOR_BACKGROUND, - config?.bgColor ?? FALLBACK_BACKGROUND, - 'COLOR_BACKGROUND' - ); - if (!config) return; - if (config.cursorColor !== undefined) { - setColor(L.opt.COLOR_CURSOR, config.cursorColor, 'COLOR_CURSOR'); - } - const palette = config.palette ?? []; - const entries = palette - .map((value, i) => ({ value, i })) - .filter((e): e is { value: number; i: number } => e.i < 16 && e.value !== undefined); - if (entries.length > 0) { - // Unset entries and 16-255 keep Ghostty's default palette. - const rc = this.ex.ghostty_terminal_get( - this.term, - L.tdata.COLOR_PALETTE_DEFAULT, - at.palette - ); - if (rc !== 0) throw new Error(`libghostty-vt COLOR_PALETTE_DEFAULT failed: ${rc}`); - for (const { value, i } of entries) this.writeRgb(at.palette + i * L.colors.rgbSize, value); - this.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); - } - } - - private allocView(): void { - const total = this._cols * this._rows; - while (this.viewCells.length < total) this.viewCells.push(blankCell()); - this.viewCells.length = total; - this.viewGraphemes = new Array(this._rows).fill(null); - } - - // ========================================================================== - // Private: refresh - // ========================================================================== - - /** Cursor, scrollback rows and active screen, read once per generation. */ - private info(): void { - if (this.infoGen === this.generation) return; - const at = this.at; - const rc = this.ex.ghostty_terminal_get_multi(this.term, 4, at.tKeys, at.tVals, 0); - if (rc !== 0) throw new Error(`libghostty-vt terminal_get_multi failed: ${rc}`); - const dv = this.dv(); - this.cursorX = dv.getUint16(at.curX, true); - this.cursorY = dv.getUint16(at.curY, true); - this.sbRows = dv.getUint32(at.sbRows, true); - this.altScreen = dv.getInt32(at.screen, true) === this.L.screenAlternate; - this.infoGen = this.generation; - } - - /** - * Brings the active-screen copy up to date with the terminal, once per generation. Each - * refresh decodes only the rows Ghostty reports dirty (every row after a resize or - * colour change), adds them to the dirty set, and ends with `render_state_clean`, - * which clears Ghostty's global and per-row dirty state. The JS dirty state - * accumulates until `markClean()`. - */ - private refresh(): void { - if (this.refreshedGen === this.generation) return; - this.info(); - const ex = this.ex; - const L = this.L; - const at = this.at; - - let rc = ex.ghostty_render_state_update(this.rs, this.term); - if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); - let dv = this.dv(); - dv.setUint32(at.cursor + L.cursor.sizeField, L.cursor.size, true); - dv.setUint32(at.colors + L.colors.sizeField, L.colors.size, true); - rc = ex.ghostty_render_state_get_multi(this.rs, 3, at.rsKeys, at.rsVals, 0); - if (rc !== 0) throw new Error(`libghostty-vt render_state_get_multi failed: ${rc}`); - // Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no - // row dirty when only the defaults change (OSC 10/11/110/111, DECSCNM): its own - // renderer applies them globally. Redecode every row when any colour changed. - const colorsChanged = this.readCursorAndColors(); - dv = this.dv(); - const dirty = dv.getInt32(at.dirty, true); - rc = ex.ghostty_render_state_get(this.rs, L.rs.ROW_ITERATOR, at.iterH); - if (rc !== 0) throw new Error(`libghostty-vt ROW_ITERATOR failed: ${rc}`); - - const all = this.decodeAll || colorsChanged; - const cols = this._cols; - while ( - all - ? ex.ghostty_render_state_row_iterator_next(this.iter) - : ex.ghostty_render_state_row_iterator_next_dirty(this.iter, at.y) - ) { - const len = this.readRowRaw(); - const y = this.dv().getInt32(at.viewY, true); - if (y < 0 || y >= this._rows) continue; - this.viewGraphemes[y] = this.decodeRow(this.viewCells, y * cols, len); - this.dirtyRows.add(y); - } - ex.ghostty_render_state_clean(this.rs); - - const level = all - ? DirtyState.FULL - : dirty === L.dirty.FULL - ? DirtyState.FULL - : dirty === L.dirty.PARTIAL - ? DirtyState.PARTIAL - : DirtyState.NONE; - if (level > this.dirtyMax) this.dirtyMax = level; - this.decodeAll = false; - this.refreshedGen = this.generation; - } - - /** Returns whether the default colours or the palette changed since the last read. */ - private readCursorAndColors(): boolean { - const L = this.L; - const at = this.at; - const u8 = this.u8(); - const dv = this.dv(); - const C = L.cursor; - this.cursorVisible = u8[at.cursor + C.visible] !== 0; - this.cursorBlinking = u8[at.cursor + C.blinking] !== 0; - const style = dv.getInt32(at.cursor + C.visualStyle, true); - this.cursorStyle = - style === L.cursorStyle.BAR ? 'bar' : style === L.cursorStyle.UNDERLINE ? 'underline' : 'block'; - const K = L.colors; - const defBg = this.rgbAt(at.colors + K.background); - const defFg = this.rgbAt(at.colors + K.foreground); - let changed = defBg !== this.defBg || defFg !== this.defFg; - this.defBg = defBg; - this.defFg = defFg; - const pal = at.colors + K.palette; - const out = this.palette; - for (let i = 0; i < 256; i++) { - const p = pal + i * K.rgbSize; - const r = u8[p + K.r]; - const g = u8[p + K.g]; - const b = u8[p + K.b]; - if (out[i * 3] !== r || out[i * 3 + 1] !== g || out[i * 3 + 2] !== b) { - changed = true; - out[i * 3] = r; - out[i * 3 + 1] = g; - out[i * 3 + 2] = b; - } - } - return changed; - } - - /** - * Reads the iterator's current row (CELLS_RAW, VIEWPORT_Y, and binds the row-cells - * handle) and copies the raw cells into the JS-owned `raw` array at once. - */ - private readRowRaw(): number { - const at = this.at; - const L = this.L; - const rc = this.ex.ghostty_render_state_row_get_multi(this.iter, 3, at.rowKeys, at.rowVals, 0); - if (rc !== 0) throw new Error(`libghostty-vt render_state_row_get_multi failed: ${rc}`); - const dv = this.dv(); - const ptr = dv.getUint32(at.view + L.cellsView.ptr, true); - const len = dv.getUint32(at.view + L.cellsView.len, true); - const words = (len * L.cellSize) / 4; - if (this.raw.length < words) this.raw = new Uint32Array(words); - if (ptr && words) this.raw.set(new Uint32Array(this.ex.memory.buffer, ptr, words)); - return len; - } - - /** - * Decodes the copied raw row into `out[base..base+cols)`. Styles come from a - * per-row cache (`row_cells_get` STYLE only for an id not seen in the row), and - * grapheme codepoints are read only for cells tagged as graphemes. - */ - private decodeRow(out: GhosttyCell[], base: number, len: number): Map<number, string> | null { - const L = this.L; - const B = L.cell; - // Hoisted: this loop runs once per cell of every decoded row. - const tagLsb = B.tag.lsb; - const tagMask = B.tag.mask; - const contentLsb = B.content.lsb; - const contentMask = B.content.mask; - const styleLsb = B.style.lsb; - const styleMask = B.style.mask; - const wideLsb = B.wide.lsb; - const wideMask = B.wide.mask; - const linkLsb = B.hyperlink.lsb; - const linkMask = B.hyperlink.mask; - const cpLsb = B.codepoint.lsb; - const cpMask = B.codepoint.mask; - const TAG_CP = L.tag.CODEPOINT; - const TAG_GRAPHEME = L.tag.CODEPOINT_GRAPHEME; - const TAG_BG_PALETTE = L.tag.BG_COLOR_PALETTE; - const TAG_BG_RGB = L.tag.BG_COLOR_RGB; - const WIDE = L.wide.WIDE; - const NARROW = L.wide.NARROW; - const defFg = this.defFg; - const defBg = this.defBg; - const raw = this.raw; - const pal = this.palette; - const words = L.cellSize / 4; - const styles = this.rowStyles; - styles.clear(); - let graphemes: Map<number, string> | null = null; - const n = Math.min(len, this._cols); - for (let x = 0; x < n; x++) { - const lo = raw[x * words]; - const hi = raw[x * words + 1]; - const tag = readBits(lo, hi, tagLsb, tagMask); - const content = readBits(lo, hi, contentLsb, contentMask); - const styleId = readBits(lo, hi, styleLsb, styleMask); - const wide = readBits(lo, hi, wideLsb, wideMask); - const cell = out[base + x]; - let fg = defFg; - let bg = defBg; - let flags = 0; - let codepoint = 0; - let bgFromContent = false; - if (tag === TAG_CP || tag === TAG_GRAPHEME) { - codepoint = (content >>> cpLsb) & cpMask; - } else if (tag === TAG_BG_PALETTE) { - const i = readBits(content, 0, B.paletteIndex.lsb, B.paletteIndex.mask) * 3; - bg = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; - bgFromContent = true; - } else if (tag === TAG_BG_RGB) { - bg = - (readBits(content, 0, B.r.lsb, B.r.mask) << 16) | - (readBits(content, 0, B.g.lsb, B.g.mask) << 8) | - readBits(content, 0, B.b.lsb, B.b.mask); - bgFromContent = true; - } - if (styleId !== 0) { - let style = styles.get(styleId); - if (!style) { - style = this.readStyle(x); - styles.set(styleId, style); - } - if (style.fg >= 0) fg = style.fg; - if (style.bg >= 0 && !bgFromContent) bg = style.bg; - flags = style.flags; - } - cell.codepoint = codepoint; - cell.fg_r = (fg >>> 16) & 0xff; - cell.fg_g = (fg >>> 8) & 0xff; - cell.fg_b = fg & 0xff; - cell.bg_r = (bg >>> 16) & 0xff; - cell.bg_g = (bg >>> 8) & 0xff; - cell.bg_b = bg & 0xff; - cell.flags = flags; - cell.width = wide === WIDE ? 2 : wide === NARROW ? 1 : 0; - cell.hyperlink_id = readBits(lo, hi, linkLsb, linkMask); - cell.grapheme_len = 0; - if (tag === TAG_GRAPHEME) { - const cps = this.readGrapheme(x); - if (cps.length > 1) { - cell.grapheme_len = cps.length - 1; - (graphemes ??= new Map()).set(x, String.fromCodePoint(...cps)); - } - } - } - for (let x = n; x < this._cols; x++) { - const cell = out[base + x]; - cell.codepoint = 0; - setRgb(cell, defFg, defBg); - cell.flags = 0; - cell.width = 1; - cell.hyperlink_id = 0; - cell.grapheme_len = 0; - } - return graphemes; - } - - private readStyle(x: number): DecodedStyle { - const L = this.L; - const S = L.style; - const C = L.styleColor; - const p = this.at.style; - const ex = this.ex; - ex.ghostty_render_state_row_cells_select(this.rowCells, x); - new DataView(ex.memory.buffer).setUint32(p + S.sizeField, S.size, true); - if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.STYLE, p) !== 0) { - return { fg: -1, bg: -1, flags: 0 }; - } - const dv = new DataView(ex.memory.buffer); - const pal = this.palette; - let fg = -1; - let bg = -1; - for (let side = 0; side < 2; side++) { - const at = p + (side === 0 ? S.fg : S.bg); - const tag = dv.getInt32(at + C.tag, true); - const v = at + C.value; - let rgb = -1; - if (tag === C.PALETTE) { - const i = dv.getUint8(v + C.palette) * 3; - rgb = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; - } else if (tag === C.RGB) { - const c = v + C.rgb; - const K = L.colors; - rgb = (dv.getUint8(c + K.r) << 16) | (dv.getUint8(c + K.g) << 8) | dv.getUint8(c + K.b); - } - if (side === 0) fg = rgb; - else bg = rgb; - } - const flags = - (dv.getUint8(p + S.bold) ? CellFlags.BOLD : 0) | - (dv.getUint8(p + S.italic) ? CellFlags.ITALIC : 0) | - (dv.getInt32(p + S.underline, true) ? CellFlags.UNDERLINE : 0) | - (dv.getUint8(p + S.strikethrough) ? CellFlags.STRIKETHROUGH : 0) | - (dv.getUint8(p + S.inverse) ? CellFlags.INVERSE : 0) | - (dv.getUint8(p + S.invisible) ? CellFlags.INVISIBLE : 0) | - (dv.getUint8(p + S.blink) ? CellFlags.BLINK : 0) | - (dv.getUint8(p + S.faint) ? CellFlags.FAINT : 0); - return { fg, bg, flags }; - } - - private readGrapheme(x: number): number[] { - const L = this.L; - const at = this.at; - const ex = this.ex; - ex.ghostty_render_state_row_cells_select(this.rowCells, x); - if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_LEN, at.u32)) { - return []; - } - const n = this.dv().getUint32(at.u32, true); - if (n === 0) return []; - const big = n > GRAPHEME_SCRATCH; - const buf = big ? ex.ghostty_wasm_alloc(n * 4) : at.gBuf; - try { - if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_BUF, buf)) { - return []; - } - const dv = this.dv(); - const cps: number[] = []; - for (let i = 0; i < n; i++) cps.push(dv.getUint32(buf + i * 4, true)); - return cps; - } finally { - if (big) ex.ghostty_wasm_free(buf, n * 4); - } - } - - // ========================================================================== - // Private: rows by absolute index (0 = oldest scrollback row) - // ========================================================================== - - /** A row by absolute index; call `refresh()` first. */ - private rowAt(abs: number): RowRef { - if (abs >= this.sbRows) { - const y = abs - this.sbRows; - return { cells: this.viewCells, base: y * this._cols, graphemes: this.viewGraphemes[y] }; - } - return this.readPage(abs); - } - - /** - * Reads the page of `rows` rows holding scrollback row `abs` through a second - * render state: moves Ghostty's viewport there, reads every row with CELLS_RAW, - * and moves it back to the bottom. The primary state has already been refreshed - * at this generation (callers refresh first), so it has captured every pending - * dirty bit before the second state's update consumes the terminal's dirty state. - * The second state cannot trust dirty bits the primary has cleared, so it is - * recreated whenever the content has changed, and every miss rebuilds every row - * it reads. Scrolling changes no row, so the state and the pages are kept. - */ - private readPage(abs: number): RowRef { - const rows = this._rows; - // abs < S, so the page starts in scrollback: scroll_viewport ROW n snaps to the - // active screen only for n >= S. A page may run on into active rows; rowAt - // serves those from the primary copy. - const start = Math.floor(abs / rows) * rows; - if (this.pageGen !== this.generation) { - if (this.pageRs) this.ex.ghostty_render_state_free(this.pageRs); - this.pageRs = this.vt.newHandle('render_state_new', (slot) => - this.ex.ghostty_render_state_new(0, slot) - ); - this.pages.clear(); - this.pageGen = this.generation; - } - let page = this.pages.get(start); - if (page) { - this.pages.delete(start); - this.pages.set(start, page); - return page[abs - start]; - } - - const ex = this.ex; - const L = this.L; - const at = this.at; - this.scrollViewport(L.scroll.ROW, start); - try { - const rc = ex.ghostty_render_state_update(this.pageRs, this.term); - if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); - if (ex.ghostty_render_state_get(this.pageRs, L.rs.ROW_ITERATOR, at.iterH) !== 0) { - throw new Error('libghostty-vt ROW_ITERATOR failed'); - } - page = []; - while (ex.ghostty_render_state_row_iterator_next(this.iter)) { - const len = this.readRowRaw(); - const y = this.dv().getInt32(at.viewY, true); - if (y < 0 || y >= rows) continue; - const cells: GhosttyCell[] = []; - for (let x = 0; x < this._cols; x++) cells.push(blankCell()); - page[y] = { cells, base: 0, graphemes: this.decodeRow(cells, 0, len) }; - } - } finally { - this.scrollViewport(L.scroll.BOTTOM, 0); - } - for (let y = 0; y < rows; y++) { - if (!page[y]) { - const cells: GhosttyCell[] = []; - for (let x = 0; x < this._cols; x++) cells.push(blankCell()); - page[y] = { cells, base: 0, graphemes: null }; - } - } - this.pages.set(start, page); - if (this.pages.size > PAGE_CACHE_SIZE) { - const oldest = this.pages.keys().next().value; - if (oldest !== undefined) this.pages.delete(oldest); - } - return page[abs - start]; - } - - private copyRow(ref: RowRef): GhosttyCell[] { - const out: GhosttyCell[] = new Array(this._cols); - for (let x = 0; x < this._cols; x++) out[x] = { ...ref.cells[ref.base + x] }; - return out; - } - - private graphemeOf(ref: RowRef, col: number): string { - const g = ref.graphemes?.get(col); - if (g !== undefined) return g; - const cp = ref.cells[ref.base + col].codepoint; - return cp ? String.fromCodePoint(cp) : ' '; - } - - // ========================================================================== - // Private: single-cell lookups (grid_ref) - // ========================================================================== - - /** Resolves a point to a grid reference at `out` (by default `at.gridRef`). */ - private gridRef(tag: number, x: number, y: number, out: number = this.at.gridRef): boolean { - const L = this.L; - const at = this.at; - const P = L.point; - this.u8().fill(0, at.point, at.point + P.size); - const dv = this.dv(); - dv.setInt32(at.point + P.tag, tag, true); - dv.setUint16(at.point + P.value + P.x, x, true); - dv.setUint32(at.point + P.value + P.y, y, true); - this.u8().fill(0, out, out + L.gridRef.size); - dv.setUint32(out + L.gridRef.sizeField, L.gridRef.size, true); - return this.ex.ghostty_terminal_grid_ref(this.term, at.point, out) === 0; - } - - // ========================================================================== - // Private: selection (ADR 050) - // ========================================================================== - - private screenRef(cell: ScreenCell, out: number): boolean { - if (cell.col < 0 || cell.col >= this._cols || cell.row < 0) return false; - return this.gridRef(this.L.point.SCREEN, cell.col, cell.row, out); - } - - /** The screen cell of the grid reference at `ref`, or null if it has none. */ - private screenCellOf(ref: number): ScreenCell | null { - const at = this.at; - const P = this.L.point; - const rc = this.ex.ghostty_terminal_point_from_grid_ref(this.term, ref, P.SCREEN, at.coord); - if (rc !== 0) return null; - const dv = this.dv(); - return { col: dv.getUint16(at.coord + P.x, true), row: dv.getUint32(at.coord + P.y, true) }; - } - - /** Zeroes the GhosttySelection at `at.sel` and sets its size field. */ - private clearSelectionStruct(): void { - const S = this.L.selection; - this.u8().fill(0, this.at.sel, this.at.sel + S.size); - this.dv().setUint32(this.at.sel + S.sizeField, S.size, true); - } - - /** - * Runs a `select_*` call whose options are a sized struct with one grid reference, - * set to `cell`, and returns the selection it derives in screen cells. - */ - private selectAt( - options: { size: number; sizeField: number; ref: number }, - cell: ScreenCell, - call: (options: number, outSelection: number) => number - ): ScreenRange | null { - const at = this.at; - const S = this.L.selection; - this.info(); - this.u8().fill(0, at.selOpts, at.selOpts + options.size); - this.dv().setUint32(at.selOpts + options.sizeField, options.size, true); - if (!this.screenRef(cell, at.selOpts + options.ref)) return null; - this.clearSelectionStruct(); - if (call(at.selOpts, at.sel) !== 0) return null; - const start = this.screenCellOf(at.sel + S.start); - const end = this.screenCellOf(at.sel + S.end); - return start && end ? { start, end } : null; - } - - private hyperlinkAt(tag: number, x: number, y: number): string | null { - if (x < 0 || x >= this._cols || y < 0) return null; - const ex = this.ex; - const at = this.at; - if (!this.gridRef(tag, x, y)) return null; - // Most URIs fit; a longer one (Ghostty keeps OSC 8 URIs up to about 2 KiB) is - // read again with the size Ghostty reports. - let cap = 256; - for (let attempt = 0; attempt < 2; attempt++) { - const size = cap; - const buf = ex.ghostty_wasm_alloc(size); - try { - const rc = ex.ghostty_grid_ref_hyperlink_uri(at.gridRef, buf, size, at.u32); - const n = this.dv().getUint32(at.u32, true); - if (rc === this.L.result.OUT_OF_SPACE) { - cap = n; - continue; - } - if (rc !== 0 || n === 0) return null; - return new TextDecoder().decode(this.u8().slice(buf, buf + n)); - } finally { - ex.ghostty_wasm_free(buf, size); - } - } - return null; - } - - private readTitle(): void { - this.titleChanged = false; - const at = this.at; - const S = this.L.string; - if (this.ex.ghostty_terminal_get(this.term, this.L.tdata.TITLE, at.str) !== 0) return; - const dv = this.dv(); - const ptr = dv.getUint32(at.str + S.ptr, true); - const len = dv.getUint32(at.str + S.len, true); - this.pendingTitle = len ? new TextDecoder().decode(this.u8().slice(ptr, ptr + len)) : ''; - } -} From 2694d594f960a5d17c5fd17f64afba71c238e4ba Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 17:55:16 -0400 Subject: [PATCH 234/295] refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) OfficialTerminal's per-cell decode and page cache leave the facade for parts that share one explicit EngineContext (ADR 061): - engine/engine-context.ts: EngineContext, the state one terminal's bindings share: the exports, layout, terminal handle, scratch, grid size and generation as plain fields, info() and its fields, u8()/dv() (views built per call, never stored), setU32s, setOption and scrollViewport. Each part declares its own scratch slots (renderStateSlots, the facade's terminalSlots, the context's own), so the 40-slot table is written once and is still one allocation; - engine/render-state.ts: RenderState, the generation-gated refresh, the active-screen copy, readRowRaw/decodeRow/readStyle/readGrapheme with the hoisted locals, the reused Uint32Array and the per-row style cache kept as one unit (492 lines, limit 700), and decodePage, the page decode loop; - engine/scrollback-pages.ts: ScrollbackPages, the per-generation LRU page cache over a second render state, and rowAt; - engine/row-ref.ts: RowRef and the pure blankCell, setRgb, copyRow and graphemeOf. Bodies are unchanged apart from reading ctx fields; decodeRow has no per-cell closure, getter or allocation. The facade keeps one-line delegations and drops to 1258 lines (ratchet entry lowered). Tests: the cell, dirt and recolour tests move to vendor/tests/engine/render-state.test.ts and the scrollback row and page-cache tests to scrollback-pages.test.ts, with new direct tests of RenderState, ScrollbackPages (LRU eviction, generation drop), EngineContext and row-ref. Shared readers move to support/terminal-text.ts, countedGhostty joins support/wasm.ts and support/engine-harness.ts builds parts without the facade. PROVENANCE.md records the moves. official-replay.test.ts, 3 runs each: before 924, 935, 931 ms; after 905, 870, 876 ms. --- docs/specs/architecture.md | 5 +- scripts/architecture/ratchet-allowlist.ts | 6 +- src/client/vendor/ghostty-web/PROVENANCE.md | 8 +- .../ghostty-web/engine/engine-context.ts | 137 +++ .../ghostty-web/engine/official-terminal.ts | 1018 ++++------------- .../vendor/ghostty-web/engine/render-state.ts | 492 ++++++++ .../vendor/ghostty-web/engine/row-ref.ts | 63 + .../ghostty-web/engine/scrollback-pages.ts | 92 ++ .../tests/engine/engine-context.test.ts | 95 ++ .../vendor/tests/engine/render-state.test.ts | 271 +++++ .../vendor/tests/engine/row-ref.test.ts | 72 ++ .../tests/engine/scrollback-pages.test.ts | 232 ++++ .../vendor/tests/official-terminal.test.ts | 303 +---- .../vendor/tests/support/engine-harness.ts | 33 + .../vendor/tests/support/terminal-text.ts | 55 + src/client/vendor/tests/support/wasm.ts | 27 + 16 files changed, 1780 insertions(+), 1129 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/engine-context.ts create mode 100644 src/client/vendor/ghostty-web/engine/render-state.ts create mode 100644 src/client/vendor/ghostty-web/engine/row-ref.ts create mode 100644 src/client/vendor/ghostty-web/engine/scrollback-pages.ts create mode 100644 src/client/vendor/tests/engine/engine-context.test.ts create mode 100644 src/client/vendor/tests/engine/render-state.test.ts create mode 100644 src/client/vendor/tests/engine/row-ref.test.ts create mode 100644 src/client/vendor/tests/engine/scrollback-pages.test.ts create mode 100644 src/client/vendor/tests/support/engine-harness.ts create mode 100644 src/client/vendor/tests/support/terminal-text.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 95f4d0d..ccbae20 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -161,7 +161,8 @@ src/ vendor/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/) - support/ wasm.ts (the one WASM path, a cached loadGhostty()), fake-canvas.ts, fake-event-target.ts + support/ wasm.ts (the one WASM path, a cached loadGhostty(), countedGhostty()), fake-canvas.ts, fake-event-target.ts, + engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours) fixtures/ PTY captures (was vendor/fixtures/) ghostty-web/ Biome-excluded; see "The vendored layer" scripts/ @@ -195,7 +196,7 @@ ghostty-web/ lifecycle/ disposable-store.ts (DisposableStore, listen, toDisposable) – webtty-original engine/ every wasm call; no DOM engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts - wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts scrollback-pages.ts + wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts row-ref.ts scrollback-pages.ts selection-binding.ts search-binding.ts mouse-encoder-binding.ts paste-focus-binding.ts key-encoder-binding.ts engine-effects.ts (FnTable callbacks, clipboard write, readTitle) grid-refs.ts (grid and screen refs, hyperlinkAt, graphemes) engine-config.ts (applyConfig, colour reads and writes) cursor-blink.ts text-mime.ts kitty-keyboard-reply.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 872fe88..9c33d72 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -17,9 +17,9 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - // OfficialTerminal, moved whole out of ghostty.ts (2160 lines) before its own split. + // OfficialTerminal, moved out of ghostty.ts (2160 lines) and split into engine/ bindings. 'src/client/vendor/ghostty-web/engine/official-terminal.ts': { - lines: 1885, + lines: 1258, step: ENGINE_SPLIT, }, 'src/client/vendor/ghostty-web/input-handler.ts': { @@ -38,7 +38,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { lines: 2147, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/official-terminal.test.ts': { lines: 1432, step: VENDOR_TESTS }, + 'src/client/vendor/tests/official-terminal.test.ts': { lines: 1135, step: VENDOR_TESTS }, 'src/client/vendor/tests/renderer.test.ts': { lines: 1087, step: VENDOR_TESTS }, 'src/client/vendor/tests/terminal.test.ts': { lines: 1159, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index a6a96ce..eb69972 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -55,13 +55,13 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. -- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. +- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043, ADR 061): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). -- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal (`official-terminal.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -88,3 +88,7 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `ghostty.ts`: `Ghostty.load` (the default paths, now `defaultWasmPaths`, and the loop over them, now `loadWasmExports`) and `Ghostty.loadFromPath` (the read, now `readWasm`, and the compile) | `engine/wasm-loader.ts` | refactor(client): engine/wasm-loader.ts, the wasm loading out of Ghostty.load | | `ghostty.ts`: `OfficialKeyEncoder` (webtty), now exported and typed on `EngineHost` | `engine/key-encoder-binding.ts` | refactor(client): engine/key-encoder-binding.ts and the EngineHost interface | | `ghostty.ts`: `OfficialTerminal` (webtty) with its row types, constants and cell helpers, and the binding's rules comment; typed on `EngineHost`, re-exported by `ghostty.ts` (`git blame -C` follows the lines) | `engine/official-terminal.ts` | refactor(client): engine/official-terminal.ts, ghostty.ts becomes the factory facade | +| `engine/official-terminal.ts`: the `ex`/`L`/terminal handle/scratch/grid size/generation fields, `info()` and its fields, `u8`, `dv`, `setU32s`, `setOption`, `scrollViewport`, and the info, viewport and shared scratch slots (webtty) | `engine/engine-context.ts` (`EngineContext`) | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | +| `engine/official-terminal.ts`: `refresh`, `readCursorAndColors`, `rgbAt`, `readRowRaw`, `decodeRow`, `readStyle`, `readGrapheme`, `allocView`, the page decode loop of `readPage` (now `decodePage`), `DecodedStyle`, `GRAPHEME_SCRATCH`, the render-state handles, dirty state, active-screen copy, cursor and colour fields and their scratch slots; `update`, `getCursor`, `isRowDirty`, `markClean`, `getViewport` bodies (webtty) | `engine/render-state.ts` (`RenderState`) | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | +| `engine/official-terminal.ts`: `readPage` (the cache, now `scrollbackRow`), `rowAt`, `PAGE_CACHE_SIZE`, the page render state and cache fields (webtty) | `engine/scrollback-pages.ts` (`ScrollbackPages`) | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | +| `engine/official-terminal.ts`: `RowRef`, `blankCell`, `setRgb`, `copyRow`, `graphemeOf` (now functions taking the row) (webtty) | `engine/row-ref.ts` | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | diff --git a/src/client/vendor/ghostty-web/engine/engine-context.ts b/src/client/vendor/ghostty-web/engine/engine-context.ts new file mode 100644 index 0000000..0251b21 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-context.ts @@ -0,0 +1,137 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// The state one terminal's bindings share (object): the wasm exports and layout, the +// terminal handle, the scratch slots, the grid size, the content generation, and the +// terminal info read once per generation. Memory views are built per call, never stored. + +import type { EngineHost } from './engine-types'; +import type { OfficialLayout } from './official-layout'; +import { Scratch } from './scratch'; +import type { OfficialWasmExports } from './wasm-exports'; + +/** A binding's scratch slots: name → [size, align]. */ +export type SlotSpec<K extends string> = Record<K, readonly [number, number]>; + +/** + * The slots the context itself uses: the terminal info multi-get, the viewport scroll + * struct, and the one-word out-parameters every binding shares. + */ +function contextSlots(L: OfficialLayout) { + return { + tKeys: [16, 4], + tVals: [16, 4], + curX: [4, 4], + curY: [4, 4], + sbRows: [4, 4], + screen: [4, 4], + scroll: [L.scroll.size, 8], + u32: [8, 8], + u8: [8, 8], + } as const; +} + +/** The names of the context's own scratch slots. */ +export type ContextSlot = keyof ReturnType<typeof contextSlots>; + +/** + * One terminal's shared engine state (ADR 061). Bindings hold the context and read its + * fields directly; `K` names the scratch slots the bindings declared on top of the + * context's own, so each binding sees the slots it asked for. + */ +export class EngineContext<K extends string = never> { + readonly vt: EngineHost; + readonly ex: OfficialWasmExports; + readonly L: OfficialLayout; + readonly scratch: Scratch; + /** Every slot's address, the context's and the bindings'. */ + readonly at: Readonly<Record<K | ContextSlot, number>>; + /** The terminal handle; 0 once freed. */ + term: number; + cols: number; + rows: number; + /** + * Bumped by every content change (scrolling is not one): gates the render-state + * refresh and the info read, and keys the scrollback page cache. + */ + generation = 1; + + // Terminal info, read by `info()` once per generation. + cursorX = 0; + cursorY = 0; + sbRows = 0; + altScreen = false; + private infoGen = 0; + + constructor(vt: EngineHost, cols: number, rows: number, slots: SlotSpec<K>) { + this.vt = vt; + this.ex = vt.ex; + this.L = vt.L; + this.cols = cols; + this.rows = rows; + const ex = this.ex; + const L = this.L; + this.term = vt.newHandle('terminal_new', (slot) => ex.ghostty_terminal_new(0, slot, cols, rows)); + const { scratch, at } = Scratch.create(ex, { ...contextSlots(L), ...slots }); + this.scratch = scratch; + this.at = at as Record<K | ContextSlot, number>; + this.setU32s(at.tKeys, [ + L.tdata.CURSOR_X, + L.tdata.CURSOR_Y, + L.tdata.SCROLLBACK_ROWS, + L.tdata.ACTIVE_SCREEN, + ]); + this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); + } + + /** A fresh byte view; never keep one across an export call. */ + u8(): Uint8Array { + return new Uint8Array(this.ex.memory.buffer); + } + + /** A fresh DataView; never keep one across an export call. */ + dv(): DataView { + return new DataView(this.ex.memory.buffer); + } + + /** Writes consecutive little-endian u32 values from `ptr`. */ + setU32s(ptr: number, values: number[]): void { + const dv = this.dv(); + values.forEach((v, i) => dv.setUint32(ptr + i * 4, v, true)); + } + + /** Cursor, scrollback rows and active screen, read once per generation. */ + info(): void { + if (this.infoGen === this.generation) return; + const at = this.at; + const rc = this.ex.ghostty_terminal_get_multi(this.term, 4, at.tKeys, at.tVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt terminal_get_multi failed: ${rc}`); + const dv = this.dv(); + this.cursorX = dv.getUint16(at.curX, true); + this.cursorY = dv.getUint16(at.curY, true); + this.sbRows = dv.getUint32(at.sbRows, true); + this.altScreen = dv.getInt32(at.screen, true) === this.L.screenAlternate; + this.infoGen = this.generation; + } + + /** `terminal_set`, throwing with `what` in the message when Ghostty refuses it. */ + setOption(option: number, value: number, what: string): void { + const rc = this.ex.ghostty_terminal_set(this.term, option, value); + if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); + } + + /** + * Moves Ghostty's viewport (`GhosttyTerminalScrollViewport`). webtty's view is its own + * offset, so only page reads and prompt jumps move it, and they move it back. + */ + scrollViewport(tag: number, value: number): void { + const at = this.at; + const S = this.L.scroll; + const u8 = this.u8(); + u8.fill(0, at.scroll, at.scroll + S.size); + const dv = this.dv(); + dv.setInt32(at.scroll + S.tag, tag, true); + // The ROW arm is a usize and DELTA_PROMPT an isize (32 bits on wasm32); the union is + // zeroed above. + dv.setInt32(at.scroll + S.value, value, true); + this.ex.ghostty_terminal_scroll_viewport(this.term, at.scroll); + } +} diff --git a/src/client/vendor/ghostty-web/engine/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts index 24b16dd..23b8433 100644 --- a/src/client/vendor/ghostty-web/engine/official-terminal.ts +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -1,11 +1,13 @@ // webtty (ADR 042, ADR 047, ADR 061), not from upstream. // The terminal on the official libghostty-vt wasm (facade): `OfficialTerminal` implements // the `GhosttyTerminal` interface the rest of the vendored layer uses, on the wasm instance -// (`EngineHost`) that `Ghostty.createTerminal` passes it. Moved whole out of ghostty.ts (ADR 061). +// (`EngineHost`) that `Ghostty.createTerminal` passes it. Moved whole out of ghostty.ts (ADR 061); +// its parts share one `EngineContext`: the render state and the scrollback pages so far. import { type InstalledFn, WasmValType } from '../fn-table'; -import { CellFlags, DirtyState, type GhosttyCell, type RenderStateCursor } from './cell-types'; +import { DirtyState, type GhosttyCell, type RenderStateCursor } from './cell-types'; import { scanCursorBlink } from './cursor-blink'; +import { EngineContext } from './engine-context'; import type { DesktopNotification, EngineHost, @@ -20,9 +22,10 @@ import type { } from './engine-types'; import { isKittyKeyboardReply } from './kitty-keyboard-reply'; import type { OfficialLayout } from './official-layout'; -import { Scratch } from './scratch'; +import { RenderState, type RenderStateSlot, renderStateSlots } from './render-state'; +import { copyRow, graphemeOf } from './row-ref'; +import { ScrollbackPages } from './scrollback-pages'; import { isTextMime } from './text-mime'; -import { readBits } from './vt-layout'; import type { OfficialWasmExports } from './wasm-exports'; // Rules of the official libghostty-vt binding (ADR 041, ADR 042): @@ -38,25 +41,7 @@ import type { OfficialWasmExports } from './wasm-exports'; // copy their payload before returning. // - Callbacks never re-enter `vt_write`. -/** A decoded row: JS-owned cells and the strings of its multi-codepoint graphemes. */ -interface RowRef { - cells: GhosttyCell[]; - /** Index of the row's first cell in `cells`. */ - base: number; - /** Column → grapheme string, for cells holding more than one codepoint. */ - graphemes: Map<number, string> | null; -} - -interface DecodedStyle { - /** 0xRRGGBB, or -1 for the default. */ - fg: number; - bg: number; - flags: number; -} - const I32 = WasmValType.I32; -/** At most this many pages of scrollback rows stay cached per generation. */ -const PAGE_CACHE_SIZE = 16; /** The scrollback byte ceiling under the line limit (ADR 046). */ const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; /** History in lines when the config gives none (ADR 046, ADR 047). */ @@ -64,8 +49,6 @@ const DEFAULT_SCROLLBACK_LINES = 10000; /** webtty's default colours, used when the theme leaves one unset. */ const FALLBACK_FOREGROUND = 0xcccccc; const FALLBACK_BACKGROUND = 0x000000; -/** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ -const GRAPHEME_SCRATCH = 32; /** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ const MOUSE_REPORT_MAX = 64; /** Room for one focus report (`CSI I` / `CSI O`, 3 bytes). */ @@ -80,147 +63,67 @@ const MODE_CURSOR_BLINK = 12; /** Bytes as one char each, for scanning ASCII sequences in byte output. */ const LATIN1 = new TextDecoder('latin1'); -function blankCell(): GhosttyCell { +/** The scratch slots of the parts still in the facade, until they become bindings (ADR 061). */ +function terminalSlots(L: OfficialLayout) { return { - codepoint: 0, - fg_r: 0, - fg_g: 0, - fg_b: 0, - bg_r: 0, - bg_g: 0, - bg_b: 0, - flags: 0, - width: 1, - hyperlink_id: 0, - grapheme_len: 0, - }; + point: [L.point.size, 8], + gridRef: [L.gridRef.size, 8], + sel: [L.selection.size, 8], + selOpts: [ + Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), + 8, + ], + coord: [8, 8], + outPtr: [4, 4], + outLen: [4, 4], + row64: [8, 8], + str: [L.string.size, 8], + mode: [L.mode.size, 8], + scrollbar: [L.scrollbar.size, 8], + palette: [256 * L.colors.rgbSize, 8], + mouseSize: [L.mouse.size, 8], + mousePos: [L.mouse.positionSize, 8], + mouseOut: [MOUSE_REPORT_MAX, 8], + focusOut: [FOCUS_REPORT_MAX, 8], + clipReply: [L.clipboardWrite.replySize, 8], + searchBuf: [L.search.bufferSize, 8], + } as const; } -function setRgb(cell: GhosttyCell, fg: number, bg: number): void { - cell.fg_r = (fg >>> 16) & 0xff; - cell.fg_g = (fg >>> 8) & 0xff; - cell.fg_b = fg & 0xff; - cell.bg_r = (bg >>> 16) & 0xff; - cell.bg_g = (bg >>> 8) & 0xff; - cell.bg_b = bg & 0xff; -} +type TerminalSlot = keyof ReturnType<typeof terminalSlots>; /** * A terminal on the official libghostty-vt wasm (ADR 042). * - * Rows are read through the render state: a refresh runs `render_state_update`, - * visits the dirty rows with `row_iterator_next_dirty`, and decodes each row's - * `CELLS_RAW` into JS-owned cells indexed by active row. Refreshes happen only - * when the generation (bumped by writes, resizes and pixel-size changes) has - * changed, so the many `update()` calls of one frame decode each row once. - * Ghostty's viewport stays at the bottom, so the primary copy is the active - * screen. Scrollback rows are read a page at a time through a second render - * state and cached until the content changes, so scrolling only records the - * offset and serves rows from the copy and the cache. + * Rows are read through `RenderState`, which keeps a JS copy of the active screen up to + * date once per generation, and `ScrollbackPages`, which reads scrollback a page at a + * time and caches it until the content changes, so scrolling only records the offset + * and serves rows from the copy and the cache. The other wasm objects (selection, + * search, mouse and paste encoders, callbacks) are still driven from here. */ export class OfficialTerminal implements GhosttyTerminal { private readonly vt: EngineHost; + private readonly ctx: EngineContext<TerminalSlot | RenderStateSlot>; private readonly ex: OfficialWasmExports; private readonly L: OfficialLayout; - private term = 0; - private rs = 0; - private iter = 0; - private rowCells = 0; - private pageRs = 0; - private readonly scratch: Scratch; - private readonly at: Record< - | 'iterH' - | 'cellsH' - | 'y' - | 'dirty' - | 'viewY' - | 'view' - | 'cursor' - | 'colors' - | 'style' - | 'rsKeys' - | 'rsVals' - | 'rowKeys' - | 'rowVals' - | 'tKeys' - | 'tVals' - | 'curX' - | 'curY' - | 'sbRows' - | 'screen' - | 'scroll' - | 'scrollbar' - | 'point' - | 'gridRef' - | 'sel' - | 'selOpts' - | 'coord' - | 'outPtr' - | 'outLen' - | 'row64' - | 'str' - | 'mode' - | 'u32' - | 'u8' - | 'gBuf' - | 'palette' - | 'mouseSize' - | 'mousePos' - | 'mouseOut' - | 'focusOut' - | 'clipReply' - | 'searchBuf', - number - >; + private readonly at: EngineContext<TerminalSlot | RenderStateSlot>['at']; + private readonly render: RenderState; + private readonly pages: ScrollbackPages; private readonly callbacks: InstalledFn[] = []; - private _cols: number; - private _rows: number; private cellWidthPx = 0; private cellHeightPx = 0; - // Generation and refresh state. The generation is bumped by every content change - // (scrolling is not one) and also keys the page cache. - private generation = 1; - private refreshedGen = 0; - private infoGen = 0; - private decodeAll = true; - private readonly dirtyRows = new Set<number>(); - private dirtyMax: DirtyState = DirtyState.NONE; - - // The JS copy of the active screen, indexed by active row. - private viewCells: GhosttyCell[] = []; - private viewGraphemes: (Map<number, string> | null)[] = []; - private raw = new Uint32Array(0); - private readonly rowStyles = new Map<number, DecodedStyle>(); - - // Terminal info, read once per generation. - private cursorX = 0; - private cursorY = 0; - private sbRows = 0; - private altScreen = false; - - // Render-state cursor and colours from the last refresh. - private cursorVisible = true; - private cursorBlinking = false; - private cursorStyle: RenderStateCursor['style'] = 'block'; // The blink DECSCUSR set, or the default while it has not set one; DEC mode 12 is // ignored, so Ghostty's mode bit is put back to it after every write (ADR 049). private blinkDefault = false; private blinkIsDefault = true; private blink = false; private blinkCarry = ''; - private defFg = 0xffffff; - private defBg = 0; - private readonly palette = new Uint8Array(256 * 3); // The view's offset: lines scrolled up from the bottom. Ghostty's viewport stays at // the bottom; only page reads move it, and they move it back. private offset = 0; - // Scrollback rows, keyed by the absolute row their page starts at. - private readonly pages = new Map<number, RowRef[]>(); - private pageGen = 0; - // Mouse encoder and event, made on the first report (ADR 049). `mouseModes` is the // MOUSE_MODES bitmask the encoder was last set from; `mouseGeometry` the last size. private mouseEncoder = 0; @@ -252,101 +155,28 @@ export class OfficialTerminal implements GhosttyTerminal { this.vt = vt; this.ex = vt.ex; this.L = vt.L; - this._cols = cols; - this._rows = rows; - const ex = this.ex; - const L = this.L; - - this.term = vt.newHandle('terminal_new', (slot) => ex.ghostty_terminal_new(0, slot, cols, rows)); - this.rs = vt.newHandle('render_state_new', (slot) => ex.ghostty_render_state_new(0, slot)); - this.iter = vt.newHandle('row_iterator_new', (slot) => - ex.ghostty_render_state_row_iterator_new(0, slot) - ); - this.rowCells = vt.newHandle('row_cells_new', (slot) => - ex.ghostty_render_state_row_cells_new(0, slot) - ); - - const { scratch, at } = Scratch.create(ex, { - iterH: [4, 4], - cellsH: [4, 4], - y: [4, 4], - dirty: [4, 4], - viewY: [4, 4], - view: [L.cellsView.size, 8], - cursor: [L.cursor.size, 8], - colors: [L.colors.size, 8], - style: [L.style.size, 8], - rsKeys: [12, 4], - rsVals: [12, 4], - rowKeys: [12, 4], - rowVals: [12, 4], - tKeys: [16, 4], - tVals: [16, 4], - curX: [4, 4], - curY: [4, 4], - sbRows: [4, 4], - screen: [4, 4], - scroll: [L.scroll.size, 8], - scrollbar: [L.scrollbar.size, 8], - point: [L.point.size, 8], - gridRef: [L.gridRef.size, 8], - sel: [L.selection.size, 8], - selOpts: [ - Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), - 8, - ], - coord: [8, 8], - outPtr: [4, 4], - outLen: [4, 4], - row64: [8, 8], - str: [L.string.size, 8], - mode: [L.mode.size, 8], - u32: [8, 8], - u8: [8, 8], - gBuf: [GRAPHEME_SCRATCH * 4, 8], - palette: [256 * L.colors.rgbSize, 8], - mouseSize: [L.mouse.size, 8], - mousePos: [L.mouse.positionSize, 8], - mouseOut: [MOUSE_REPORT_MAX, 8], - focusOut: [FOCUS_REPORT_MAX, 8], - clipReply: [L.clipboardWrite.replySize, 8], - searchBuf: [L.search.bufferSize, 8], - } as const); - this.scratch = scratch; - this.at = at; - - // Fixed scratch contents: handle slots and the key/value arrays of the multi-gets. - const dv = this.dv(); - dv.setUint32(at.iterH, this.iter, true); - dv.setUint32(at.cellsH, this.rowCells, true); - this.setU32s(at.rsKeys, [L.rs.DIRTY, L.rs.CURSOR, L.rs.COLORS]); - this.setU32s(at.rsVals, [at.dirty, at.cursor, at.colors]); - this.setU32s(at.rowKeys, [L.row.CELLS_RAW, L.row.VIEWPORT_Y, L.row.CELLS]); - this.setU32s(at.rowVals, [at.view, at.viewY, at.cellsH]); - this.setU32s(at.tKeys, [ - L.tdata.CURSOR_X, - L.tdata.CURSOR_Y, - L.tdata.SCROLLBACK_ROWS, - L.tdata.ACTIVE_SCREEN, - ]); - this.setU32s(at.tVals, [at.curX, at.curY, at.sbRows, at.screen]); + this.ctx = new EngineContext(vt, cols, rows, { + ...renderStateSlots(vt.L), + ...terminalSlots(vt.L), + }); + this.at = this.ctx.at; + this.render = new RenderState(this.ctx); + this.pages = new ScrollbackPages(this.ctx, this.render); this.installCallbacks( config?.clipboardWrite ?? true, config?.desktopNotifications ?? true ); this.applyConfig(config); - this.allocView(); } get cols(): number { - return this._cols; + return this.ctx.cols; } get rows(): number { - return this._rows; + return this.ctx.rows; } - // ========================================================================== // Lifecycle // ========================================================================== @@ -366,19 +196,19 @@ export class OfficialTerminal implements GhosttyTerminal { // writing); the first frame at the bottom repaints every row. if (this.offset !== 0) { this.offset = 0; - this.dirtyMax = DirtyState.FULL; + this.render.markAllDirty(); } const ex = this.ex; let consumed: number | null = bytes.length; // until_ground takes NULL for no bytes, and then only says whether it is at ground const ptr = bytes.length > 0 ? ex.ghostty_wasm_alloc(bytes.length) : 0; if (bytes.length > 0 && !ptr) throw new Error('libghostty-vt: out of memory'); - if (ptr) this.u8().set(bytes, ptr); + if (ptr) this.ctx.u8().set(bytes, ptr); if (untilGround) { - const rc = ex.ghostty_terminal_vt_write_until_ground(this.term, ptr, bytes.length, this.at.u32); - consumed = rc === this.L.result.SUCCESS ? this.dv().getUint32(this.at.u32, true) : null; + const rc = ex.ghostty_terminal_vt_write_until_ground(this.ctx.term, ptr, bytes.length, this.at.u32); + consumed = rc === this.L.result.SUCCESS ? this.ctx.dv().getUint32(this.at.u32, true) : null; } else if (ptr) { - ex.ghostty_terminal_vt_write(this.term, ptr, bytes.length); + ex.ghostty_terminal_vt_write(this.ctx.term, ptr, bytes.length); } if (ptr) ex.ghostty_wasm_free(ptr, bytes.length); const fed = consumed ?? bytes.length; @@ -394,36 +224,35 @@ export class OfficialTerminal implements GhosttyTerminal { } this.syncBlink(); } - this.generation++; + this.ctx.generation++; if (this.titleChanged) this.readTitle(); return consumed; } resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { - if (cols === this._cols && rows === this._rows) { + if (cols === this.ctx.cols && rows === this.ctx.rows) { if (cellWidthPx !== undefined && cellHeightPx !== undefined) { this.setCellPixelSize(cellWidthPx, cellHeightPx); } return; } - this._cols = cols; - this._rows = rows; + this.ctx.cols = cols; + this.ctx.rows = rows; if (cellWidthPx !== undefined && cellHeightPx !== undefined) { this.cellWidthPx = Math.max(0, Math.round(cellWidthPx)); this.cellHeightPx = Math.max(0, Math.round(cellHeightPx)); } // One call per real change: with mode 2048 on, every call sends a size report. const rc = this.ex.ghostty_terminal_resize( - this.term, + this.ctx.term, cols, rows, this.cellWidthPx, this.cellHeightPx ); if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); - this.generation++; - this.decodeAll = true; - this.allocView(); + this.ctx.generation++; + this.render.resized(); // Reflow keeps Ghostty's viewport at the bottom; only the offset is clamped. if (this.offset > 0) this.setViewportOffset(this.offset); } @@ -434,23 +263,23 @@ export class OfficialTerminal implements GhosttyTerminal { if (w === this.cellWidthPx && h === this.cellHeightPx) return; this.cellWidthPx = w; this.cellHeightPx = h; - const rc = this.ex.ghostty_terminal_resize(this.term, this._cols, this._rows, w, h); + const rc = this.ex.ghostty_terminal_resize(this.ctx.term, this.ctx.cols, this.ctx.rows, w, h); if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); - this.generation++; + this.ctx.generation++; } setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { const L = this.L; const at = this.at; - this.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); - this.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); - this.u8()[at.u8] = blink ? 1 : 0; - this.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); + this.ctx.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); + this.ctx.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); + this.ctx.u8()[at.u8] = blink ? 1 : 0; + this.ctx.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); this.blinkDefault = blink; if (this.blinkIsDefault) this.blink = blink; this.syncBlink(); // The cursor is read from the render state, which only a content change refreshes. - this.generation++; + this.ctx.generation++; } /** @@ -459,10 +288,10 @@ export class OfficialTerminal implements GhosttyTerminal { * rows from the primary copy, so a scroll step costs no export call on a page hit. */ setViewportOffset(linesFromBottom: number): void { - this.info(); - const k = this.altScreen + this.ctx.info(); + const k = this.ctx.altScreen ? 0 - : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, this.sbRows)); + : Math.max(0, Math.min(Math.floor(linesFromBottom) || 0, this.ctx.sbRows)); if (k === this.offset) return; // Scrolling changes no row: the renderer repaints what the move needs (ADR 043). this.offset = k; @@ -470,10 +299,10 @@ export class OfficialTerminal implements GhosttyTerminal { free(): void { const ex = this.ex; - if (!this.term) return; + if (!this.ctx.term) return; this.setSearch(''); - ex.ghostty_terminal_free(this.term); - this.term = 0; + ex.ghostty_terminal_free(this.ctx.term); + this.ctx.term = 0; // Slots are released only after nothing can call them (fn-table.ts rule 3). for (const fn of this.callbacks) fn.release(); this.callbacks.length = 0; @@ -481,12 +310,9 @@ export class OfficialTerminal implements GhosttyTerminal { if (this.mouseEvent) ex.ghostty_mouse_event_free(this.mouseEvent); this.mouseEncoder = 0; this.mouseEvent = 0; - if (this.pageRs) ex.ghostty_render_state_free(this.pageRs); - ex.ghostty_render_state_row_cells_free(this.rowCells); - ex.ghostty_render_state_row_iterator_free(this.iter); - ex.ghostty_render_state_free(this.rs); - this.pageRs = 0; - this.scratch.free(); + this.pages.free(); + this.render.free(); + this.ctx.scratch.free(); } // ========================================================================== @@ -494,43 +320,30 @@ export class OfficialTerminal implements GhosttyTerminal { // ========================================================================== update(): DirtyState { - this.refresh(); - return this.dirtyMax; + return this.render.update(); } getCursor(): RenderStateCursor { - this.refresh(); - return { - x: this.cursorX, - y: this.cursorY, - viewportX: this.cursorX, - viewportY: this.cursorY, - visible: this.cursorVisible, - blinking: this.cursorBlinking, - style: this.cursorStyle, - }; + return this.render.getCursor(); } isRowDirty(y: number): boolean { - this.refresh(); - return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); + return this.render.isRowDirty(y); } markClean(): void { - this.dirtyRows.clear(); - this.dirtyMax = DirtyState.NONE; + this.render.markClean(); } getViewport(): GhosttyCell[] { - this.refresh(); // The active screen, scrolled or not. - return this.viewCells; + return this.render.getViewport(); } getLine(y: number): GhosttyCell[] | null { - if (y < 0 || y >= this._rows) return null; - this.refresh(); - return this.copyRow(this.rowAt(this.sbRows + y)); + if (y < 0 || y >= this.ctx.rows) return null; + this.render.refresh(); + return copyRow(this.pages.rowAt(this.ctx.sbRows + y), this.ctx.cols); } needsFullRedraw(): boolean { @@ -542,7 +355,7 @@ export class OfficialTerminal implements GhosttyTerminal { } getDimensions(): { cols: number; rows: number } { - return { cols: this._cols, rows: this._rows }; + return { cols: this.ctx.cols, rows: this.ctx.rows }; } // ========================================================================== @@ -550,48 +363,48 @@ export class OfficialTerminal implements GhosttyTerminal { // ========================================================================== isAlternateScreen(): boolean { - this.info(); - return this.altScreen; + this.ctx.info(); + return this.ctx.altScreen; } hasMouseTracking(): boolean { const at = this.at; - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MOUSE_TRACKING, at.u8); - return rc === 0 && this.u8()[at.u8] !== 0; + const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.MOUSE_TRACKING, at.u8); + return rc === 0 && this.ctx.u8()[at.u8] !== 0; } cursorAtPrompt(): boolean { const at = this.at; - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.CURSOR_AT_PROMPT, at.u8); - return rc === 0 && this.u8()[at.u8] !== 0; + const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.CURSOR_AT_PROMPT, at.u8); + return rc === 0 && this.ctx.u8()[at.u8] !== 0; } promptJumpRow(top: number, delta: number): number { const at = this.at; const S = this.L.scroll; - this.info(); + this.ctx.info(); // Ghostty moves its viewport from its top row, so it is put on webtty's for the move - this.scrollViewport(S.ROW, top); + this.ctx.scrollViewport(S.ROW, top); try { - this.scrollViewport(S.DELTA_PROMPT, delta); - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.SCROLLBAR, at.scrollbar); + this.ctx.scrollViewport(S.DELTA_PROMPT, delta); + const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.SCROLLBAR, at.scrollbar); if (rc !== 0) return top; // The offset is a u64; a screen row fits in the low 32 bits - return this.dv().getUint32(at.scrollbar + this.L.scrollbar.offset, true); + return this.ctx.dv().getUint32(at.scrollbar + this.L.scrollbar.offset, true); } finally { - this.scrollViewport(S.BOTTOM, 0); + this.ctx.scrollViewport(S.BOTTOM, 0); } } getMode(mode: number, isAnsi: boolean = false): boolean { const at = this.at; const M = this.L.mode; - const dv = this.dv(); + const dv = this.ctx.dv(); // GhosttyMode (modes.h ghostty_mode_new): number in bits 0-14, bit 15 = ANSI. dv.setUint16(at.mode + M.mode, (mode & 0x7fff) | (isAnsi ? 0x8000 : 0), true); dv.setUint8(at.mode + M.value, 0); - const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MODE, at.mode); - return rc === 0 && this.u8()[at.mode + M.value] !== 0; + const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.MODE, at.mode); + return rc === 0 && this.ctx.u8()[at.mode + M.value] !== 0; } // ========================================================================== @@ -599,30 +412,30 @@ export class OfficialTerminal implements GhosttyTerminal { // ========================================================================== getScrollbackLength(): number { - this.info(); - return this.sbRows; + this.ctx.info(); + return this.ctx.sbRows; } getScrollbackLine(offset: number): GhosttyCell[] | null { - this.refresh(); - if (offset < 0 || offset >= this.sbRows) return null; + this.render.refresh(); + if (offset < 0 || offset >= this.ctx.sbRows) return null; // A page's cells are never written after the page is read, so the row's cell // objects are shared, not copied; only the array is new. - return this.readPage(offset).cells.slice(); + return this.pages.scrollbackRow(offset).cells.slice(); } isRowWrapped(row: number): boolean { - if (row < 0 || row >= this._rows) return false; + if (row < 0 || row >= this.ctx.rows) return false; const at = this.at; if (!this.gridRef(this.L.point.ACTIVE, 0, row)) return false; if (this.ex.ghostty_grid_ref_row(at.gridRef, at.row64) !== 0) return false; - const rowValue = this.dv().getBigUint64(at.row64, true); + const rowValue = this.ctx.dv().getBigUint64(at.row64, true); if (this.ex.ghostty_row_get(rowValue, this.L.rowWrapContinuation, at.u8) !== 0) return false; - return this.u8()[at.u8] !== 0; + return this.ctx.u8()[at.u8] !== 0; } getHyperlinkUri(row: number, col: number): string | null { - if (row < 0 || row >= this._rows) return null; + if (row < 0 || row >= this.ctx.rows) return null; return this.hyperlinkAt(this.L.point.ACTIVE, col, row); } @@ -631,15 +444,15 @@ export class OfficialTerminal implements GhosttyTerminal { } getGraphemeString(row: number, col: number): string { - if (row < 0 || row >= this._rows || col < 0 || col >= this._cols) return ' '; - this.refresh(); - return this.graphemeOf(this.rowAt(this.sbRows + row), col); + if (row < 0 || row >= this.ctx.rows || col < 0 || col >= this.ctx.cols) return ' '; + this.render.refresh(); + return graphemeOf(this.pages.rowAt(this.ctx.sbRows + row), col); } getScrollbackGraphemeString(offset: number, col: number): string { - this.refresh(); - if (offset < 0 || offset >= this.sbRows || col < 0 || col >= this._cols) return ' '; - return this.graphemeOf(this.rowAt(offset), col); + this.render.refresh(); + if (offset < 0 || offset >= this.ctx.sbRows || col < 0 || col >= this.ctx.cols) return ' '; + return graphemeOf(this.pages.rowAt(offset), col); } // ========================================================================== @@ -649,24 +462,24 @@ export class OfficialTerminal implements GhosttyTerminal { selectWord(cell: ScreenCell): ScreenRange | null { const W = this.L.selection.word; return this.selectAt(W, cell, (opts, out) => - this.ex.ghostty_terminal_select_word(this.term, opts, out) + this.ex.ghostty_terminal_select_word(this.ctx.term, opts, out) ); } selectLine(cell: ScreenCell): ScreenRange | null { const W = this.L.selection.line; return this.selectAt(W, cell, (opts, out) => - this.ex.ghostty_terminal_select_line(this.term, opts, out) + this.ex.ghostty_terminal_select_line(this.ctx.term, opts, out) ); } selectOutput(cell: ScreenCell): ScreenRange | null { const at = this.at; const S = this.L.selection; - this.info(); + this.ctx.info(); if (!this.screenRef(cell, at.gridRef)) return null; this.clearSelectionStruct(); - if (this.ex.ghostty_terminal_select_output(this.term, at.gridRef, at.sel) !== 0) return null; + if (this.ex.ghostty_terminal_select_output(this.ctx.term, at.gridRef, at.sel) !== 0) return null; const start = this.screenCellOf(at.sel + S.start); const end = this.screenCellOf(at.sel + S.end); return start && end ? { start, end } : null; @@ -677,18 +490,18 @@ export class OfficialTerminal implements GhosttyTerminal { const at = this.at; const S = this.L.selection; const F = S.format; - this.info(); + this.ctx.info(); // Rows that left history, or a selection made before a resize, are clamped. - const last = this.sbRows + this._rows - 1; + const last = this.ctx.sbRows + this.ctx.rows - 1; const clamp = (c: ScreenCell): ScreenCell => ({ - col: Math.max(0, Math.min(c.col, this._cols - 1)), + col: Math.max(0, Math.min(c.col, this.ctx.cols - 1)), row: Math.max(0, Math.min(c.row, last)), }); this.clearSelectionStruct(); if (!this.screenRef(clamp(start), at.sel + S.start)) return ''; if (!this.screenRef(clamp(end), at.sel + S.end)) return ''; - this.u8().fill(0, at.selOpts, at.selOpts + F.size); - const dv = this.dv(); + this.ctx.u8().fill(0, at.selOpts, at.selOpts + F.size); + const dv = this.ctx.dv(); dv.setUint32(at.selOpts + F.sizeField, F.size, true); dv.setInt32(at.selOpts + F.emit, S.PLAIN, true); // Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed. @@ -698,16 +511,16 @@ export class OfficialTerminal implements GhosttyTerminal { dv.setUint32(at.outPtr, 0, true); dv.setUint32(at.outLen, 0, true); const rc = ex.ghostty_terminal_selection_format_alloc( - this.term, + this.ctx.term, 0, at.selOpts, at.outPtr, at.outLen ); - const ptr = this.dv().getUint32(at.outPtr, true); - const len = this.dv().getUint32(at.outLen, true); + const ptr = this.ctx.dv().getUint32(at.outPtr, true); + const len = this.ctx.dv().getUint32(at.outLen, true); if (rc !== 0 || !ptr) return ''; - const text = new TextDecoder().decode(this.u8().slice(ptr, ptr + len)); + const text = new TextDecoder().decode(this.ctx.u8().slice(ptr, ptr + len)); ex.ghostty_free(0, ptr, len); return text; } @@ -730,17 +543,17 @@ export class OfficialTerminal implements GhosttyTerminal { } if (!this.search) { this.search = this.vt.newHandle('search_new', (slot) => - ex.ghostty_search_new(0, slot, this.term) + ex.ghostty_search_new(0, slot, this.ctx.term) ); // The view is webtty's own; Ghostty's viewport stays at the bottom - this.dv().setInt32(at.u32, S.SCROLL_NONE, true); + this.ctx.dv().setInt32(at.u32, S.SCROLL_NONE, true); ex.ghostty_search_set(this.search, S.SELECT_SCROLL, at.u32); } const bytes = new TextEncoder().encode(needle); const buf = ex.ghostty_wasm_alloc(bytes.length); try { - this.u8().set(bytes, buf); - const dv = this.dv(); + this.ctx.u8().set(bytes, buf); + const dv = this.ctx.dv(); dv.setUint32(at.str + this.L.string.ptr, buf, true); dv.setUint32(at.str + this.L.string.len, bytes.length, true); // An unchanged needle keeps its results @@ -758,19 +571,19 @@ export class OfficialTerminal implements GhosttyTerminal { if (!this.search) return false; let worked = false; // Feeding is the only way the search sees output, resizes and resets - if (this.searchFedGen !== this.generation) { + if (this.searchFedGen !== this.ctx.generation) { ex.ghostty_search_feed(this.search); - this.searchFedGen = this.generation; + this.searchFedGen = this.ctx.generation; worked = true; } if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) return worked; - let status = this.dv().getInt32(at.u32, true); + let status = this.ctx.dv().getInt32(at.u32, true); const end = performance.now() + budgetMs; while (status !== S.COMPLETE && performance.now() < end) { if (status === S.FEED_REQUIRED) ex.ghostty_search_feed(this.search); else ex.ghostty_search_tick(this.search, 0); if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) break; - status = this.dv().getInt32(at.u32, true); + status = this.ctx.dv().getInt32(at.u32, true); worked = true; } return worked; @@ -790,7 +603,7 @@ export class OfficialTerminal implements GhosttyTerminal { if (!this.search) return { total: 0, selected: null }; const read = (data: number): number | null => ex.ghostty_search_get(this.search, data, at.u32) === 0 - ? this.dv().getUint32(at.u32, true) + ? this.ctx.dv().getUint32(at.u32, true) : null; return { total: read(S.TOTAL_MATCHES) ?? 0, selected: read(S.SELECTED_INDEX) }; } @@ -817,7 +630,7 @@ export class OfficialTerminal implements GhosttyTerminal { // Matches are read where they are, before anything changes the terminal; the // buffer is grown to the count Ghostty reports, then read again. for (let attempt = 0; attempt < 2; attempt++) { - const dv = this.dv(); + const dv = this.ctx.dv(); dv.setUint32(at.searchBuf + S.ptr, this.matchBuf, true); dv.setUint32(at.searchBuf + S.cap, this.matchCap, true); dv.setUint32(at.searchBuf + S.len, 0, true); @@ -825,7 +638,7 @@ export class OfficialTerminal implements GhosttyTerminal { dv.setUint32(this.matchBuf + i * Sel.size + Sel.sizeField, Sel.size, true); } const rc = ex.ghostty_search_get(this.search, S.MATCHES, at.searchBuf); - const len = this.dv().getUint32(at.searchBuf + S.len, true); + const len = this.ctx.dv().getUint32(at.searchBuf + S.len, true); if (rc === this.L.result.OUT_OF_SPACE && attempt === 0) { if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * Sel.size); // Room for the matches output adds before the next read @@ -875,7 +688,7 @@ export class OfficialTerminal implements GhosttyTerminal { const enc = this.ensureMouseEncoder(); const at = this.at; const M = this.L.mouse; - const dv = this.dv(); + const dv = this.ctx.dv(); const u32 = (n: number) => Math.max(0, Math.round(n)) >>> 0; dv.setUint32(at.mouseSize + M.sizeField, M.size, true); dv.setUint32(at.mouseSize + M.screenWidth, u32(g.screenWidth), true); @@ -904,24 +717,24 @@ export class OfficialTerminal implements GhosttyTerminal { if (this.getMode(mode)) modes |= 1 << i; }); if (modes !== this.mouseModes) { - ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.term); + ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.ctx.term); this.mouseModes = modes; } - this.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; + this.ctx.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; ex.ghostty_mouse_encoder_setopt(enc, M.ANY_BUTTON_PRESSED, at.u8); const ev = this.mouseEvent; ex.ghostty_mouse_event_set_action(ev, M.action[input.action]); if (input.button) ex.ghostty_mouse_event_set_button(ev, M.button[input.button]); else ex.ghostty_mouse_event_clear_button(ev); ex.ghostty_mouse_event_set_mods(ev, input.mods); - const dv = this.dv(); + const dv = this.ctx.dv(); dv.setFloat32(at.mousePos + M.x, input.x, true); dv.setFloat32(at.mousePos + M.y, input.y, true); ex.ghostty_mouse_event_set_position(ev, at.mousePos); const rc = ex.ghostty_mouse_encoder_encode(enc, ev, at.mouseOut, MOUSE_REPORT_MAX, at.u32); if (rc !== 0) return new Uint8Array(0); - const n = this.dv().getUint32(at.u32, true); - return this.u8().slice(at.mouseOut, at.mouseOut + n); + const n = this.ctx.dv().getUint32(at.u32, true); + return this.ctx.u8().slice(at.mouseOut, at.mouseOut + n); } /** The mouse encoder, made with per-cell motion deduplication on, and its event. */ @@ -935,7 +748,7 @@ export class OfficialTerminal implements GhosttyTerminal { this.mouseEvent = vt.newHandle('mouse_event_new', (slot) => ex.ghostty_mouse_event_new(0, slot) ); - this.u8()[this.at.u8] = 1; + this.ctx.u8()[this.at.u8] = 1; ex.ghostty_mouse_encoder_setopt(this.mouseEncoder, this.L.mouse.TRACK_LAST_CELL, this.at.u8); return this.mouseEncoder; } @@ -950,8 +763,8 @@ export class OfficialTerminal implements GhosttyTerminal { const event = focused ? F.GAINED : F.LOST; const rc = this.ex.ghostty_focus_encode(event, at.focusOut, FOCUS_REPORT_MAX, at.u32); if (rc !== 0) return new Uint8Array(0); - const n = this.dv().getUint32(at.u32, true); - return this.u8().slice(at.focusOut, at.focusOut + n); + const n = this.ctx.dv().getUint32(at.u32, true); + return this.ctx.u8().slice(at.focusOut, at.focusOut + n); } // ========================================================================== @@ -972,11 +785,11 @@ export class OfficialTerminal implements GhosttyTerminal { const base = ex.ghostty_wasm_alloc(len); if (!base) throw new Error('libghostty-vt: out of memory'); try { - const u8 = this.u8(); + const u8 = this.ctx.u8(); u8.fill(0, base, base + P.size); u8.set(mime, base + mimeAt); u8.set(bytes, base + textAt); - const dv = this.dv(); + const dv = this.ctx.dv(); dv.setUint32(base + mimesAt + S.ptr, base + mimeAt, true); dv.setUint32(base + mimesAt + S.len, mime.length, true); dv.setUint32(base + P.sizeField, P.size, true); @@ -989,7 +802,7 @@ export class OfficialTerminal implements GhosttyTerminal { // ADR 037, and webtty has no confirmation dialog. dv.setUint8(base + P.allowUnsafe, 1); this.pasteText = { ptr: base + textAt, len: bytes.length }; - const rc = ex.ghostty_terminal_paste(this.term, base, 0); + const rc = ex.ghostty_terminal_paste(this.ctx.term, base, 0); if (rc !== 0) throw new Error(`libghostty-vt terminal_paste failed: ${rc}`); } finally { this.pasteText = { ptr: 0, len: 0 }; @@ -1010,7 +823,7 @@ export class OfficialTerminal implements GhosttyTerminal { ((_userdata: number, _mime: number, writer: number) => { const { ptr, len } = this.pasteText; if (len === 0) return 1; - const dv = this.dv(); + const dv = this.ctx.dv(); const write = table.get(dv.getUint32(writer + P.writerWrite, true)) as ( userdata: number, data: number, @@ -1063,32 +876,11 @@ export class OfficialTerminal implements GhosttyTerminal { } // ========================================================================== - // Private: memory helpers + // Private: colours and the cursor blink // ========================================================================== - /** A fresh byte view; never keep one across an export call. */ - private u8(): Uint8Array { - return new Uint8Array(this.ex.memory.buffer); - } - - /** A fresh DataView; never keep one across an export call. */ - private dv(): DataView { - return new DataView(this.ex.memory.buffer); - } - - private setU32s(ptr: number, values: number[]): void { - const dv = this.dv(); - values.forEach((v, i) => dv.setUint32(ptr + i * 4, v, true)); - } - - private rgbAt(ptr: number): number { - const u8 = this.u8(); - const C = this.L.colors; - return (u8[ptr + C.r] << 16) | (u8[ptr + C.g] << 8) | u8[ptr + C.b]; - } - private writeRgb(ptr: number, rgb: number): void { - const u8 = this.u8(); + const u8 = this.ctx.u8(); const C = this.L.colors; u8[ptr + C.r] = (rgb >>> 16) & 0xff; u8[ptr + C.g] = (rgb >>> 8) & 0xff; @@ -1106,28 +898,10 @@ export class OfficialTerminal implements GhosttyTerminal { if (this.getMode(MODE_CURSOR_BLINK) === this.blink) return; const at = this.at; const M = this.L.mode; - const dv = this.dv(); + const dv = this.ctx.dv(); dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); - this.setOption(this.L.opt.MODE, at.mode, 'MODE 12'); - } - - private setOption(option: number, value: number, what: string): void { - const rc = this.ex.ghostty_terminal_set(this.term, option, value); - if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); - } - - private scrollViewport(tag: number, value: number): void { - const at = this.at; - const S = this.L.scroll; - const u8 = this.u8(); - u8.fill(0, at.scroll, at.scroll + S.size); - const dv = this.dv(); - dv.setInt32(at.scroll + S.tag, tag, true); - // The ROW arm is a usize and DELTA_PROMPT an isize (32 bits on wasm32); the union is - // zeroed above. - dv.setInt32(at.scroll + S.value, value, true); - this.ex.ghostty_terminal_scroll_viewport(this.term, at.scroll); + this.ctx.setOption(this.L.opt.MODE, at.mode, 'MODE 12'); } // ========================================================================== @@ -1150,7 +924,7 @@ export class OfficialTerminal implements GhosttyTerminal { returnsBool ? [I32] : [] ); this.callbacks.push(installed); - this.setOption(option, installed.index, what); + this.ctx.setOption(option, installed.index, what); }; // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. @@ -1161,7 +935,7 @@ export class OfficialTerminal implements GhosttyTerminal { L.opt.WRITE_PTY, 'WRITE_PTY', (_t, _u, ptr, len) => { - const reply = this.u8().slice(ptr, ptr + len); + const reply = this.ctx.u8().slice(ptr, ptr + len); if (!isKittyKeyboardReply(reply)) this.responses.push(reply); return undefined; }, @@ -1195,9 +969,9 @@ export class OfficialTerminal implements GhosttyTerminal { 'SIZE', (_t, _u, out) => { const Z = L.sizeReport; - const dv = this.dv(); - dv.setUint16(out + Z.rows, this._rows, true); - dv.setUint16(out + Z.columns, this._cols, true); + const dv = this.ctx.dv(); + dv.setUint16(out + Z.rows, this.ctx.rows, true); + dv.setUint16(out + Z.columns, this.ctx.cols, true); dv.setUint32(out + Z.cellWidth, this.cellWidthPx, true); dv.setUint32(out + Z.cellHeight, this.cellHeightPx, true); return 1; @@ -1212,7 +986,7 @@ export class OfficialTerminal implements GhosttyTerminal { 'DEVICE_ATTRIBUTES', (_t, _u, out) => { const D = L.da; - const dv = this.dv(); + const dv = this.ctx.dv(); const primary = out + D.primary; const secondary = out + D.secondary; dv.setUint16(primary + D.conformance, 62, true); @@ -1252,7 +1026,7 @@ export class OfficialTerminal implements GhosttyTerminal { (_t, _u, notification) => { const N = this.L.notification; // A sized struct: read only the fields its size covers. - if (this.dv().getUint32(notification + N.size, true) < N.bodyEnd) return undefined; + if (this.ctx.dv().getUint32(notification + N.size, true) < N.bodyEnd) return undefined; const text = (ptr: number) => new TextDecoder().decode(this.stringAt(ptr)); this.pendingNotifications.push({ title: text(notification + N.title), @@ -1277,7 +1051,7 @@ export class OfficialTerminal implements GhosttyTerminal { */ private clipboardWrite(write: number): void { const C = this.L.clipboardWrite; - const dv = this.dv(); + const dv = this.ctx.dv(); let result = C.UNSUPPORTED; if (dv.getInt32(write + C.location, true) === C.STANDARD) { const contents = dv.getUint32(write + C.contents, true); @@ -1296,7 +1070,7 @@ export class OfficialTerminal implements GhosttyTerminal { } } const reply = this.at.clipReply; - const out = this.dv(); + const out = this.ctx.dv(); new Uint8Array(this.ex.memory.buffer, reply, C.replySize).fill(0); out.setUint32(reply + C.replySizeField, C.replySize, true); out.setInt32(reply + C.replyResult, result, true); @@ -1310,10 +1084,10 @@ export class OfficialTerminal implements GhosttyTerminal { /** A copy of the bytes a `GhosttyString` at `ptr` points to. */ private stringAt(ptr: number): Uint8Array { const S = this.L.string; - const dv = this.dv(); + const dv = this.ctx.dv(); const data = dv.getUint32(ptr + S.ptr, true); const len = dv.getUint32(ptr + S.len, true); - return len === 0 ? new Uint8Array(0) : this.u8().slice(data, data + len); + return len === 0 ? new Uint8Array(0) : this.ctx.u8().slice(data, data + len); } private applyConfig(config: GhosttyTerminalConfig | undefined): void { @@ -1324,23 +1098,23 @@ export class OfficialTerminal implements GhosttyTerminal { // first applies. 0 bytes would disable scrollback and a NULL byte limit would // remove the ceiling, so neither is ever passed; NULL lines removes the line limit. const lines = config?.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES; - this.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); - this.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); - if (lines > 0) this.dv().setUint32(at.u32, lines, true); - this.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); + this.ctx.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); + this.ctx.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + if (lines > 0) this.ctx.dv().setUint32(at.u32, lines, true); + this.ctx.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); // Grapheme clustering (mode 2027) on by default, so RIS keeps it. - const dv = this.dv(); + const dv = this.ctx.dv(); dv.setUint16(at.mode + L.mode.mode, 2027, true); dv.setUint8(at.mode + L.mode.value, 1); - this.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); + this.ctx.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); // The configured cursor, which DECSCUSR Ps 0 and RIS restore (ADR 049). this.setDefaultCursor(config?.cursorStyle ?? 'block', config?.cursorBlink ?? false); const setColor = (option: number, value: number, what: string) => { this.writeRgb(at.u32, value); - this.setOption(option, at.u32, what); + this.ctx.setOption(option, at.u32, what); }; // Foreground and background are always both set: Ghostty's render state copies // the default colours only when both are set (render.zig `bg_fg`), otherwise it @@ -1367,414 +1141,14 @@ export class OfficialTerminal implements GhosttyTerminal { if (entries.length > 0) { // Unset entries and 16-255 keep Ghostty's default palette. const rc = this.ex.ghostty_terminal_get( - this.term, + this.ctx.term, L.tdata.COLOR_PALETTE_DEFAULT, at.palette ); if (rc !== 0) throw new Error(`libghostty-vt COLOR_PALETTE_DEFAULT failed: ${rc}`); for (const { value, i } of entries) this.writeRgb(at.palette + i * L.colors.rgbSize, value); - this.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); - } - } - - private allocView(): void { - const total = this._cols * this._rows; - while (this.viewCells.length < total) this.viewCells.push(blankCell()); - this.viewCells.length = total; - this.viewGraphemes = new Array(this._rows).fill(null); - } - - // ========================================================================== - // Private: refresh - // ========================================================================== - - /** Cursor, scrollback rows and active screen, read once per generation. */ - private info(): void { - if (this.infoGen === this.generation) return; - const at = this.at; - const rc = this.ex.ghostty_terminal_get_multi(this.term, 4, at.tKeys, at.tVals, 0); - if (rc !== 0) throw new Error(`libghostty-vt terminal_get_multi failed: ${rc}`); - const dv = this.dv(); - this.cursorX = dv.getUint16(at.curX, true); - this.cursorY = dv.getUint16(at.curY, true); - this.sbRows = dv.getUint32(at.sbRows, true); - this.altScreen = dv.getInt32(at.screen, true) === this.L.screenAlternate; - this.infoGen = this.generation; - } - - /** - * Brings the active-screen copy up to date with the terminal, once per generation. Each - * refresh decodes only the rows Ghostty reports dirty (every row after a resize or - * colour change), adds them to the dirty set, and ends with `render_state_clean`, - * which clears Ghostty's global and per-row dirty state. The JS dirty state - * accumulates until `markClean()`. - */ - private refresh(): void { - if (this.refreshedGen === this.generation) return; - this.info(); - const ex = this.ex; - const L = this.L; - const at = this.at; - - let rc = ex.ghostty_render_state_update(this.rs, this.term); - if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); - let dv = this.dv(); - dv.setUint32(at.cursor + L.cursor.sizeField, L.cursor.size, true); - dv.setUint32(at.colors + L.colors.sizeField, L.colors.size, true); - rc = ex.ghostty_render_state_get_multi(this.rs, 3, at.rsKeys, at.rsVals, 0); - if (rc !== 0) throw new Error(`libghostty-vt render_state_get_multi failed: ${rc}`); - // Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no - // row dirty when only the defaults change (OSC 10/11/110/111, DECSCNM): its own - // renderer applies them globally. Redecode every row when any colour changed. - const colorsChanged = this.readCursorAndColors(); - dv = this.dv(); - const dirty = dv.getInt32(at.dirty, true); - rc = ex.ghostty_render_state_get(this.rs, L.rs.ROW_ITERATOR, at.iterH); - if (rc !== 0) throw new Error(`libghostty-vt ROW_ITERATOR failed: ${rc}`); - - const all = this.decodeAll || colorsChanged; - const cols = this._cols; - while ( - all - ? ex.ghostty_render_state_row_iterator_next(this.iter) - : ex.ghostty_render_state_row_iterator_next_dirty(this.iter, at.y) - ) { - const len = this.readRowRaw(); - const y = this.dv().getInt32(at.viewY, true); - if (y < 0 || y >= this._rows) continue; - this.viewGraphemes[y] = this.decodeRow(this.viewCells, y * cols, len); - this.dirtyRows.add(y); - } - ex.ghostty_render_state_clean(this.rs); - - const level = all - ? DirtyState.FULL - : dirty === L.dirty.FULL - ? DirtyState.FULL - : dirty === L.dirty.PARTIAL - ? DirtyState.PARTIAL - : DirtyState.NONE; - if (level > this.dirtyMax) this.dirtyMax = level; - this.decodeAll = false; - this.refreshedGen = this.generation; - } - - /** Returns whether the default colours or the palette changed since the last read. */ - private readCursorAndColors(): boolean { - const L = this.L; - const at = this.at; - const u8 = this.u8(); - const dv = this.dv(); - const C = L.cursor; - this.cursorVisible = u8[at.cursor + C.visible] !== 0; - this.cursorBlinking = u8[at.cursor + C.blinking] !== 0; - const style = dv.getInt32(at.cursor + C.visualStyle, true); - this.cursorStyle = - style === L.cursorStyle.BAR ? 'bar' : style === L.cursorStyle.UNDERLINE ? 'underline' : 'block'; - const K = L.colors; - const defBg = this.rgbAt(at.colors + K.background); - const defFg = this.rgbAt(at.colors + K.foreground); - let changed = defBg !== this.defBg || defFg !== this.defFg; - this.defBg = defBg; - this.defFg = defFg; - const pal = at.colors + K.palette; - const out = this.palette; - for (let i = 0; i < 256; i++) { - const p = pal + i * K.rgbSize; - const r = u8[p + K.r]; - const g = u8[p + K.g]; - const b = u8[p + K.b]; - if (out[i * 3] !== r || out[i * 3 + 1] !== g || out[i * 3 + 2] !== b) { - changed = true; - out[i * 3] = r; - out[i * 3 + 1] = g; - out[i * 3 + 2] = b; - } + this.ctx.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); } - return changed; - } - - /** - * Reads the iterator's current row (CELLS_RAW, VIEWPORT_Y, and binds the row-cells - * handle) and copies the raw cells into the JS-owned `raw` array at once. - */ - private readRowRaw(): number { - const at = this.at; - const L = this.L; - const rc = this.ex.ghostty_render_state_row_get_multi(this.iter, 3, at.rowKeys, at.rowVals, 0); - if (rc !== 0) throw new Error(`libghostty-vt render_state_row_get_multi failed: ${rc}`); - const dv = this.dv(); - const ptr = dv.getUint32(at.view + L.cellsView.ptr, true); - const len = dv.getUint32(at.view + L.cellsView.len, true); - const words = (len * L.cellSize) / 4; - if (this.raw.length < words) this.raw = new Uint32Array(words); - if (ptr && words) this.raw.set(new Uint32Array(this.ex.memory.buffer, ptr, words)); - return len; - } - - /** - * Decodes the copied raw row into `out[base..base+cols)`. Styles come from a - * per-row cache (`row_cells_get` STYLE only for an id not seen in the row), and - * grapheme codepoints are read only for cells tagged as graphemes. - */ - private decodeRow(out: GhosttyCell[], base: number, len: number): Map<number, string> | null { - const L = this.L; - const B = L.cell; - // Hoisted: this loop runs once per cell of every decoded row. - const tagLsb = B.tag.lsb; - const tagMask = B.tag.mask; - const contentLsb = B.content.lsb; - const contentMask = B.content.mask; - const styleLsb = B.style.lsb; - const styleMask = B.style.mask; - const wideLsb = B.wide.lsb; - const wideMask = B.wide.mask; - const linkLsb = B.hyperlink.lsb; - const linkMask = B.hyperlink.mask; - const cpLsb = B.codepoint.lsb; - const cpMask = B.codepoint.mask; - const TAG_CP = L.tag.CODEPOINT; - const TAG_GRAPHEME = L.tag.CODEPOINT_GRAPHEME; - const TAG_BG_PALETTE = L.tag.BG_COLOR_PALETTE; - const TAG_BG_RGB = L.tag.BG_COLOR_RGB; - const WIDE = L.wide.WIDE; - const NARROW = L.wide.NARROW; - const defFg = this.defFg; - const defBg = this.defBg; - const raw = this.raw; - const pal = this.palette; - const words = L.cellSize / 4; - const styles = this.rowStyles; - styles.clear(); - let graphemes: Map<number, string> | null = null; - const n = Math.min(len, this._cols); - for (let x = 0; x < n; x++) { - const lo = raw[x * words]; - const hi = raw[x * words + 1]; - const tag = readBits(lo, hi, tagLsb, tagMask); - const content = readBits(lo, hi, contentLsb, contentMask); - const styleId = readBits(lo, hi, styleLsb, styleMask); - const wide = readBits(lo, hi, wideLsb, wideMask); - const cell = out[base + x]; - let fg = defFg; - let bg = defBg; - let flags = 0; - let codepoint = 0; - let bgFromContent = false; - if (tag === TAG_CP || tag === TAG_GRAPHEME) { - codepoint = (content >>> cpLsb) & cpMask; - } else if (tag === TAG_BG_PALETTE) { - const i = readBits(content, 0, B.paletteIndex.lsb, B.paletteIndex.mask) * 3; - bg = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; - bgFromContent = true; - } else if (tag === TAG_BG_RGB) { - bg = - (readBits(content, 0, B.r.lsb, B.r.mask) << 16) | - (readBits(content, 0, B.g.lsb, B.g.mask) << 8) | - readBits(content, 0, B.b.lsb, B.b.mask); - bgFromContent = true; - } - if (styleId !== 0) { - let style = styles.get(styleId); - if (!style) { - style = this.readStyle(x); - styles.set(styleId, style); - } - if (style.fg >= 0) fg = style.fg; - if (style.bg >= 0 && !bgFromContent) bg = style.bg; - flags = style.flags; - } - cell.codepoint = codepoint; - cell.fg_r = (fg >>> 16) & 0xff; - cell.fg_g = (fg >>> 8) & 0xff; - cell.fg_b = fg & 0xff; - cell.bg_r = (bg >>> 16) & 0xff; - cell.bg_g = (bg >>> 8) & 0xff; - cell.bg_b = bg & 0xff; - cell.flags = flags; - cell.width = wide === WIDE ? 2 : wide === NARROW ? 1 : 0; - cell.hyperlink_id = readBits(lo, hi, linkLsb, linkMask); - cell.grapheme_len = 0; - if (tag === TAG_GRAPHEME) { - const cps = this.readGrapheme(x); - if (cps.length > 1) { - cell.grapheme_len = cps.length - 1; - (graphemes ??= new Map()).set(x, String.fromCodePoint(...cps)); - } - } - } - for (let x = n; x < this._cols; x++) { - const cell = out[base + x]; - cell.codepoint = 0; - setRgb(cell, defFg, defBg); - cell.flags = 0; - cell.width = 1; - cell.hyperlink_id = 0; - cell.grapheme_len = 0; - } - return graphemes; - } - - private readStyle(x: number): DecodedStyle { - const L = this.L; - const S = L.style; - const C = L.styleColor; - const p = this.at.style; - const ex = this.ex; - ex.ghostty_render_state_row_cells_select(this.rowCells, x); - new DataView(ex.memory.buffer).setUint32(p + S.sizeField, S.size, true); - if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.STYLE, p) !== 0) { - return { fg: -1, bg: -1, flags: 0 }; - } - const dv = new DataView(ex.memory.buffer); - const pal = this.palette; - let fg = -1; - let bg = -1; - for (let side = 0; side < 2; side++) { - const at = p + (side === 0 ? S.fg : S.bg); - const tag = dv.getInt32(at + C.tag, true); - const v = at + C.value; - let rgb = -1; - if (tag === C.PALETTE) { - const i = dv.getUint8(v + C.palette) * 3; - rgb = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; - } else if (tag === C.RGB) { - const c = v + C.rgb; - const K = L.colors; - rgb = (dv.getUint8(c + K.r) << 16) | (dv.getUint8(c + K.g) << 8) | dv.getUint8(c + K.b); - } - if (side === 0) fg = rgb; - else bg = rgb; - } - const flags = - (dv.getUint8(p + S.bold) ? CellFlags.BOLD : 0) | - (dv.getUint8(p + S.italic) ? CellFlags.ITALIC : 0) | - (dv.getInt32(p + S.underline, true) ? CellFlags.UNDERLINE : 0) | - (dv.getUint8(p + S.strikethrough) ? CellFlags.STRIKETHROUGH : 0) | - (dv.getUint8(p + S.inverse) ? CellFlags.INVERSE : 0) | - (dv.getUint8(p + S.invisible) ? CellFlags.INVISIBLE : 0) | - (dv.getUint8(p + S.blink) ? CellFlags.BLINK : 0) | - (dv.getUint8(p + S.faint) ? CellFlags.FAINT : 0); - return { fg, bg, flags }; - } - - private readGrapheme(x: number): number[] { - const L = this.L; - const at = this.at; - const ex = this.ex; - ex.ghostty_render_state_row_cells_select(this.rowCells, x); - if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_LEN, at.u32)) { - return []; - } - const n = this.dv().getUint32(at.u32, true); - if (n === 0) return []; - const big = n > GRAPHEME_SCRATCH; - const buf = big ? ex.ghostty_wasm_alloc(n * 4) : at.gBuf; - try { - if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_BUF, buf)) { - return []; - } - const dv = this.dv(); - const cps: number[] = []; - for (let i = 0; i < n; i++) cps.push(dv.getUint32(buf + i * 4, true)); - return cps; - } finally { - if (big) ex.ghostty_wasm_free(buf, n * 4); - } - } - - // ========================================================================== - // Private: rows by absolute index (0 = oldest scrollback row) - // ========================================================================== - - /** A row by absolute index; call `refresh()` first. */ - private rowAt(abs: number): RowRef { - if (abs >= this.sbRows) { - const y = abs - this.sbRows; - return { cells: this.viewCells, base: y * this._cols, graphemes: this.viewGraphemes[y] }; - } - return this.readPage(abs); - } - - /** - * Reads the page of `rows` rows holding scrollback row `abs` through a second - * render state: moves Ghostty's viewport there, reads every row with CELLS_RAW, - * and moves it back to the bottom. The primary state has already been refreshed - * at this generation (callers refresh first), so it has captured every pending - * dirty bit before the second state's update consumes the terminal's dirty state. - * The second state cannot trust dirty bits the primary has cleared, so it is - * recreated whenever the content has changed, and every miss rebuilds every row - * it reads. Scrolling changes no row, so the state and the pages are kept. - */ - private readPage(abs: number): RowRef { - const rows = this._rows; - // abs < S, so the page starts in scrollback: scroll_viewport ROW n snaps to the - // active screen only for n >= S. A page may run on into active rows; rowAt - // serves those from the primary copy. - const start = Math.floor(abs / rows) * rows; - if (this.pageGen !== this.generation) { - if (this.pageRs) this.ex.ghostty_render_state_free(this.pageRs); - this.pageRs = this.vt.newHandle('render_state_new', (slot) => - this.ex.ghostty_render_state_new(0, slot) - ); - this.pages.clear(); - this.pageGen = this.generation; - } - let page = this.pages.get(start); - if (page) { - this.pages.delete(start); - this.pages.set(start, page); - return page[abs - start]; - } - - const ex = this.ex; - const L = this.L; - const at = this.at; - this.scrollViewport(L.scroll.ROW, start); - try { - const rc = ex.ghostty_render_state_update(this.pageRs, this.term); - if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); - if (ex.ghostty_render_state_get(this.pageRs, L.rs.ROW_ITERATOR, at.iterH) !== 0) { - throw new Error('libghostty-vt ROW_ITERATOR failed'); - } - page = []; - while (ex.ghostty_render_state_row_iterator_next(this.iter)) { - const len = this.readRowRaw(); - const y = this.dv().getInt32(at.viewY, true); - if (y < 0 || y >= rows) continue; - const cells: GhosttyCell[] = []; - for (let x = 0; x < this._cols; x++) cells.push(blankCell()); - page[y] = { cells, base: 0, graphemes: this.decodeRow(cells, 0, len) }; - } - } finally { - this.scrollViewport(L.scroll.BOTTOM, 0); - } - for (let y = 0; y < rows; y++) { - if (!page[y]) { - const cells: GhosttyCell[] = []; - for (let x = 0; x < this._cols; x++) cells.push(blankCell()); - page[y] = { cells, base: 0, graphemes: null }; - } - } - this.pages.set(start, page); - if (this.pages.size > PAGE_CACHE_SIZE) { - const oldest = this.pages.keys().next().value; - if (oldest !== undefined) this.pages.delete(oldest); - } - return page[abs - start]; - } - - private copyRow(ref: RowRef): GhosttyCell[] { - const out: GhosttyCell[] = new Array(this._cols); - for (let x = 0; x < this._cols; x++) out[x] = { ...ref.cells[ref.base + x] }; - return out; - } - - private graphemeOf(ref: RowRef, col: number): string { - const g = ref.graphemes?.get(col); - if (g !== undefined) return g; - const cp = ref.cells[ref.base + col].codepoint; - return cp ? String.fromCodePoint(cp) : ' '; } // ========================================================================== @@ -1786,14 +1160,14 @@ export class OfficialTerminal implements GhosttyTerminal { const L = this.L; const at = this.at; const P = L.point; - this.u8().fill(0, at.point, at.point + P.size); - const dv = this.dv(); + this.ctx.u8().fill(0, at.point, at.point + P.size); + const dv = this.ctx.dv(); dv.setInt32(at.point + P.tag, tag, true); dv.setUint16(at.point + P.value + P.x, x, true); dv.setUint32(at.point + P.value + P.y, y, true); - this.u8().fill(0, out, out + L.gridRef.size); + this.ctx.u8().fill(0, out, out + L.gridRef.size); dv.setUint32(out + L.gridRef.sizeField, L.gridRef.size, true); - return this.ex.ghostty_terminal_grid_ref(this.term, at.point, out) === 0; + return this.ex.ghostty_terminal_grid_ref(this.ctx.term, at.point, out) === 0; } // ========================================================================== @@ -1801,7 +1175,7 @@ export class OfficialTerminal implements GhosttyTerminal { // ========================================================================== private screenRef(cell: ScreenCell, out: number): boolean { - if (cell.col < 0 || cell.col >= this._cols || cell.row < 0) return false; + if (cell.col < 0 || cell.col >= this.ctx.cols || cell.row < 0) return false; return this.gridRef(this.L.point.SCREEN, cell.col, cell.row, out); } @@ -1809,17 +1183,17 @@ export class OfficialTerminal implements GhosttyTerminal { private screenCellOf(ref: number): ScreenCell | null { const at = this.at; const P = this.L.point; - const rc = this.ex.ghostty_terminal_point_from_grid_ref(this.term, ref, P.SCREEN, at.coord); + const rc = this.ex.ghostty_terminal_point_from_grid_ref(this.ctx.term, ref, P.SCREEN, at.coord); if (rc !== 0) return null; - const dv = this.dv(); + const dv = this.ctx.dv(); return { col: dv.getUint16(at.coord + P.x, true), row: dv.getUint32(at.coord + P.y, true) }; } /** Zeroes the GhosttySelection at `at.sel` and sets its size field. */ private clearSelectionStruct(): void { const S = this.L.selection; - this.u8().fill(0, this.at.sel, this.at.sel + S.size); - this.dv().setUint32(this.at.sel + S.sizeField, S.size, true); + this.ctx.u8().fill(0, this.at.sel, this.at.sel + S.size); + this.ctx.dv().setUint32(this.at.sel + S.sizeField, S.size, true); } /** @@ -1833,9 +1207,9 @@ export class OfficialTerminal implements GhosttyTerminal { ): ScreenRange | null { const at = this.at; const S = this.L.selection; - this.info(); - this.u8().fill(0, at.selOpts, at.selOpts + options.size); - this.dv().setUint32(at.selOpts + options.sizeField, options.size, true); + this.ctx.info(); + this.ctx.u8().fill(0, at.selOpts, at.selOpts + options.size); + this.ctx.dv().setUint32(at.selOpts + options.sizeField, options.size, true); if (!this.screenRef(cell, at.selOpts + options.ref)) return null; this.clearSelectionStruct(); if (call(at.selOpts, at.sel) !== 0) return null; @@ -1845,7 +1219,7 @@ export class OfficialTerminal implements GhosttyTerminal { } private hyperlinkAt(tag: number, x: number, y: number): string | null { - if (x < 0 || x >= this._cols || y < 0) return null; + if (x < 0 || x >= this.ctx.cols || y < 0) return null; const ex = this.ex; const at = this.at; if (!this.gridRef(tag, x, y)) return null; @@ -1857,13 +1231,13 @@ export class OfficialTerminal implements GhosttyTerminal { const buf = ex.ghostty_wasm_alloc(size); try { const rc = ex.ghostty_grid_ref_hyperlink_uri(at.gridRef, buf, size, at.u32); - const n = this.dv().getUint32(at.u32, true); + const n = this.ctx.dv().getUint32(at.u32, true); if (rc === this.L.result.OUT_OF_SPACE) { cap = n; continue; } if (rc !== 0 || n === 0) return null; - return new TextDecoder().decode(this.u8().slice(buf, buf + n)); + return new TextDecoder().decode(this.ctx.u8().slice(buf, buf + n)); } finally { ex.ghostty_wasm_free(buf, size); } @@ -1875,10 +1249,10 @@ export class OfficialTerminal implements GhosttyTerminal { this.titleChanged = false; const at = this.at; const S = this.L.string; - if (this.ex.ghostty_terminal_get(this.term, this.L.tdata.TITLE, at.str) !== 0) return; - const dv = this.dv(); + if (this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.TITLE, at.str) !== 0) return; + const dv = this.ctx.dv(); const ptr = dv.getUint32(at.str + S.ptr, true); const len = dv.getUint32(at.str + S.len, true); - this.pendingTitle = len ? new TextDecoder().decode(this.u8().slice(ptr, ptr + len)) : ''; + this.pendingTitle = len ? new TextDecoder().decode(this.ctx.u8().slice(ptr, ptr + len)) : ''; } } diff --git a/src/client/vendor/ghostty-web/engine/render-state.ts b/src/client/vendor/ghostty-web/engine/render-state.ts new file mode 100644 index 0000000..688a274 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/render-state.ts @@ -0,0 +1,492 @@ +// webtty (ADR 042, ADR 043, ADR 061), not from upstream. +// The render-state binding (object, the per-cell decode hot path): keeps a JS copy of the +// active screen up to date through Ghostty's render state, once per generation, and +// decodes rows of any render state into JS-owned cells. Kept as one unit (ADR 061). + +import { CellFlags, DirtyState, type GhosttyCell, type RenderStateCursor } from './cell-types'; +import type { EngineContext } from './engine-context'; +import type { OfficialLayout } from './official-layout'; +import { blankCell, blankCells, type RowRef, setRgb } from './row-ref'; +import { readBits } from './vt-layout'; + +interface DecodedStyle { + /** 0xRRGGBB, or -1 for the default. */ + fg: number; + bg: number; + flags: number; +} + +/** Grapheme codepoints read through the scratch buffer; longer clusters allocate. */ +const GRAPHEME_SCRATCH = 32; + +/** The scratch slots the render state declares on the shared context. */ +export function renderStateSlots(L: OfficialLayout) { + return { + iterH: [4, 4], + cellsH: [4, 4], + y: [4, 4], + dirty: [4, 4], + viewY: [4, 4], + view: [L.cellsView.size, 8], + cursor: [L.cursor.size, 8], + colors: [L.colors.size, 8], + style: [L.style.size, 8], + rsKeys: [12, 4], + rsVals: [12, 4], + rowKeys: [12, 4], + rowVals: [12, 4], + gBuf: [GRAPHEME_SCRATCH * 4, 8], + } as const; +} + +/** The names of the render state's scratch slots. */ +export type RenderStateSlot = keyof ReturnType<typeof renderStateSlots>; + +/** + * The active screen through Ghostty's render state (ADR 042, ADR 043). + * + * A refresh runs `render_state_update`, visits the dirty rows with + * `row_iterator_next_dirty`, and decodes each row's `CELLS_RAW` into JS-owned cells + * indexed by active row. Refreshes happen only when the generation (bumped by writes, + * resizes and pixel-size changes) has changed, so the many `update()` calls of one + * frame decode each row once. Ghostty's viewport stays at the bottom, so this copy is + * the active screen; `decodePage` reads the rows of another render state, for the + * scrollback pages. + */ +export class RenderState { + private readonly ctx: EngineContext<RenderStateSlot>; + private rs: number; + private iter: number; + private rowCells: number; + + // Refresh state. The JS dirty state accumulates until `markClean()`. + private refreshedGen = 0; + private decodeAll = true; + private readonly dirtyRows = new Set<number>(); + private dirtyMax: DirtyState = DirtyState.NONE; + + // The JS copy of the active screen, indexed by active row. + private viewCells: GhosttyCell[] = []; + private viewGraphemes: (Map<number, string> | null)[] = []; + private raw = new Uint32Array(0); + private readonly rowStyles = new Map<number, DecodedStyle>(); + + // Render-state cursor and colours from the last refresh. + private cursorVisible = true; + private cursorBlinking = false; + private cursorStyle: RenderStateCursor['style'] = 'block'; + private defFg = 0xffffff; + private defBg = 0; + private readonly palette = new Uint8Array(256 * 3); + + constructor(ctx: EngineContext<RenderStateSlot>) { + this.ctx = ctx; + const { vt, ex, L, at } = ctx; + this.rs = vt.newHandle('render_state_new', (slot) => ex.ghostty_render_state_new(0, slot)); + this.iter = vt.newHandle('row_iterator_new', (slot) => + ex.ghostty_render_state_row_iterator_new(0, slot) + ); + this.rowCells = vt.newHandle('row_cells_new', (slot) => + ex.ghostty_render_state_row_cells_new(0, slot) + ); + // Fixed scratch contents: handle slots and the key/value arrays of the multi-gets. + const dv = ctx.dv(); + dv.setUint32(at.iterH, this.iter, true); + dv.setUint32(at.cellsH, this.rowCells, true); + ctx.setU32s(at.rsKeys, [L.rs.DIRTY, L.rs.CURSOR, L.rs.COLORS]); + ctx.setU32s(at.rsVals, [at.dirty, at.cursor, at.colors]); + ctx.setU32s(at.rowKeys, [L.row.CELLS_RAW, L.row.VIEWPORT_Y, L.row.CELLS]); + ctx.setU32s(at.rowVals, [at.view, at.viewY, at.cellsH]); + this.allocView(); + } + + /** Refreshes and returns the dirt accumulated since `markClean()`. */ + update(): DirtyState { + this.refresh(); + return this.dirtyMax; + } + + /** The cursor as of the latest content. */ + getCursor(): RenderStateCursor { + this.refresh(); + const ctx = this.ctx; + return { + x: ctx.cursorX, + y: ctx.cursorY, + viewportX: ctx.cursorX, + viewportY: ctx.cursorY, + visible: this.cursorVisible, + blinking: this.cursorBlinking, + style: this.cursorStyle, + }; + } + + /** Whether active row `y` changed since `markClean()`. */ + isRowDirty(y: number): boolean { + this.refresh(); + return this.dirtyMax === DirtyState.FULL || this.dirtyRows.has(y); + } + + /** Forgets the accumulated dirt, once the renderer has drawn it. */ + markClean(): void { + this.dirtyRows.clear(); + this.dirtyMax = DirtyState.NONE; + } + + /** Makes the next frame repaint every row (the view came back to the bottom). */ + markAllDirty(): void { + this.dirtyMax = DirtyState.FULL; + } + + /** The active screen's cells, row-major; the array is the live copy, not a snapshot. */ + getViewport(): GhosttyCell[] { + this.refresh(); + return this.viewCells; + } + + /** Active row `y` of the copy; call `refresh()` first. */ + viewRow(y: number): RowRef { + const cols = this.ctx.cols; + return { cells: this.viewCells, base: y * cols, graphemes: this.viewGraphemes[y] }; + } + + /** After a resize: size the copy to the new grid and decode every row on the next refresh. */ + resized(): void { + this.decodeAll = true; + this.allocView(); + } + + /** + * Brings the active-screen copy up to date with the terminal, once per generation. Each + * refresh decodes only the rows Ghostty reports dirty (every row after a resize or + * colour change), adds them to the dirty set, and ends with `render_state_clean`, + * which clears Ghostty's global and per-row dirty state. The JS dirty state + * accumulates until `markClean()`. + */ + refresh(): void { + const ctx = this.ctx; + if (this.refreshedGen === ctx.generation) return; + ctx.info(); + const { ex, L, at } = ctx; + + let rc = ex.ghostty_render_state_update(this.rs, ctx.term); + if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); + let dv = ctx.dv(); + dv.setUint32(at.cursor + L.cursor.sizeField, L.cursor.size, true); + dv.setUint32(at.colors + L.colors.sizeField, L.colors.size, true); + rc = ex.ghostty_render_state_get_multi(this.rs, 3, at.rsKeys, at.rsVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt render_state_get_multi failed: ${rc}`); + // Decoded cells carry the default fg/bg and palette colours, but Ghostty marks no + // row dirty when only the defaults change (OSC 10/11/110/111, DECSCNM): its own + // renderer applies them globally. Redecode every row when any colour changed. + const colorsChanged = this.readCursorAndColors(); + dv = ctx.dv(); + const dirty = dv.getInt32(at.dirty, true); + rc = ex.ghostty_render_state_get(this.rs, L.rs.ROW_ITERATOR, at.iterH); + if (rc !== 0) throw new Error(`libghostty-vt ROW_ITERATOR failed: ${rc}`); + + const all = this.decodeAll || colorsChanged; + const cols = ctx.cols; + const rows = ctx.rows; + while ( + all + ? ex.ghostty_render_state_row_iterator_next(this.iter) + : ex.ghostty_render_state_row_iterator_next_dirty(this.iter, at.y) + ) { + const len = this.readRowRaw(); + const y = ctx.dv().getInt32(at.viewY, true); + if (y < 0 || y >= rows) continue; + this.viewGraphemes[y] = this.decodeRow(this.viewCells, y * cols, len); + this.dirtyRows.add(y); + } + ex.ghostty_render_state_clean(this.rs); + + const level = all + ? DirtyState.FULL + : dirty === L.dirty.FULL + ? DirtyState.FULL + : dirty === L.dirty.PARTIAL + ? DirtyState.PARTIAL + : DirtyState.NONE; + if (level > this.dirtyMax) this.dirtyMax = level; + this.decodeAll = false; + this.refreshedGen = ctx.generation; + } + + /** + * Decodes every row of render state `pageRs`, already updated for the rows wanted, into + * fresh rows indexed by viewport row; rows it does not report are blank. Uses the + * shared row iterator and the colours of the last `refresh()`, so call that first. + */ + decodePage(pageRs: number): RowRef[] { + const ctx = this.ctx; + const { ex, L, at } = ctx; + const rows = ctx.rows; + const cols = ctx.cols; + if (ex.ghostty_render_state_get(pageRs, L.rs.ROW_ITERATOR, at.iterH) !== 0) { + throw new Error('libghostty-vt ROW_ITERATOR failed'); + } + const page: RowRef[] = []; + while (ex.ghostty_render_state_row_iterator_next(this.iter)) { + const len = this.readRowRaw(); + const y = ctx.dv().getInt32(at.viewY, true); + if (y < 0 || y >= rows) continue; + const cells = blankCells(cols); + page[y] = { cells, base: 0, graphemes: this.decodeRow(cells, 0, len) }; + } + for (let y = 0; y < rows; y++) { + if (!page[y]) page[y] = { cells: blankCells(cols), base: 0, graphemes: null }; + } + return page; + } + + /** Frees the render state, its row iterator and row-cells handles. */ + free(): void { + const ex = this.ctx.ex; + ex.ghostty_render_state_row_cells_free(this.rowCells); + ex.ghostty_render_state_row_iterator_free(this.iter); + ex.ghostty_render_state_free(this.rs); + this.rowCells = 0; + this.iter = 0; + this.rs = 0; + } + + private allocView(): void { + const total = this.ctx.cols * this.ctx.rows; + while (this.viewCells.length < total) this.viewCells.push(blankCell()); + this.viewCells.length = total; + this.viewGraphemes = new Array(this.ctx.rows).fill(null); + } + + private rgbAt(ptr: number): number { + const u8 = this.ctx.u8(); + const C = this.ctx.L.colors; + return (u8[ptr + C.r] << 16) | (u8[ptr + C.g] << 8) | u8[ptr + C.b]; + } + + /** Returns whether the default colours or the palette changed since the last read. */ + private readCursorAndColors(): boolean { + const { L, at } = this.ctx; + const u8 = this.ctx.u8(); + const dv = this.ctx.dv(); + const C = L.cursor; + this.cursorVisible = u8[at.cursor + C.visible] !== 0; + this.cursorBlinking = u8[at.cursor + C.blinking] !== 0; + const style = dv.getInt32(at.cursor + C.visualStyle, true); + this.cursorStyle = + style === L.cursorStyle.BAR ? 'bar' : style === L.cursorStyle.UNDERLINE ? 'underline' : 'block'; + const K = L.colors; + const defBg = this.rgbAt(at.colors + K.background); + const defFg = this.rgbAt(at.colors + K.foreground); + let changed = defBg !== this.defBg || defFg !== this.defFg; + this.defBg = defBg; + this.defFg = defFg; + const pal = at.colors + K.palette; + const out = this.palette; + for (let i = 0; i < 256; i++) { + const p = pal + i * K.rgbSize; + const r = u8[p + K.r]; + const g = u8[p + K.g]; + const b = u8[p + K.b]; + if (out[i * 3] !== r || out[i * 3 + 1] !== g || out[i * 3 + 2] !== b) { + changed = true; + out[i * 3] = r; + out[i * 3 + 1] = g; + out[i * 3 + 2] = b; + } + } + return changed; + } + + /** + * Reads the iterator's current row (CELLS_RAW, VIEWPORT_Y, and binds the row-cells + * handle) and copies the raw cells into the JS-owned `raw` array at once. + */ + private readRowRaw(): number { + const { ex, L, at } = this.ctx; + const rc = ex.ghostty_render_state_row_get_multi(this.iter, 3, at.rowKeys, at.rowVals, 0); + if (rc !== 0) throw new Error(`libghostty-vt render_state_row_get_multi failed: ${rc}`); + const dv = this.ctx.dv(); + const ptr = dv.getUint32(at.view + L.cellsView.ptr, true); + const len = dv.getUint32(at.view + L.cellsView.len, true); + const words = (len * L.cellSize) / 4; + if (this.raw.length < words) this.raw = new Uint32Array(words); + if (ptr && words) this.raw.set(new Uint32Array(ex.memory.buffer, ptr, words)); + return len; + } + + /** + * Decodes the copied raw row into `out[base..base+cols)`. Styles come from a + * per-row cache (`row_cells_get` STYLE only for an id not seen in the row), and + * grapheme codepoints are read only for cells tagged as graphemes. + * + * Over the 60-line function budget on purpose: this loop runs once per cell of every + * decoded row, so its layout reads are hoisted into locals and the cell is written + * in place, with no per-cell call, closure or allocation (ADR 061). + */ + private decodeRow(out: GhosttyCell[], base: number, len: number): Map<number, string> | null { + const L = this.ctx.L; + const B = L.cell; + // Hoisted: this loop runs once per cell of every decoded row. + const tagLsb = B.tag.lsb; + const tagMask = B.tag.mask; + const contentLsb = B.content.lsb; + const contentMask = B.content.mask; + const styleLsb = B.style.lsb; + const styleMask = B.style.mask; + const wideLsb = B.wide.lsb; + const wideMask = B.wide.mask; + const linkLsb = B.hyperlink.lsb; + const linkMask = B.hyperlink.mask; + const cpLsb = B.codepoint.lsb; + const cpMask = B.codepoint.mask; + const TAG_CP = L.tag.CODEPOINT; + const TAG_GRAPHEME = L.tag.CODEPOINT_GRAPHEME; + const TAG_BG_PALETTE = L.tag.BG_COLOR_PALETTE; + const TAG_BG_RGB = L.tag.BG_COLOR_RGB; + const WIDE = L.wide.WIDE; + const NARROW = L.wide.NARROW; + const defFg = this.defFg; + const defBg = this.defBg; + const raw = this.raw; + const pal = this.palette; + const words = L.cellSize / 4; + const cols = this.ctx.cols; + const styles = this.rowStyles; + styles.clear(); + let graphemes: Map<number, string> | null = null; + const n = Math.min(len, cols); + for (let x = 0; x < n; x++) { + const lo = raw[x * words]; + const hi = raw[x * words + 1]; + const tag = readBits(lo, hi, tagLsb, tagMask); + const content = readBits(lo, hi, contentLsb, contentMask); + const styleId = readBits(lo, hi, styleLsb, styleMask); + const wide = readBits(lo, hi, wideLsb, wideMask); + const cell = out[base + x]; + let fg = defFg; + let bg = defBg; + let flags = 0; + let codepoint = 0; + let bgFromContent = false; + if (tag === TAG_CP || tag === TAG_GRAPHEME) { + codepoint = (content >>> cpLsb) & cpMask; + } else if (tag === TAG_BG_PALETTE) { + const i = readBits(content, 0, B.paletteIndex.lsb, B.paletteIndex.mask) * 3; + bg = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; + bgFromContent = true; + } else if (tag === TAG_BG_RGB) { + bg = + (readBits(content, 0, B.r.lsb, B.r.mask) << 16) | + (readBits(content, 0, B.g.lsb, B.g.mask) << 8) | + readBits(content, 0, B.b.lsb, B.b.mask); + bgFromContent = true; + } + if (styleId !== 0) { + let style = styles.get(styleId); + if (!style) { + style = this.readStyle(x); + styles.set(styleId, style); + } + if (style.fg >= 0) fg = style.fg; + if (style.bg >= 0 && !bgFromContent) bg = style.bg; + flags = style.flags; + } + cell.codepoint = codepoint; + cell.fg_r = (fg >>> 16) & 0xff; + cell.fg_g = (fg >>> 8) & 0xff; + cell.fg_b = fg & 0xff; + cell.bg_r = (bg >>> 16) & 0xff; + cell.bg_g = (bg >>> 8) & 0xff; + cell.bg_b = bg & 0xff; + cell.flags = flags; + cell.width = wide === WIDE ? 2 : wide === NARROW ? 1 : 0; + cell.hyperlink_id = readBits(lo, hi, linkLsb, linkMask); + cell.grapheme_len = 0; + if (tag === TAG_GRAPHEME) { + const cps = this.readGrapheme(x); + if (cps.length > 1) { + cell.grapheme_len = cps.length - 1; + (graphemes ??= new Map()).set(x, String.fromCodePoint(...cps)); + } + } + } + for (let x = n; x < cols; x++) { + const cell = out[base + x]; + cell.codepoint = 0; + setRgb(cell, defFg, defBg); + cell.flags = 0; + cell.width = 1; + cell.hyperlink_id = 0; + cell.grapheme_len = 0; + } + return graphemes; + } + + /** The style of cell `x` of the bound row, its colours resolved through the palette. */ + private readStyle(x: number): DecodedStyle { + const { ex, L } = this.ctx; + const S = L.style; + const C = L.styleColor; + const p = this.ctx.at.style; + ex.ghostty_render_state_row_cells_select(this.rowCells, x); + new DataView(ex.memory.buffer).setUint32(p + S.sizeField, S.size, true); + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.STYLE, p) !== 0) { + return { fg: -1, bg: -1, flags: 0 }; + } + const dv = new DataView(ex.memory.buffer); + const pal = this.palette; + let fg = -1; + let bg = -1; + for (let side = 0; side < 2; side++) { + const at = p + (side === 0 ? S.fg : S.bg); + const tag = dv.getInt32(at + C.tag, true); + const v = at + C.value; + let rgb = -1; + if (tag === C.PALETTE) { + const i = dv.getUint8(v + C.palette) * 3; + rgb = (pal[i] << 16) | (pal[i + 1] << 8) | pal[i + 2]; + } else if (tag === C.RGB) { + const c = v + C.rgb; + const K = L.colors; + rgb = (dv.getUint8(c + K.r) << 16) | (dv.getUint8(c + K.g) << 8) | dv.getUint8(c + K.b); + } + if (side === 0) fg = rgb; + else bg = rgb; + } + const flags = + (dv.getUint8(p + S.bold) ? CellFlags.BOLD : 0) | + (dv.getUint8(p + S.italic) ? CellFlags.ITALIC : 0) | + (dv.getInt32(p + S.underline, true) ? CellFlags.UNDERLINE : 0) | + (dv.getUint8(p + S.strikethrough) ? CellFlags.STRIKETHROUGH : 0) | + (dv.getUint8(p + S.inverse) ? CellFlags.INVERSE : 0) | + (dv.getUint8(p + S.invisible) ? CellFlags.INVISIBLE : 0) | + (dv.getUint8(p + S.blink) ? CellFlags.BLINK : 0) | + (dv.getUint8(p + S.faint) ? CellFlags.FAINT : 0); + return { fg, bg, flags }; + } + + /** The codepoints of grapheme cell `x` of the bound row; clusters over 32 allocate. */ + private readGrapheme(x: number): number[] { + const { ex, L, at } = this.ctx; + ex.ghostty_render_state_row_cells_select(this.rowCells, x); + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_LEN, at.u32)) { + return []; + } + const n = this.ctx.dv().getUint32(at.u32, true); + if (n === 0) return []; + const big = n > GRAPHEME_SCRATCH; + const buf = big ? ex.ghostty_wasm_alloc(n * 4) : at.gBuf; + try { + if (ex.ghostty_render_state_row_cells_get(this.rowCells, L.cellData.GRAPHEMES_BUF, buf)) { + return []; + } + const dv = this.ctx.dv(); + const cps: number[] = []; + for (let i = 0; i < n; i++) cps.push(dv.getUint32(buf + i * 4, true)); + return cps; + } finally { + if (big) ex.ghostty_wasm_free(buf, n * 4); + } + } +} diff --git a/src/client/vendor/ghostty-web/engine/row-ref.ts b/src/client/vendor/ghostty-web/engine/row-ref.ts new file mode 100644 index 0000000..fa92a4b --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/row-ref.ts @@ -0,0 +1,63 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// A decoded row and the pure helpers on it (util): the shape the render state and the +// scrollback pages hand out, blank cells, and the copies and grapheme strings read from it. + +import type { GhosttyCell } from './cell-types'; + +/** A decoded row: JS-owned cells and the strings of its multi-codepoint graphemes. */ +export interface RowRef { + cells: GhosttyCell[]; + /** Index of the row's first cell in `cells`. */ + base: number; + /** Column → grapheme string, for cells holding more than one codepoint. */ + graphemes: Map<number, string> | null; +} + +/** A fresh empty cell, the value every decoded cell starts from. */ +export function blankCell(): GhosttyCell { + return { + codepoint: 0, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; +} + +/** A row of `cols` fresh empty cells. */ +export function blankCells(cols: number): GhosttyCell[] { + const cells: GhosttyCell[] = []; + for (let x = 0; x < cols; x++) cells.push(blankCell()); + return cells; +} + +/** Writes 0xRRGGBB foreground and background colours into a cell's channels. */ +export function setRgb(cell: GhosttyCell, fg: number, bg: number): void { + cell.fg_r = (fg >>> 16) & 0xff; + cell.fg_g = (fg >>> 8) & 0xff; + cell.fg_b = fg & 0xff; + cell.bg_r = (bg >>> 16) & 0xff; + cell.bg_g = (bg >>> 8) & 0xff; + cell.bg_b = bg & 0xff; +} + +/** A copy of the row's first `cols` cells, so a caller cannot change the decoded copy. */ +export function copyRow(ref: RowRef, cols: number): GhosttyCell[] { + const out: GhosttyCell[] = new Array(cols); + for (let x = 0; x < cols; x++) out[x] = { ...ref.cells[ref.base + x] }; + return out; +} + +/** The text of one cell: its grapheme string, its codepoint, or a space when empty. */ +export function graphemeOf(ref: RowRef, col: number): string { + const g = ref.graphemes?.get(col); + if (g !== undefined) return g; + const cp = ref.cells[ref.base + col].codepoint; + return cp ? String.fromCodePoint(cp) : ' '; +} diff --git a/src/client/vendor/ghostty-web/engine/scrollback-pages.ts b/src/client/vendor/ghostty-web/engine/scrollback-pages.ts new file mode 100644 index 0000000..cbc9729 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/scrollback-pages.ts @@ -0,0 +1,92 @@ +// webtty (ADR 043, ADR 047, ADR 061), not from upstream. +// The scrollback page cache (object): rows above the active screen, read a page at a time +// through a second render state and kept until the content changes, so scrolling only +// records an offset and serves rows from the active copy and this cache. + +import type { EngineContext } from './engine-context'; +import type { RenderState } from './render-state'; +import type { RowRef } from './row-ref'; + +/** At most this many pages of scrollback rows stay cached per generation. */ +export const PAGE_CACHE_SIZE = 16; + +/** + * Rows by absolute index, 0 being the oldest scrollback row: active rows come from the + * render state's copy, scrollback rows from pages of one screen height, least recently + * used dropped first. + */ +export class ScrollbackPages { + private readonly ctx: EngineContext; + private readonly render: RenderState; + private pageRs = 0; + // Scrollback rows, keyed by the absolute row their page starts at. + private readonly pages = new Map<number, RowRef[]>(); + private pageGen = 0; + + constructor(ctx: EngineContext, render: RenderState) { + this.ctx = ctx; + this.render = render; + } + + /** A row by absolute index; call `render.refresh()` first. */ + rowAt(abs: number): RowRef { + const sbRows = this.ctx.sbRows; + if (abs >= sbRows) return this.render.viewRow(abs - sbRows); + return this.scrollbackRow(abs); + } + + /** + * Scrollback row `abs` (`abs` < the scrollback length), read with the page of `rows` + * rows holding it on a miss: moves Ghostty's viewport there, reads every row with + * CELLS_RAW, and moves it back to the bottom. The primary state has already been + * refreshed at this generation (callers refresh first), so it has captured every + * pending dirty bit before the second state's update consumes the terminal's dirty + * state. The second state cannot trust dirty bits the primary has cleared, so it is + * recreated whenever the content has changed, and every miss rebuilds every row it + * reads. Scrolling changes no row, so the state and the pages are kept. + */ + scrollbackRow(abs: number): RowRef { + const ctx = this.ctx; + const ex = ctx.ex; + // abs < S, so the page starts in scrollback: scroll_viewport ROW n snaps to the + // active screen only for n >= S. A page may run on into active rows; rowAt + // serves those from the primary copy. + const start = Math.floor(abs / ctx.rows) * ctx.rows; + if (this.pageGen !== ctx.generation) { + if (this.pageRs) ex.ghostty_render_state_free(this.pageRs); + this.pageRs = ctx.vt.newHandle('render_state_new', (slot) => + ex.ghostty_render_state_new(0, slot) + ); + this.pages.clear(); + this.pageGen = ctx.generation; + } + let page = this.pages.get(start); + if (page) { + this.pages.delete(start); + this.pages.set(start, page); + return page[abs - start]; + } + + ctx.scrollViewport(ctx.L.scroll.ROW, start); + try { + const rc = ex.ghostty_render_state_update(this.pageRs, ctx.term); + if (rc !== 0) throw new Error(`libghostty-vt render_state_update failed: ${rc}`); + page = this.render.decodePage(this.pageRs); + } finally { + ctx.scrollViewport(ctx.L.scroll.BOTTOM, 0); + } + this.pages.set(start, page); + if (this.pages.size > PAGE_CACHE_SIZE) { + const oldest = this.pages.keys().next().value; + if (oldest !== undefined) this.pages.delete(oldest); + } + return page[abs - start]; + } + + /** Frees the second render state; the cache is dropped with it. */ + free(): void { + if (this.pageRs) this.ctx.ex.ghostty_render_state_free(this.pageRs); + this.pageRs = 0; + this.pages.clear(); + } +} diff --git a/src/client/vendor/tests/engine/engine-context.test.ts b/src/client/vendor/tests/engine/engine-context.test.ts new file mode 100644 index 0000000..be6af12 --- /dev/null +++ b/src/client/vendor/tests/engine/engine-context.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { EngineContext } from '../../ghostty-web/engine/engine-context'; +import type { Ghostty } from '../../ghostty-web/ghostty'; +import { freeContext, writeVt } from '../support/engine-harness'; +import { countedGhostty, loadGhostty } from '../support/wasm'; + +// The shared engine state (ADR 061): its scratch slots, fresh memory views, the terminal +// info read once per generation, and the option and viewport calls. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +describe('EngineContext', () => { + test('creates the terminal and lays out its own slots and the bindings', () => { + const ctx = new EngineContext(vt, 12, 4, { extra: [16, 8] }); + expect(ctx.term).not.toBe(0); + expect([ctx.cols, ctx.rows, ctx.generation]).toEqual([12, 4, 1]); + for (const slot of ['tKeys', 'tVals', 'scroll', 'u32', 'u8', 'extra'] as const) { + expect(ctx.at[slot]).toBeGreaterThanOrEqual(ctx.scratch.base); + expect(ctx.at[slot]).toBeLessThan(ctx.scratch.base + ctx.scratch.len); + } + expect(ctx.at.extra % 8).toBe(0); + freeContext(ctx); + }); + + test('builds memory views on the current buffer at every call', () => { + const ctx = new EngineContext(vt, 10, 3, {}); + expect(ctx.u8().buffer).toBe(ctx.ex.memory.buffer); + expect(ctx.dv().buffer).toBe(ctx.ex.memory.buffer); + expect(ctx.u8()).not.toBe(ctx.u8()); + ctx.setU32s(ctx.at.u32, [7, 9]); + expect([ + ctx.dv().getUint32(ctx.at.u32, true), + ctx.dv().getUint32(ctx.at.u32 + 4, true), + ]).toEqual([7, 9]); + freeContext(ctx); + }); + + test('reads the cursor, scrollback rows and screen once per generation', async () => { + const { ghostty, calls } = await countedGhostty(['ghostty_terminal_get_multi']); + const ctx = new EngineContext(ghostty, 10, 3, {}); + writeVt(ctx, '1\r\n2\r\n3\r\n4\r\n5ab'); + ctx.info(); + ctx.info(); + expect(calls.ghostty_terminal_get_multi).toBe(1); + expect([ctx.cursorX, ctx.cursorY, ctx.sbRows, ctx.altScreen]).toEqual([3, 2, 2, false]); + writeVt(ctx, '\x1b[?1049h'); + ctx.info(); + expect(calls.ghostty_terminal_get_multi).toBe(2); + expect(ctx.altScreen).toBe(true); + freeContext(ctx); + }); + + test('info throws when Ghostty refuses the read', () => { + const ctx = new EngineContext(vt, 10, 3, {}); + const term = ctx.term; + ctx.term = 0; + ctx.generation++; + expect(() => ctx.info()).toThrow('libghostty-vt terminal_get_multi failed'); + ctx.term = term; + freeContext(ctx); + }); + + test('setOption names the option when Ghostty refuses it', () => { + const ctx = new EngineContext(vt, 10, 3, {}); + const term = ctx.term; + ctx.term = 0; + expect(() => ctx.setOption(ctx.L.opt.MODE, ctx.at.u32, 'MODE')).toThrow( + 'libghostty-vt terminal_set MODE failed', + ); + ctx.term = term; + freeContext(ctx); + }); + + test('scrollViewport moves the viewport, which a bottom scroll puts back', () => { + const ctx = new EngineContext(vt, 10, 2, { scrollbar: [vt.L.scrollbar.size, 8] }); + writeVt(ctx, '1\r\n2\r\n3\r\n4'); + const S = ctx.L.scroll; + const top = () => { + expect(ctx.ex.ghostty_terminal_get(ctx.term, ctx.L.tdata.SCROLLBAR, ctx.at.scrollbar)).toBe( + 0, + ); + return ctx.dv().getUint32(ctx.at.scrollbar + ctx.L.scrollbar.offset, true); + }; + expect(top()).toBe(2); + ctx.scrollViewport(S.ROW, 1); + expect(top()).toBe(1); + ctx.scrollViewport(S.BOTTOM, 0); + expect(top()).toBe(2); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/render-state.test.ts b/src/client/vendor/tests/engine/render-state.test.ts new file mode 100644 index 0000000..7abfd88 --- /dev/null +++ b/src/client/vendor/tests/engine/render-state.test.ts @@ -0,0 +1,271 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { CellFlags, DirtyState } from '../../ghostty-web/engine/cell-types'; +import { RenderState } from '../../ghostty-web/engine/render-state'; +import type { Ghostty, GhosttyTerminal, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { freeContext, renderContext, writeVt } from '../support/engine-harness'; +import { BG, BLUE, bgRgb, FG, RED, rgb, rowText, THEME } from '../support/terminal-text'; +import { countedGhostty, loadGhostty } from '../support/wasm'; + +// The render-state binding (ADR 042, ADR 061): cells decoded from Ghostty's render state, +// the dirty rows, and the once-per-generation refresh, through the facade and directly. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('render state: cells', () => { + test('reads 16-colour, 256-colour and truecolor foregrounds and backgrounds', () => { + const t = term(); + t.write( + '\x1b[31mA\x1b[0m\x1b[38;5;208mB\x1b[0m\x1b[38;2;10;20;30;48;2;200;100;50mC\x1b[0m' + + '\x1b[44mD\x1b[0mE\x1b[48;5;21m \x1b[0m', + ); + const line = t.getLine(0); + expect(line).not.toBeNull(); + const [a, b, c, d, e, f] = line ?? []; + expect(rgb(a)).toBe(RED); + expect(bgRgb(a)).toBe(BG); + expect(rgb(b)).toBe(0xff8700); + expect(rgb(c)).toBe(0x0a141e); + expect(bgRgb(c)).toBe(0xc86432); + expect(bgRgb(d)).toBe(BLUE); + expect(rgb(d)).toBe(FG); + expect(rgb(e)).toBe(FG); + expect(bgRgb(e)).toBe(BG); + // Spaces written with a 256-colour background. + expect(f.codepoint).toBe(0x20); + expect(bgRgb(f)).toBe(0x0000ff); + t.free(); + }); + + test('reads bold, italic, underline, strikethrough, inverse, faint, blink and invisible', () => { + const t = term(); + t.write( + '\x1b[1mA\x1b[0m\x1b[3mB\x1b[0m\x1b[4mC\x1b[0m\x1b[9mD\x1b[0m\x1b[7mE\x1b[0m' + + '\x1b[2mF\x1b[0m\x1b[5mG\x1b[0m\x1b[8mH\x1b[0m\x1b[1;3;4;7mI\x1b[0mJ', + ); + const flags = (t.getLine(0) ?? []).slice(0, 10).map((c) => c.flags); + expect(flags).toEqual([ + CellFlags.BOLD, + CellFlags.ITALIC, + CellFlags.UNDERLINE, + CellFlags.STRIKETHROUGH, + CellFlags.INVERSE, + CellFlags.FAINT, + CellFlags.BLINK, + CellFlags.INVISIBLE, + CellFlags.BOLD | CellFlags.ITALIC | CellFlags.UNDERLINE | CellFlags.INVERSE, + 0, + ]); + t.free(); + }); + + test('reads wide CJK, emoji, VS16 and combining marks as graphemes', () => { + const t = term(); + t.write('你好|😀|❤️|é|👨‍👩‍👧'); + const line = t.getLine(0) ?? []; + expect(line.slice(0, 5).map((c) => c.width)).toEqual([2, 0, 2, 0, 1]); + expect(String.fromCodePoint(line[0].codepoint)).toBe('你'); + expect(line[5].width).toBe(2); + expect(String.fromCodePoint(line[5].codepoint)).toBe('😀'); + // VS16 makes the heart wide (mode 2027 on by default). + expect(line[8].width).toBe(2); + expect(line[8].grapheme_len).toBe(1); + expect(t.getGraphemeString(0, 8)).toBe('❤️'); + expect(line[11].grapheme_len).toBe(1); + expect(t.getGraphemeString(0, 11)).toBe('é'); + expect([...t.getGraphemeString(0, 11)].map((c) => c.codePointAt(0))).toEqual([0x65, 0x301]); + expect(t.getGraphemeString(0, 13)).toBe('👨‍👩‍👧'); + expect(line[13].width).toBe(2); + expect(rowText(t, 0)).toBe('你好|😀|❤️|é|👨‍👩‍👧'); + t.free(); + }); +}); + +describe('render state: dirty rows and colours', () => { + test('keeps row dirt until markClean and only marks changed rows', () => { + const t = term(20, 5); + t.write('one\r\ntwo\r\nthree'); + t.update(); + t.markClean(); + expect(t.update()).toBe(DirtyState.NONE); + t.write('\x1b[2;1HTWO'); + expect(t.update()).toBe(DirtyState.PARTIAL); + expect(t.update()).toBe(DirtyState.PARTIAL); + expect(t.isRowDirty(1)).toBe(true); + expect(t.isRowDirty(0)).toBe(false); + expect(t.isRowDirty(3)).toBe(false); + expect(rowText(t, 1)).toBe('TWO'); + t.clearDirty(); + expect(t.isRowDirty(1)).toBe(false); + t.write('\x1b[?1049h'); + expect(t.needsFullRedraw()).toBe(true); + t.free(); + }); + + // Ghostty marks no row dirty when only the default colours change; the binding bakes + // them into every cell, so it must redecode every row itself. + const everyCell = (t: GhosttyTerminal, rows: number, f: (c: never) => number) => + Array.from({ length: rows }, (_, y) => (t.getLine(y) ?? []).map((c) => f(c as never))); + + test('recolours every row when OSC 10/11 change only the default colours', () => { + const t = term(10, 3); + t.write('a\r\nb\r\nc'); + t.update(); + t.markClean(); + t.write('\x1b]11;rgb:44/55/66\x1b\\'); + expect(t.update()).toBe(DirtyState.FULL); + for (let y = 0; y < 3; y++) expect(t.isRowDirty(y)).toBe(true); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(0x445566)); + t.markClean(); + t.write('\x1b]10;rgb:11/22/33\x1b\\'); + expect(t.update()).toBe(DirtyState.FULL); + expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(0x112233)); + t.write('\x1b[1;1HX'); + t.update(); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(0x445566)); + t.markClean(); + t.write('\x1b]111\x1b\\\x1b]110\x1b\\'); + t.update(); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(BG)); + expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(FG)); + t.free(); + }); + + test('recolours every row on DECSCNM (?5h / ?5l)', () => { + const t = term(10, 3); + t.write('a\r\nb\r\nc'); + t.update(); + t.markClean(); + t.write('\x1b[?5h'); + expect(t.update()).toBe(DirtyState.FULL); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(FG)); + t.markClean(); + t.write('\x1b[2;1HB'); + t.update(); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(FG)); + t.markClean(); + t.write('\x1b[?5l'); + expect(t.update()).toBe(DirtyState.FULL); + expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(BG)); + expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(FG)); + t.free(); + }); +}); + +describe('RenderState', () => { + test('refreshes once per generation, however many reads a frame makes', async () => { + const { ghostty, calls } = await countedGhostty(['ghostty_render_state_update']); + const ctx = renderContext(ghostty, 10, 3); + const render = new RenderState(ctx); + writeVt(ctx, 'abc'); + render.update(); + render.getCursor(); + render.isRowDirty(0); + render.getViewport(); + expect(calls.ghostty_render_state_update).toBe(1); + ctx.generation++; + render.update(); + expect(calls.ghostty_render_state_update).toBe(2); + render.free(); + freeContext(ctx); + }); + + test('reads the cursor and the active rows of the copy', () => { + const ctx = renderContext(vt, 10, 3); + const render = new RenderState(ctx); + writeVt(ctx, 'ab\r\ncd\x1b[4 q'); + render.refresh(); + expect(render.getCursor()).toEqual({ + x: 2, + y: 1, + viewportX: 2, + viewportY: 1, + visible: true, + blinking: false, + style: 'underline', + }); + const row = render.viewRow(1); + expect(row.base).toBe(10); + expect(String.fromCodePoint(row.cells[row.base + 1].codepoint)).toBe('d'); + writeVt(ctx, '\x1b[6 q'); + expect(render.getCursor().style).toBe('bar'); + render.free(); + freeContext(ctx); + }); + + test('markAllDirty makes the next frame full; markClean forgets it', () => { + const ctx = renderContext(vt, 10, 3); + const render = new RenderState(ctx); + writeVt(ctx, 'a'); + render.update(); + render.markClean(); + expect(render.update()).toBe(DirtyState.NONE); + render.markAllDirty(); + expect(render.update()).toBe(DirtyState.FULL); + expect(render.isRowDirty(2)).toBe(true); + render.markClean(); + expect(render.isRowDirty(2)).toBe(false); + render.free(); + freeContext(ctx); + }); + + test('resized sizes the copy to the new grid and redecodes every row', () => { + const ctx = renderContext(vt, 10, 3); + const render = new RenderState(ctx); + writeVt(ctx, 'one\r\ntwo'); + render.update(); + render.markClean(); + ctx.cols = 12; + ctx.rows = 4; + expect(ctx.ex.ghostty_terminal_resize(ctx.term, 12, 4, 0, 0)).toBe(0); + ctx.generation++; + render.resized(); + expect(render.getViewport().length).toBe(48); + expect(render.update()).toBe(DirtyState.FULL); + expect(String.fromCodePoint(render.viewRow(1).cells[12 + 2].codepoint)).toBe('o'); + render.free(); + freeContext(ctx); + }); + + test('a grapheme longer than the scratch buffer is read through its own allocation', () => { + const ctx = renderContext(vt, 10, 3); + const render = new RenderState(ctx); + const marks = '́'.repeat(40); + writeVt(ctx, `e${marks}x`); + render.refresh(); + const row = render.viewRow(0); + expect(row.cells[0].grapheme_len).toBe(40); + expect(row.graphemes?.get(0)).toBe(`e${marks}`); + expect(String.fromCodePoint(row.cells[1].codepoint)).toBe('x'); + render.free(); + freeContext(ctx); + }); + + test('decodePage reads every row of another render state into fresh rows', () => { + const ctx = renderContext(vt, 8, 3); + const render = new RenderState(ctx); + writeVt(ctx, 'top\r\nmid\r\nend'); + render.refresh(); + const rs = ctx.vt.newHandle('render_state_new', (slot) => + ctx.ex.ghostty_render_state_new(0, slot), + ); + expect(ctx.ex.ghostty_render_state_update(rs, ctx.term)).toBe(0); + const page = render.decodePage(rs); + expect(page.length).toBe(3); + expect(page.map((r) => r.base)).toEqual([0, 0, 0]); + expect(page[2].cells.length).toBe(8); + expect(String.fromCodePoint(page[2].cells[0].codepoint)).toBe('e'); + // Fresh cells, not the active copy's. + expect(page[0].cells).not.toBe(render.getViewport()); + ctx.ex.ghostty_render_state_free(rs); + render.free(); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/row-ref.test.ts b/src/client/vendor/tests/engine/row-ref.test.ts new file mode 100644 index 0000000..78995a6 --- /dev/null +++ b/src/client/vendor/tests/engine/row-ref.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { + blankCell, + blankCells, + copyRow, + graphemeOf, + type RowRef, + setRgb, +} from '../../ghostty-web/engine/row-ref'; + +// The decoded row helpers (ADR 061): blank cells, colour writes, row copies and the text +// of one cell. + +function row(codepoints: number[], base = 0, graphemes: RowRef['graphemes'] = null): RowRef { + const cells = blankCells(base + codepoints.length); + codepoints.forEach((cp, i) => { + cells[base + i].codepoint = cp; + }); + return { cells, base, graphemes }; +} + +describe('row-ref', () => { + test('a blank cell is an empty, narrow, unstyled cell; each call is a new object', () => { + expect(blankCell()).toEqual({ + codepoint: 0, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }); + expect(blankCell()).not.toBe(blankCell()); + const cells = blankCells(3); + expect(cells.length).toBe(3); + expect(cells[0]).not.toBe(cells[1]); + expect(blankCells(0)).toEqual([]); + }); + + test('setRgb splits 0xRRGGBB colours into the channels', () => { + const cell = blankCell(); + setRgb(cell, 0x123456, 0xabcdef); + expect([cell.fg_r, cell.fg_g, cell.fg_b]).toEqual([0x12, 0x34, 0x56]); + expect([cell.bg_r, cell.bg_g, cell.bg_b]).toEqual([0xab, 0xcd, 0xef]); + }); + + test('copyRow copies the cols cells from the base, each a new object', () => { + const ref = row([0x61, 0x62, 0x63], 2); + const copy = copyRow(ref, 2); + expect(copy.map((c) => c.codepoint)).toEqual([0x61, 0x62]); + expect(copy[0]).not.toBe(ref.cells[2]); + copy[0].codepoint = 0x7a; + expect(ref.cells[2].codepoint).toBe(0x61); + }); + + test.each([ + ['a grapheme string', 0, 'é'], + ['the codepoint', 1, 'x'], + ['a space for an empty cell', 2, ' '], + ])('graphemeOf reads %s', (_what, col, expected) => { + const ref = row([0x65, 0x78, 0], 1, new Map([[0, 'é']])); + expect(graphemeOf(ref, col)).toBe(expected); + }); + + test('graphemeOf reads the codepoint when the row has no graphemes', () => { + expect(graphemeOf(row([0x1f600]), 0)).toBe('😀'); + }); +}); diff --git a/src/client/vendor/tests/engine/scrollback-pages.test.ts b/src/client/vendor/tests/engine/scrollback-pages.test.ts new file mode 100644 index 0000000..47807a7 --- /dev/null +++ b/src/client/vendor/tests/engine/scrollback-pages.test.ts @@ -0,0 +1,232 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { RenderState } from '../../ghostty-web/engine/render-state'; +import { PAGE_CACHE_SIZE, ScrollbackPages } from '../../ghostty-web/engine/scrollback-pages'; +import { Ghostty, type OfficialTerminal } from '../../ghostty-web/ghostty'; +import { freeContext, renderContext, writeVt } from '../support/engine-harness'; +import { lines, rowText, scrollbackText, THEME } from '../support/terminal-text'; +import { countedGhostty, instantiateWasm, loadGhostty } from '../support/wasm'; + +// The scrollback page cache (ADR 043, ADR 061): rows above the active screen read a page +// at a time, kept per generation, least recently used dropped first. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('scrollback pages: through the terminal', () => { + test('keeps scrolled-off rows, oldest first', () => { + const t = term(20, 5, { scrollbackLines: 0 }); + t.write(lines(50)); + expect(t.getScrollbackLength()).toBe(45); + expect(scrollbackText(t, 0)).toBe('line 1'); + expect(scrollbackText(t, 44)).toBe('line 45'); + expect(t.getScrollbackLine(45)).toBeNull(); + expect(rowText(t, 0)).toBe('line 46'); + expect(rowText(t, 4)).toBe('line 50'); + t.free(); + }); + + test('keeps active-screen coordinates while the view is scrolled up', () => { + const t = term(20, 5, { scrollbackLines: 0 }); + t.write(lines(50)); + t.setViewportOffset(7); + expect(t.isRowDirty(0)).toBe(true); + expect(rowText(t, 0)).toBe('line 46'); + expect(rowText(t, 4)).toBe('line 50'); + expect(scrollbackText(t, 38)).toBe('line 39'); + expect(scrollbackText(t, 44)).toBe('line 45'); + const viewport = t.getViewport(); + expect(viewport.length).toBe(100); + expect(String.fromCodePoint(viewport[80 + 5].codepoint)).toBe('5'); + expect(t.getCursor()).toMatchObject({ x: 7, y: 4 }); + // Output snaps the view back to the bottom. + t.write('\r\nline 51'); + expect(rowText(t, 4)).toBe('line 51'); + expect(t.needsFullRedraw()).toBe(true); + t.free(); + }); + + test('a scroll step decodes nothing and moves no viewport once its page is cached', async () => { + const real = (await instantiateWasm()).exports as Record<string, (...args: number[]) => number>; + const calls = { update: 0, rows: 0, scroll: 0 }; + const counted = + (name: string, key: keyof typeof calls) => + (...args: number[]) => { + calls[key]++; + return real[name](...args); + }; + const spy = { + ...real, + ghostty_render_state_update: counted('ghostty_render_state_update', 'update'), + ghostty_render_state_row_get_multi: counted('ghostty_render_state_row_get_multi', 'rows'), + ghostty_terminal_scroll_viewport: counted('ghostty_terminal_scroll_viewport', 'scroll'), + }; + const spied = new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]); + const t = spied.createTerminal(20, 5, { scrollbackLines: 0 }); + t.write(lines(50)); + const sb = t.getScrollbackLength(); + expect(sb).toBe(45); + // One frame as the renderer reads it at viewportY = k. + const frame = (k: number) => { + t.setViewportOffset(k); + t.getCursor(); + t.needsFullRedraw(); + const view = t.getViewport(); + const shown: string[] = []; + for (let y = 0; y < t.rows; y++) { + if (y < k) { + const line = t.getScrollbackLine(sb - k + y) ?? []; + for (let x = 0; x < line.length; x++) t.getScrollbackGraphemeString(sb - k + y, x); + shown.push( + line + .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) + .join('') + .trimEnd(), + ); + } else { + const row = view.slice((y - k) * t.cols, (y - k + 1) * t.cols); + shown.push( + row + .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) + .join('') + .trimEnd(), + ); + } + t.isRowDirty(y); + } + t.markClean(); + return shown; + }; + const expected = (k: number) => + Array.from({ length: 5 }, (_, y) => `line ${50 - 5 - k + y + 1}`); + frame(0); + // The first walk reads each scrollback page once: 45 rows in pages of 5. + const first = { ...calls }; + for (let k = 1; k <= sb; k++) expect(frame(k)).toEqual(expected(k)); + expect(calls.update - first.update).toBe(9); + expect(calls.rows - first.rows).toBe(9 * 5); + // Each page read moves the viewport there and back; nothing else moves it. + expect(calls.scroll - first.scroll).toBe(9 * 2); + // Every page is cached now: a walk back down and up again costs no export call. + const warm = { ...calls }; + for (let k = sb; k >= 0; k--) expect(frame(k)).toEqual(k ? expected(k) : expected(0)); + for (let k = 0; k <= sb + 10; k++) frame(k); + expect(calls).toEqual(warm); + // Output while scrolled: one refresh of the active rows, no viewport move, and + // the first frame at the bottom repaints every row. + t.setViewportOffset(12); + t.write('\r\nline 51'); + expect(t.needsFullRedraw()).toBe(true); + expect(calls.update - warm.update).toBe(1); + expect(calls.scroll).toBe(warm.scroll); + expect(rowText(t, 4)).toBe('line 51'); + t.markClean(); + // Resize while scrolled: no viewport move, and the reflowed rows are read anew. + t.setViewportOffset(12); + const beforeResize = calls.scroll; + t.resize(30, 4); + expect(calls.scroll).toBe(beforeResize); + expect(t.getScrollbackLength()).toBe(47); + expect(scrollbackText(t, 46)).toBe('line 47'); + expect(rowText(t, 3)).toBe('line 51'); + t.free(); + }); +}); + +describe('ScrollbackPages', () => { + const UPDATE = 'ghostty_render_state_update'; + + async function setup(rows: number, written: number) { + const { ghostty, calls } = await countedGhostty([UPDATE, 'ghostty_terminal_scroll_viewport']); + const ctx = renderContext(ghostty, 20, rows); + const render = new RenderState(ctx); + const pages = new ScrollbackPages(ctx, render); + writeVt(ctx, lines(written)); + render.refresh(); + const text = (abs: number) => { + const row = pages.rowAt(abs); + return row.cells + .slice(row.base, row.base + 20) + .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) + .join('') + .trimEnd(); + }; + const done = () => { + pages.free(); + render.free(); + freeContext(ctx); + }; + return { ctx, render, pages, calls, text, done }; + } + + test('rowAt serves scrollback from pages and active rows from the render state', async () => { + const { ctx, render, calls, text, done } = await setup(2, 7); + expect(ctx.sbRows).toBe(5); + expect(text(0)).toBe('line 1'); + expect(text(4)).toBe('line 5'); + expect(text(5)).toBe('line 6'); + expect(text(6)).toBe('line 7'); + // Two page reads (rows 0-1 and 4-5); rows 5 and 6 came from the active copy. + expect(calls[UPDATE]).toBe(1 + 2); + expect(calls.ghostty_terminal_scroll_viewport).toBe(2 * 2); + expect(render.viewRow(0).cells).toBe(render.getViewport()); + done(); + }); + + test('keeps the most recently used pages and drops the oldest past the limit', async () => { + const pagesRead = PAGE_CACHE_SIZE + 2; + const { calls, text, done } = await setup(2, pagesRead * 2 + 2); + for (let p = 0; p < pagesRead; p++) text(p * 2); + const afterWalk = calls[UPDATE]; + // The last 16 pages are cached: no read. + text((pagesRead - 1) * 2); + text(2 * 2 + 1); + expect(calls[UPDATE]).toBe(afterWalk); + // Pages 0 and 1 were dropped: each costs a read again. + expect(text(0)).toBe('line 1'); + expect(text(3)).toBe('line 4'); + expect(calls[UPDATE]).toBe(afterWalk + 2); + done(); + }); + + test('a hit moves its page to the back, so the least recently used goes first', async () => { + const { calls, text, done } = await setup(2, (PAGE_CACHE_SIZE + 1) * 2 + 2); + for (let p = 0; p < PAGE_CACHE_SIZE; p++) text(p * 2); + // Touch page 0, then read one more page: page 1 is the one dropped. + text(0); + text(PAGE_CACHE_SIZE * 2); + const before = calls[UPDATE]; + text(1); + expect(calls[UPDATE]).toBe(before); + text(2); + expect(calls[UPDATE]).toBe(before + 1); + done(); + }); + + test('a new generation drops every page and the second render state', async () => { + const { ctx, render, calls, text, done } = await setup(2, 7); + text(0); + const before = calls[UPDATE]; + text(1); + expect(calls[UPDATE]).toBe(before); + writeVt(ctx, '\r\nline 8'); + render.refresh(); + expect(text(0)).toBe('line 1'); + // One refresh of the active rows, one page read. + expect(calls[UPDATE]).toBe(before + 2); + done(); + }); + + test('free releases the second render state once', async () => { + const { pages, text, done } = await setup(2, 7); + text(0); + done(); + expect(() => pages.free()).not.toThrow(); + }); +}); diff --git a/src/client/vendor/tests/official-terminal.test.ts b/src/client/vendor/tests/official-terminal.test.ts index fb36b8f..2128912 100644 --- a/src/client/vendor/tests/official-terminal.test.ts +++ b/src/client/vendor/tests/official-terminal.test.ts @@ -1,25 +1,16 @@ import { beforeAll, describe, expect, test } from 'bun:test'; import { - DirtyState, Ghostty, type GhosttyTerminal, type MouseInput, type OfficialTerminal, } from '../ghostty-web/ghostty'; -import { CellFlags, Key, KeyAction, KeyEncoderOption, Mods } from '../ghostty-web/types'; +import { Key, KeyAction, KeyEncoderOption, Mods } from '../ghostty-web/types'; +import { bgRgb, FG, RED, rgb, rowText, scrollbackText, THEME } from './support/terminal-text'; import { instantiateWasm, loadGhostty } from './support/wasm'; // The official libghostty-vt binding (ADR 042) on the vendored wasm, loaded from disk. - -const FG = 0xd4d4d4; -const BG = 0x1e1e1e; -const RED = 0xcd3131; -const BLUE = 0x2472c8; -const THEME = { - fgColor: FG, - bgColor: BG, - palette: [0x000000, RED, 0x0dbc79, 0xe5e510, BLUE], -}; +// Cell decoding and the scrollback pages are tested beside their parts in engine/. let vt: Ghostty; @@ -31,106 +22,7 @@ function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { return vt.createTerminal(cols, rows, config) as OfficialTerminal; } -/** The text of a row as an app would see it: graphemes joined, spacer cells skipped. */ -function rowText(t: GhosttyTerminal, y: number): string { - const line = t.getLine(y); - if (!line) return ''; - return line - .map((c, x) => { - if (c.width === 0) return ''; - if (c.grapheme_len > 0) return t.getGraphemeString(y, x); - return c.codepoint ? String.fromCodePoint(c.codepoint) : ' '; - }) - .join('') - .trimEnd(); -} - -function scrollbackText(t: GhosttyTerminal, offset: number): string { - const line = t.getScrollbackLine(offset); - if (!line) return ''; - return line - .map((c, x) => { - if (c.width === 0) return ''; - if (c.grapheme_len > 0) return t.getScrollbackGraphemeString(offset, x); - return c.codepoint ? String.fromCodePoint(c.codepoint) : ' '; - }) - .join('') - .trimEnd(); -} - -const rgb = (c: { fg_r: number; fg_g: number; fg_b: number }) => - (c.fg_r << 16) | (c.fg_g << 8) | c.fg_b; -const bgRgb = (c: { bg_r: number; bg_g: number; bg_b: number }) => - (c.bg_r << 16) | (c.bg_g << 8) | c.bg_b; - describe('official engine: cells', () => { - test('reads 16-colour, 256-colour and truecolor foregrounds and backgrounds', () => { - const t = term(); - t.write( - '\x1b[31mA\x1b[0m\x1b[38;5;208mB\x1b[0m\x1b[38;2;10;20;30;48;2;200;100;50mC\x1b[0m' + - '\x1b[44mD\x1b[0mE\x1b[48;5;21m \x1b[0m', - ); - const line = t.getLine(0); - expect(line).not.toBeNull(); - const [a, b, c, d, e, f] = line ?? []; - expect(rgb(a)).toBe(RED); - expect(bgRgb(a)).toBe(BG); - expect(rgb(b)).toBe(0xff8700); - expect(rgb(c)).toBe(0x0a141e); - expect(bgRgb(c)).toBe(0xc86432); - expect(bgRgb(d)).toBe(BLUE); - expect(rgb(d)).toBe(FG); - expect(rgb(e)).toBe(FG); - expect(bgRgb(e)).toBe(BG); - // Spaces written with a 256-colour background. - expect(f.codepoint).toBe(0x20); - expect(bgRgb(f)).toBe(0x0000ff); - t.free(); - }); - - test('reads bold, italic, underline, strikethrough, inverse, faint, blink and invisible', () => { - const t = term(); - t.write( - '\x1b[1mA\x1b[0m\x1b[3mB\x1b[0m\x1b[4mC\x1b[0m\x1b[9mD\x1b[0m\x1b[7mE\x1b[0m' + - '\x1b[2mF\x1b[0m\x1b[5mG\x1b[0m\x1b[8mH\x1b[0m\x1b[1;3;4;7mI\x1b[0mJ', - ); - const flags = (t.getLine(0) ?? []).slice(0, 10).map((c) => c.flags); - expect(flags).toEqual([ - CellFlags.BOLD, - CellFlags.ITALIC, - CellFlags.UNDERLINE, - CellFlags.STRIKETHROUGH, - CellFlags.INVERSE, - CellFlags.FAINT, - CellFlags.BLINK, - CellFlags.INVISIBLE, - CellFlags.BOLD | CellFlags.ITALIC | CellFlags.UNDERLINE | CellFlags.INVERSE, - 0, - ]); - t.free(); - }); - - test('reads wide CJK, emoji, VS16 and combining marks as graphemes', () => { - const t = term(); - t.write('你好|😀|❤️|é|👨‍👩‍👧'); - const line = t.getLine(0) ?? []; - expect(line.slice(0, 5).map((c) => c.width)).toEqual([2, 0, 2, 0, 1]); - expect(String.fromCodePoint(line[0].codepoint)).toBe('你'); - expect(line[5].width).toBe(2); - expect(String.fromCodePoint(line[5].codepoint)).toBe('😀'); - // VS16 makes the heart wide (mode 2027 on by default). - expect(line[8].width).toBe(2); - expect(line[8].grapheme_len).toBe(1); - expect(t.getGraphemeString(0, 8)).toBe('❤️'); - expect(line[11].grapheme_len).toBe(1); - expect(t.getGraphemeString(0, 11)).toBe('é'); - expect([...t.getGraphemeString(0, 11)].map((c) => c.codePointAt(0))).toEqual([0x65, 0x301]); - expect(t.getGraphemeString(0, 13)).toBe('👨‍👩‍👧'); - expect(line[13].width).toBe(2); - expect(rowText(t, 0)).toBe('你好|😀|❤️|é|👨‍👩‍👧'); - t.free(); - }); - test('marks OSC 8 hyperlinks and resolves their URIs on screen and in scrollback', () => { const t = term(40, 5); t.write('\x1b]8;;https://example.com/a\x1b\\link\x1b]8;;\x1b\\ plain'); @@ -368,126 +260,6 @@ describe('official engine: cursor and modes', () => { }); describe('official engine: scrollback, resize and the viewport', () => { - const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n'); - - test('keeps scrolled-off rows, oldest first', () => { - const t = term(20, 5, { scrollbackLines: 0 }); - t.write(lines(50)); - expect(t.getScrollbackLength()).toBe(45); - expect(scrollbackText(t, 0)).toBe('line 1'); - expect(scrollbackText(t, 44)).toBe('line 45'); - expect(t.getScrollbackLine(45)).toBeNull(); - expect(rowText(t, 0)).toBe('line 46'); - expect(rowText(t, 4)).toBe('line 50'); - t.free(); - }); - - test('keeps active-screen coordinates while the view is scrolled up', () => { - const t = term(20, 5, { scrollbackLines: 0 }); - t.write(lines(50)); - t.setViewportOffset(7); - expect(t.isRowDirty(0)).toBe(true); - expect(rowText(t, 0)).toBe('line 46'); - expect(rowText(t, 4)).toBe('line 50'); - expect(scrollbackText(t, 38)).toBe('line 39'); - expect(scrollbackText(t, 44)).toBe('line 45'); - const viewport = t.getViewport(); - expect(viewport.length).toBe(100); - expect(String.fromCodePoint(viewport[80 + 5].codepoint)).toBe('5'); - expect(t.getCursor()).toMatchObject({ x: 7, y: 4 }); - // Output snaps the view back to the bottom. - t.write('\r\nline 51'); - expect(rowText(t, 4)).toBe('line 51'); - expect(t.needsFullRedraw()).toBe(true); - t.free(); - }); - - test('a scroll step decodes nothing and moves no viewport once its page is cached', async () => { - const real = (await instantiateWasm()).exports as Record<string, (...args: number[]) => number>; - const calls = { update: 0, rows: 0, scroll: 0 }; - const counted = - (name: string, key: keyof typeof calls) => - (...args: number[]) => { - calls[key]++; - return real[name](...args); - }; - const spy = { - ...real, - ghostty_render_state_update: counted('ghostty_render_state_update', 'update'), - ghostty_render_state_row_get_multi: counted('ghostty_render_state_row_get_multi', 'rows'), - ghostty_terminal_scroll_viewport: counted('ghostty_terminal_scroll_viewport', 'scroll'), - }; - const spied = new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]); - const t = spied.createTerminal(20, 5, { scrollbackLines: 0 }); - t.write(lines(50)); - const sb = t.getScrollbackLength(); - expect(sb).toBe(45); - // One frame as the renderer reads it at viewportY = k. - const frame = (k: number) => { - t.setViewportOffset(k); - t.getCursor(); - t.needsFullRedraw(); - const view = t.getViewport(); - const shown: string[] = []; - for (let y = 0; y < t.rows; y++) { - if (y < k) { - const line = t.getScrollbackLine(sb - k + y) ?? []; - for (let x = 0; x < line.length; x++) t.getScrollbackGraphemeString(sb - k + y, x); - shown.push( - line - .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) - .join('') - .trimEnd(), - ); - } else { - const row = view.slice((y - k) * t.cols, (y - k + 1) * t.cols); - shown.push( - row - .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) - .join('') - .trimEnd(), - ); - } - t.isRowDirty(y); - } - t.markClean(); - return shown; - }; - const expected = (k: number) => - Array.from({ length: 5 }, (_, y) => `line ${50 - 5 - k + y + 1}`); - frame(0); - // The first walk reads each scrollback page once: 45 rows in pages of 5. - const first = { ...calls }; - for (let k = 1; k <= sb; k++) expect(frame(k)).toEqual(expected(k)); - expect(calls.update - first.update).toBe(9); - expect(calls.rows - first.rows).toBe(9 * 5); - // Each page read moves the viewport there and back; nothing else moves it. - expect(calls.scroll - first.scroll).toBe(9 * 2); - // Every page is cached now: a walk back down and up again costs no export call. - const warm = { ...calls }; - for (let k = sb; k >= 0; k--) expect(frame(k)).toEqual(k ? expected(k) : expected(0)); - for (let k = 0; k <= sb + 10; k++) frame(k); - expect(calls).toEqual(warm); - // Output while scrolled: one refresh of the active rows, no viewport move, and - // the first frame at the bottom repaints every row. - t.setViewportOffset(12); - t.write('\r\nline 51'); - expect(t.needsFullRedraw()).toBe(true); - expect(calls.update - warm.update).toBe(1); - expect(calls.scroll).toBe(warm.scroll); - expect(rowText(t, 4)).toBe('line 51'); - t.markClean(); - // Resize while scrolled: no viewport move, and the reflowed rows are read anew. - t.setViewportOffset(12); - const beforeResize = calls.scroll; - t.resize(30, 4); - expect(calls.scroll).toBe(beforeResize); - expect(t.getScrollbackLength()).toBe(47); - expect(scrollbackText(t, 46)).toBe('line 47'); - expect(rowText(t, 3)).toBe('line 51'); - t.free(); - }); - test('reflows wrapped lines on resize', () => { const t = term(20, 5); t.write('abcdefghijklmnopqrstuvwxyz0123'); @@ -510,75 +282,6 @@ describe('official engine: scrollback, resize and the viewport', () => { expect(t.rows).toBe(6); t.free(); }); - - test('keeps row dirt until markClean and only marks changed rows', () => { - const t = term(20, 5); - t.write('one\r\ntwo\r\nthree'); - t.update(); - t.markClean(); - expect(t.update()).toBe(DirtyState.NONE); - t.write('\x1b[2;1HTWO'); - expect(t.update()).toBe(DirtyState.PARTIAL); - expect(t.update()).toBe(DirtyState.PARTIAL); - expect(t.isRowDirty(1)).toBe(true); - expect(t.isRowDirty(0)).toBe(false); - expect(t.isRowDirty(3)).toBe(false); - expect(rowText(t, 1)).toBe('TWO'); - t.clearDirty(); - expect(t.isRowDirty(1)).toBe(false); - t.write('\x1b[?1049h'); - expect(t.needsFullRedraw()).toBe(true); - t.free(); - }); - - // Ghostty marks no row dirty when only the default colours change; the binding bakes - // them into every cell, so it must redecode every row itself. - const everyCell = (t: GhosttyTerminal, rows: number, f: (c: never) => number) => - Array.from({ length: rows }, (_, y) => (t.getLine(y) ?? []).map((c) => f(c as never))); - - test('recolours every row when OSC 10/11 change only the default colours', () => { - const t = term(10, 3); - t.write('a\r\nb\r\nc'); - t.update(); - t.markClean(); - t.write('\x1b]11;rgb:44/55/66\x1b\\'); - expect(t.update()).toBe(DirtyState.FULL); - for (let y = 0; y < 3; y++) expect(t.isRowDirty(y)).toBe(true); - expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(0x445566)); - t.markClean(); - t.write('\x1b]10;rgb:11/22/33\x1b\\'); - expect(t.update()).toBe(DirtyState.FULL); - expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(0x112233)); - t.write('\x1b[1;1HX'); - t.update(); - expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(0x445566)); - t.markClean(); - t.write('\x1b]111\x1b\\\x1b]110\x1b\\'); - t.update(); - expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(BG)); - expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(FG)); - t.free(); - }); - - test('recolours every row on DECSCNM (?5h / ?5l)', () => { - const t = term(10, 3); - t.write('a\r\nb\r\nc'); - t.update(); - t.markClean(); - t.write('\x1b[?5h'); - expect(t.update()).toBe(DirtyState.FULL); - expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(FG)); - t.markClean(); - t.write('\x1b[2;1HB'); - t.update(); - expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(FG)); - t.markClean(); - t.write('\x1b[?5l'); - expect(t.update()).toBe(DirtyState.FULL); - expect(everyCell(t, 3, bgRgb).flat()).toEqual(Array(30).fill(BG)); - expect(everyCell(t, 3, rgb).flat()).toEqual(Array(30).fill(FG)); - t.free(); - }); }); describe('official engine: scrollback in lines (ADR 046)', () => { diff --git a/src/client/vendor/tests/support/engine-harness.ts b/src/client/vendor/tests/support/engine-harness.ts new file mode 100644 index 0000000..0c1b64b --- /dev/null +++ b/src/client/vendor/tests/support/engine-harness.ts @@ -0,0 +1,33 @@ +// Engine parts on a real wasm instance without the OfficialTerminal facade (test support, +// ADR 064): a context with the render state's slots, and raw writes that bump its +// generation as the facade's do. + +import { EngineContext } from '../../ghostty-web/engine/engine-context'; +import { type RenderStateSlot, renderStateSlots } from '../../ghostty-web/engine/render-state'; +import type { Ghostty } from '../../ghostty-web/ghostty'; + +/** A context for a `cols` × `rows` terminal with the render state's scratch slots. */ +export function renderContext( + vt: Ghostty, + cols: number, + rows: number, +): EngineContext<RenderStateSlot> { + return new EngineContext(vt, cols, rows, renderStateSlots(vt.L)); +} + +/** Feeds `text` to the terminal through `vt_write` and bumps the generation. */ +export function writeVt(ctx: EngineContext, text: string): void { + const bytes = new TextEncoder().encode(text); + const ptr = ctx.ex.ghostty_wasm_alloc(bytes.length); + ctx.u8().set(bytes, ptr); + ctx.ex.ghostty_terminal_vt_write(ctx.term, ptr, bytes.length); + ctx.ex.ghostty_wasm_free(ptr, bytes.length); + ctx.generation++; +} + +/** Frees the terminal and the scratch, as the facade's `free()` ends. */ +export function freeContext(ctx: EngineContext): void { + ctx.ex.ghostty_terminal_free(ctx.term); + ctx.term = 0; + ctx.scratch.free(); +} diff --git a/src/client/vendor/tests/support/terminal-text.ts b/src/client/vendor/tests/support/terminal-text.ts new file mode 100644 index 0000000..2c94063 --- /dev/null +++ b/src/client/vendor/tests/support/terminal-text.ts @@ -0,0 +1,55 @@ +// Readers and colours shared by the engine tests (test support, ADR 064): a row's text as +// an app sees it, on screen and in scrollback, the 0xRRGGBB of a decoded cell, and the +// theme most engine tests create their terminals with. + +import type { GhosttyCell, GhosttyTerminal } from '../../ghostty-web/ghostty'; + +/** The theme's default foreground. */ +export const FG = 0xd4d4d4; +/** The theme's default background. */ +export const BG = 0x1e1e1e; +/** Palette entry 1. */ +export const RED = 0xcd3131; +/** Palette entry 4. */ +export const BLUE = 0x2472c8; +/** The config most engine tests create their terminals with. */ +export const THEME = { + fgColor: FG, + bgColor: BG, + palette: [0x000000, RED, 0x0dbc79, 0xe5e510, BLUE], +}; + +function cellsText(cells: GhosttyCell[], grapheme: (x: number) => string): string { + return cells + .map((c, x) => { + if (c.width === 0) return ''; + if (c.grapheme_len > 0) return grapheme(x); + return c.codepoint ? String.fromCodePoint(c.codepoint) : ' '; + }) + .join('') + .trimEnd(); +} + +/** The text of a row as an app would see it: graphemes joined, spacer cells skipped. */ +export function rowText(t: GhosttyTerminal, y: number): string { + const line = t.getLine(y); + return line ? cellsText(line, (x) => t.getGraphemeString(y, x)) : ''; +} + +/** The text of scrollback row `offset` (0 is the oldest), as `rowText` reads it. */ +export function scrollbackText(t: GhosttyTerminal, offset: number): string { + const line = t.getScrollbackLine(offset); + return line ? cellsText(line, (x) => t.getScrollbackGraphemeString(offset, x)) : ''; +} + +/** A cell's foreground as 0xRRGGBB. */ +export const rgb = (c: { fg_r: number; fg_g: number; fg_b: number }) => + (c.fg_r << 16) | (c.fg_g << 8) | c.fg_b; + +/** A cell's background as 0xRRGGBB. */ +export const bgRgb = (c: { bg_r: number; bg_g: number; bg_b: number }) => + (c.bg_r << 16) | (c.bg_g << 8) | c.bg_b; + +/** `n` lines `line 1` … `line n`, joined by CRLF. */ +export const lines = (n: number) => + Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n'); diff --git a/src/client/vendor/tests/support/wasm.ts b/src/client/vendor/tests/support/wasm.ts index beb8a9f..ca862c4 100644 --- a/src/client/vendor/tests/support/wasm.ts +++ b/src/client/vendor/tests/support/wasm.ts @@ -40,3 +40,30 @@ export async function loadGhostty(): Promise<Ghostty> { const instance = await instantiateWasm(); return new Ghostty(instance.exports as unknown as ConstructorParameters<typeof Ghostty>[0]); } + +/** Export call counts by name, filled in by a `countedGhostty` instance. */ +export type ExportCalls = Record<string, number>; + +/** + * A Ghostty on a fresh instance whose named exports count their calls, so a test can + * assert how many times the engine crossed into the wasm for a given read. + */ +export async function countedGhostty( + names: readonly string[], +): Promise<{ ghostty: Ghostty; calls: ExportCalls }> { + const real = (await instantiateWasm()).exports as Record<string, unknown>; + const calls: ExportCalls = {}; + const spy: Record<string, unknown> = { ...real }; + for (const name of names) { + const fn = real[name] as (...args: number[]) => number; + calls[name] = 0; + spy[name] = (...args: number[]) => { + calls[name]++; + return fn(...args); + }; + } + return { + ghostty: new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]), + calls, + }; +} From 8c844b04281ffdfc638949c09d9575b13325bd87 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:04:53 -0400 Subject: [PATCH 235/295] test(client): split official-terminal.test.ts by engine area The 1135-line vendor/tests/official-terminal.test.ts moves to vendor/tests/engine/ and is split by the binding each test exercises, so the coming binding extraction diffs only source (ADR 061, ADR 064). Every test still runs through OfficialTerminal, with the same assertions: - engine-config.test.ts: theme colours and scrollbackLines - grid-refs.test.ts: OSC 8 hyperlink URIs - engine-effects.test.ts: title, bell, replies, OSC 52 clipboard, notifications - mouse-encoder-binding.test.ts, paste-focus-binding.test.ts, selection-binding.test.ts (with selectOutput), search-binding.test.ts - key-encoder-binding.test.ts: the key encoder test, beside its binding - official-terminal.test.ts: cursor and modes, resize (with the mode 2048 report and the resize call count), writeUntilGround, memory growth, promptJumpRow The OSC 133 command builder used by both promptJumpRow and selectOutput moves to support/terminal-text.ts (markedCommand, markedSession). The ratchet entry for the old file goes. 78 tests and 893 expects before and after. --- scripts/architecture/ratchet-allowlist.ts | 1 - src/client/vendor/ghostty-vt/PROVENANCE.md | 2 +- .../vendor/tests/engine/engine-config.test.ts | 142 +++ .../tests/engine/engine-effects.test.ts | 239 ++++ .../vendor/tests/engine/grid-refs.test.ts | 36 + .../tests/engine/key-encoder-binding.test.ts | 30 + .../engine/mouse-encoder-binding.test.ts | 186 +++ .../tests/engine/official-terminal.test.ts | 333 +++++ .../tests/engine/paste-focus-binding.test.ts | 79 ++ .../tests/engine/search-binding.test.ts | 122 ++ .../tests/engine/selection-binding.test.ts | 107 ++ .../vendor/tests/official-terminal.test.ts | 1135 ----------------- .../vendor/tests/support/terminal-text.ts | 10 + 13 files changed, 1285 insertions(+), 1137 deletions(-) create mode 100644 src/client/vendor/tests/engine/engine-config.test.ts create mode 100644 src/client/vendor/tests/engine/engine-effects.test.ts create mode 100644 src/client/vendor/tests/engine/grid-refs.test.ts create mode 100644 src/client/vendor/tests/engine/key-encoder-binding.test.ts create mode 100644 src/client/vendor/tests/engine/mouse-encoder-binding.test.ts create mode 100644 src/client/vendor/tests/engine/official-terminal.test.ts create mode 100644 src/client/vendor/tests/engine/paste-focus-binding.test.ts create mode 100644 src/client/vendor/tests/engine/search-binding.test.ts create mode 100644 src/client/vendor/tests/engine/selection-binding.test.ts delete mode 100644 src/client/vendor/tests/official-terminal.test.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 9c33d72..ce2d7ca 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -38,7 +38,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { lines: 2147, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/official-terminal.test.ts': { lines: 1135, step: VENDOR_TESTS }, 'src/client/vendor/tests/renderer.test.ts': { lines: 1087, step: VENDOR_TESTS }, 'src/client/vendor/tests/terminal.test.ts': { lines: 1159, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, diff --git a/src/client/vendor/ghostty-vt/PROVENANCE.md b/src/client/vendor/ghostty-vt/PROVENANCE.md index 56c44c9..0e2cb4b 100644 --- a/src/client/vendor/ghostty-vt/PROVENANCE.md +++ b/src/client/vendor/ghostty-vt/PROVENANCE.md @@ -8,4 +8,4 @@ Ghostty's official libghostty-vt wasm, ReleaseSmall build, unpatched and not edi Checked offline by `src/client/vendor/tests/ghostty-vt.test.ts` (size, sha256, signature, key) on every `bun test`, and by `bun scripts/update-ghostty-vt.ts --verify`. `scripts/build.ts` refuses to copy a wasm whose size or sha256 does not match the sidecar, and writes the licence to `dist/THIRD_PARTY_LICENSES`. -The wasm, signature, sidecar and `LICENSE` are written by `bun scripts/update-ghostty-vt.ts --commit <sha>`, the only step that uses the network, and are not edited by hand. A pin bump is a deliberate change: update the commit above and re-run the binding's tests (`official-terminal.test.ts`, `official-replay.test.ts`, ADR 041). +The wasm, signature, sidecar and `LICENSE` are written by `bun scripts/update-ghostty-vt.ts --commit <sha>`, the only step that uses the network, and are not edited by hand. A pin bump is a deliberate change: update the commit above and re-run the binding's tests (`vendor/tests/engine/`, `official-replay.test.ts`, ADR 041). diff --git a/src/client/vendor/tests/engine/engine-config.test.ts b/src/client/vendor/tests/engine/engine-config.test.ts new file mode 100644 index 0000000..9b8eb6a --- /dev/null +++ b/src/client/vendor/tests/engine/engine-config.test.ts @@ -0,0 +1,142 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty, GhosttyTerminal, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bgRgb, FG, RED, rgb, THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// The terminal config the engine applies (ADR 042, ADR 046, ADR 061): theme colours and +// their fallbacks, and the scrollback limit in lines, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: theme colours', () => { + test('uses an explicitly black theme background instead of the default', () => { + const t = term(40, 5, { fgColor: FG, bgColor: 0 }); + t.write('\x1b]11;?\x07'); + expect(t.readResponse()).toBe('\x1b]11;rgb:0000/0000/0000\x07'); + t.free(); + }); + + test("sets a black palette entry as black and keeps Ghostty's default for an unset one", () => { + const fgs = (config: object) => { + const t = term(10, 3, config); + t.write('\x1b[30mA\x1b[31mB'); + t.update(); + const line = t.getLine(0) ?? []; + t.free(); + return [rgb(line[0]), rgb(line[1])]; + }; + const ghosttyBlack = fgs({})[0]; + expect(ghosttyBlack).not.toBe(0); + expect(fgs({ palette: [0x000000, RED] })).toEqual([0, RED]); + expect(fgs({ palette: [undefined, RED] })).toEqual([ghosttyBlack, RED]); + }); + + // Ghostty's render state copies the default colours only when both are set, so the + // binding always sets both, falling back to webtty's default 0xcccccc on black. + const defaults = (t: GhosttyTerminal) => { + t.write('\x1b[1;1HA'); + t.update(); + const cell = (t.getLine(0) ?? [])[0]; + t.readResponse(); + t.write('\x1b]10;?\x07'); + const osc10 = t.readResponse(); + t.write('\x1b]11;?\x07'); + const osc11 = t.readResponse(); + return { + cellFg: rgb(cell), + cellBg: bgRgb(cell), + osc10, + osc11, + }; + }; + + test('falls back to the default background when only the foreground is given', () => { + for (const config of [{ fgColor: FG }, { fgColor: FG, bgColor: undefined }]) { + const t = term(10, 3, config); + expect(defaults(t)).toEqual({ + cellFg: FG, + cellBg: 0, + osc10: '\x1b]10;rgb:d4d4/d4d4/d4d4\x07', + osc11: '\x1b]11;rgb:0000/0000/0000\x07', + }); + t.free(); + } + }); + + test('falls back to the default foreground when only the background is given', () => { + const t = term(10, 3, { bgColor: 0x202020 }); + expect(defaults(t)).toEqual({ + cellFg: 0xcccccc, + cellBg: 0x202020, + osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', + osc11: '\x1b]11;rgb:2020/2020/2020\x07', + }); + t.free(); + }); + + test('without theme colours still applies OSC 11 and DECSCNM', () => { + for (const config of [undefined, { scrollbackLines: 1000 }]) { + const t = vt.createTerminal(10, 3, config) as OfficialTerminal; + expect(defaults(t)).toEqual({ + cellFg: 0xcccccc, + cellBg: 0, + osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', + osc11: '\x1b]11;rgb:0000/0000/0000\x07', + }); + t.write('\x1b]11;rgb:20/40/60\x1b\\'); + t.update(); + expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0x204060); + t.write('\x1b]111\x1b\\\x1b[?5h'); + t.update(); + expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0xcccccc); + expect(rgb((t.getLine(0) ?? [])[0])).toBe(0); + t.free(); + } + }); +}); + +describe('official engine: scrollback in lines (ADR 046)', () => { + /** Writes `n` numbered lines and returns how many scrollback rows the terminal kept. */ + function keep(config: object | undefined, n: number, cols = 80): number { + const t = vt.createTerminal(cols, 24, config) as OfficialTerminal; + let chunk = ''; + for (let i = 0; i < n; i++) { + chunk += `line ${i}\r\n`; + if (chunk.length > 1 << 20) { + t.write(chunk); + chunk = ''; + } + } + t.write(chunk); + t.update(); + const kept = t.getScrollbackLength(); + t.free(); + return kept; + } + + test('keeps about scrollbackLines lines, a page at most under', () => { + const kept = keep({ scrollbackLines: 10000 }, 15000); + expect(kept).toBeGreaterThan(9000); + expect(kept).toBeLessThanOrEqual(10000 + 200); + }); + + test('scrollbackLines 0 removes the line limit', () => { + expect(keep({ scrollbackLines: 0 }, 20000)).toBe(20000 - 24 + 1); + }); + + test('without scrollbackLines keeps the default 10,000 lines', () => { + for (const config of [undefined, THEME]) { + const kept = keep(config, 15000); + expect(kept).toBeGreaterThan(9000); + expect(kept).toBeLessThanOrEqual(10000 + 200); + } + }); +}); diff --git a/src/client/vendor/tests/engine/engine-effects.test.ts b/src/client/vendor/tests/engine/engine-effects.test.ts new file mode 100644 index 0000000..bfe68ee --- /dev/null +++ b/src/client/vendor/tests/engine/engine-effects.test.ts @@ -0,0 +1,239 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { rowText, THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// What the engine reports back from the wasm callbacks (ADR 042, ADR 052, ADR 056, ADR 061): +// the title, the bell, replies through WRITE_PTY, clipboard writes and desktop +// notifications, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: effects and replies', () => { + test('reports the title and the bell once', () => { + const t = term(); + t.write('\x1b]2;first\x07'); + expect(t.readEffects()).toEqual({ + bell: false, + title: 'first', + clipboard: null, + notifications: [], + }); + t.write('\x07\x1b]0;second\x1b\\'); + expect(t.readEffects()).toEqual({ + bell: true, + title: 'second', + clipboard: null, + notifications: [], + }); + expect(t.readEffects()).toEqual({ + bell: false, + title: null, + clipboard: null, + notifications: [], + }); + t.free(); + }); + + test('answers DA1, DA2, DSR, XTWINOPS and DECRQM through WRITE_PTY', () => { + const t = term(80, 24); + t.setCellPixelSize(9, 18); + const ask = (q: string) => { + t.write(q); + return t.readResponse(); + }; + expect(t.hasResponse()).toBe(false); + expect(ask('\x1b[c')).toBe('\x1b[?62;22c'); + expect(ask('\x1b[>c')).toBe('\x1b[>1;10;0c'); + expect(ask('\x1b[5n')).toBe('\x1b[0n'); + expect(ask('\x1b[3;7H\x1b[6n')).toBe('\x1b[3;7R'); + expect(ask('\x1b[14t')).toBe('\x1b[4;432;720t'); + expect(ask('\x1b[16t')).toBe('\x1b[6;18;9t'); + expect(ask('\x1b[18t')).toBe('\x1b[8;24;80t'); + expect(ask('\x1b[?2027$p')).toBe('\x1b[?2027;1$y'); + expect(ask('\x1b[c\x1b[5n')).toBe('\x1b[?62;22c\x1b[0n'); + expect(t.readResponse()).toBeNull(); + t.free(); + }); + + test('does not answer the kitty keyboard query', () => { + // Keys are never encoded with the kitty flags, so the protocol must not look on. + const t = term(); + t.write('\x1b[?u'); + expect(t.readResponse()).toBeNull(); + t.write('\x1b[>31u\x1b[?u'); + expect(t.readResponse()).toBeNull(); + // Replies around it still arrive, in order. + t.write('\x1b[c\x1b[?u\x1b[5n'); + expect(t.readResponse()).toBe('\x1b[?62;22c\x1b[0n'); + t.free(); + }); +}); + +describe('official engine: clipboard writes (ADR 052)', () => { + const b64 = (bytes: string | Uint8Array) => Buffer.from(bytes).toString('base64'); + /** The clipboard text one write reports, or null for none. */ + function copied(seq: string, config: object = THEME): string | null { + const t = term(80, 24, config); + t.write(seq); + const { clipboard } = t.readEffects(); + expect(t.readEffects().clipboard).toBeNull(); + // OSC 52 has no reply: nothing is ever sent back, query or write. + expect(t.readResponse()).toBeNull(); + t.free(); + return clipboard; + } + + test('OSC 52 to the clipboard, ended by BEL or ST, decoded as UTF-8', () => { + expect(copied(`\x1b]52;c;${b64('hello')}\x07`)).toBe('hello'); + expect(copied(`\x1b]52;c;${b64('héllo 中文 🎉\nline 2')}\x1b\\`)).toBe('héllo 中文 🎉\nline 2'); + }); + + test('an empty or unknown selection is the clipboard, as in Ghostty', () => { + expect(copied(`\x1b]52;;${b64('x')}\x07`)).toBe('x'); + expect(copied(`\x1b]52;0;${b64('zero')}\x07`)).toBe('zero'); + }); + + test('the selection and primary clipboards are not written', () => { + expect(copied(`\x1b]52;s;${b64('sel')}\x07`)).toBeNull(); + expect(copied(`\x1b]52;p;${b64('pri')}\x07`)).toBeNull(); + // Ghostty takes one selection character only. + expect(copied(`\x1b]52;pc;${b64('both')}\x07`)).toBeNull(); + }); + + test('an empty payload clears the clipboard', () => { + expect(copied('\x1b]52;c;\x07')).toBe(''); + }); + + test('a query is never answered', () => { + expect(copied('\x1b]52;c;?\x07')).toBeNull(); + }); + + test('invalid base64 is ignored whole; missing padding is accepted', () => { + expect(copied('\x1b]52;c;!!!!\x07')).toBeNull(); + expect(copied(`\x1b]52;c;${b64('ok').slice(0, 3)}?\x07`)).toBeNull(); + expect(copied('\x1b]52;c;aGk\x07')).toBe('hi'); + }); + + test('bytes that are not UTF-8 become U+FFFD', () => { + expect(copied(`\x1b]52;c;${b64(new Uint8Array([0x61, 0xff, 0x62]))}\x07`)).toBe('a\ufffdb'); + }); + + test('iTerm2 OSC 1337 Copy reaches the clipboard too', () => { + expect(copied(`\x1b]1337;Copy=:${b64('iterm')}\x07`)).toBe('iterm'); + }); + + test('a sequence split across writes is reported when it ends; the last write wins', () => { + const t = term(); + t.write(`\x1b]52;c;${b64('split')}`); + expect(t.readEffects().clipboard).toBeNull(); + t.write(`\x07\x1b]52;c;${b64('first')}\x07\x1b]52;c;${b64('last')}\x07`); + expect(t.readEffects().clipboard).toBe('last'); + t.free(); + }); + + test('a sequence over 8 MiB is dropped, and the terminal carries on', () => { + const t = term(); + const fits = 'a'.repeat(5 * 1024 * 1024); + t.write(`\x1b]52;c;${b64(fits)}\x07`); + expect(t.readEffects().clipboard?.length).toBe(fits.length); + t.write(`\x1b]52;c;${b64('a'.repeat(6 * 1024 * 1024))}\x07after`); + expect(t.readEffects().clipboard).toBeNull(); + expect(rowText(t, 0)).toBe('after'); + t.free(); + }); + + test('with clipboardWrite off, nothing is reported', () => { + const off = { ...THEME, clipboardWrite: false }; + expect(copied(`\x1b]52;c;${b64('hello')}\x07`, off)).toBeNull(); + expect(copied('\x1b]52;c;\x07', off)).toBeNull(); + expect(copied(`\x1b]1337;Copy=:${b64('iterm')}\x07`, off)).toBeNull(); + }); +}); + +describe('official engine: desktop notifications (ADR 056)', () => { + /** The notifications one write reports. */ + function notified(seq: string, config: object = THEME) { + const t = term(80, 24, config); + t.write(seq); + const { notifications } = t.readEffects(); + expect(t.readEffects().notifications).toEqual([]); + // Neither sequence has a reply. + expect(t.readResponse()).toBeNull(); + t.free(); + return notifications; + } + + test('OSC 9 is a notification with no title, ended by BEL or ST', () => { + expect(notified('\x1b]9;Build done\x07')).toEqual([{ title: '', body: 'Build done' }]); + expect(notified('\x1b]9;héllo 中文 🎉; with; semicolons\x1b\\')).toEqual([ + { title: '', body: 'héllo 中文 🎉; with; semicolons' }, + ]); + }); + + test('OSC 777;notify has a title and a body; the body may hold semicolons', () => { + expect(notified('\x1b]777;notify;Claude Code;Needs your input\x07')).toEqual([ + { title: 'Claude Code', body: 'Needs your input' }, + ]); + expect(notified('\x1b]777;notify;t;a;b\x07')).toEqual([{ title: 't', body: 'a;b' }]); + expect(notified('\x1b]777;notify;;body\x07')).toEqual([{ title: '', body: 'body' }]); + }); + + test("ConEmu's OSC 9;<n> forms are not notifications", () => { + for (const seq of [ + '9;4;1;50', // progress + '9;4;0', + '9;1;100', // sleep + '9;2;message box', + '9;3;tab title', + '9;5', // wait for input + '9;6;macro', + '9;7;cmd', + '9;8;PATH', + '9;9;/tmp', + '9;10', + '9;11;comment', + '9;12', // prompt start + ]) { + expect(notified(`\x1b]${seq}\x07`)).toEqual([]); + } + }); + + test('an OSC 9 that only looks like ConEmu is a notification, as in Ghostty', () => { + expect(notified('\x1b]9;4\x07')).toEqual([{ title: '', body: '4' }]); + expect(notified('\x1b]9;42 tests passed\x07')).toEqual([ + { title: '', body: '42 tests passed' }, + ]); + }); + + test('OSC 777 other than notify, or with no title, is ignored', () => { + expect(notified('\x1b]777;other;a;b\x07')).toEqual([]); + expect(notified('\x1b]777;notify;only a title\x07')).toEqual([]); + }); + + test('a sequence split across writes is reported when it ends', () => { + const t = term(); + t.write('\x1b]777;notify;Do'); + expect(t.readEffects().notifications).toEqual([]); + t.write('ne;all green\x07\x1b]9;second\x07'); + expect(t.readEffects().notifications).toEqual([ + { title: 'Done', body: 'all green' }, + { title: '', body: 'second' }, + ]); + t.free(); + }); + + test('with desktopNotifications off, nothing is reported', () => { + const off = { ...THEME, desktopNotifications: false }; + expect(notified('\x1b]9;Build done\x07', off)).toEqual([]); + expect(notified('\x1b]777;notify;t;b\x07', off)).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/engine/grid-refs.test.ts b/src/client/vendor/tests/engine/grid-refs.test.ts new file mode 100644 index 0000000..fafbe75 --- /dev/null +++ b/src/client/vendor/tests/engine/grid-refs.test.ts @@ -0,0 +1,36 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// Cells read through Ghostty grid refs (ADR 042, ADR 061): OSC 8 hyperlink URIs on screen +// and in scrollback, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: hyperlinks (ADR 042)', () => { + test('marks OSC 8 hyperlinks and resolves their URIs on screen and in scrollback', () => { + const t = term(40, 5); + t.write('\x1b]8;;https://example.com/a\x1b\\link\x1b]8;;\x1b\\ plain'); + expect((t.getLine(0) ?? []).slice(0, 6).map((c) => c.hyperlink_id)).toEqual([1, 1, 1, 1, 0, 0]); + expect(t.getHyperlinkUri(0, 0)).toBe('https://example.com/a'); + expect(t.getHyperlinkUri(0, 5)).toBeNull(); + t.write('\r\n'.repeat(10)); + expect(t.getScrollbackLength()).toBeGreaterThan(0); + expect(t.getScrollbackHyperlinkUri(0, 3)).toBe('https://example.com/a'); + expect(t.getScrollbackHyperlinkUri(0, 6)).toBeNull(); + // Longer than the first buffer tried. + const long = `https://example.com/${'p'.repeat(1500)}`; + t.write(`\x1b]8;;${long}\x1b\\long\x1b]8;;\x1b\\`); + expect(t.getHyperlinkUri(t.getCursor().y, 0)).toBe(long); + t.free(); + }); +}); diff --git a/src/client/vendor/tests/engine/key-encoder-binding.test.ts b/src/client/vendor/tests/engine/key-encoder-binding.test.ts new file mode 100644 index 0000000..8a4600f --- /dev/null +++ b/src/client/vendor/tests/engine/key-encoder-binding.test.ts @@ -0,0 +1,30 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty } from '../../ghostty-web/ghostty'; +import { Key, KeyAction, KeyEncoderOption, Mods } from '../../ghostty-web/types'; +import { loadGhostty } from '../support/wasm'; + +// The wasm key encoder (ADR 042, ADR 061), made by the Ghostty factory. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +describe('official engine: key encoder', () => { + test('encodes keys by their official names', () => { + const enc = vt.createKeyEncoder(); + const encode = (key: Key, mods = Mods.NONE, utf8?: string) => + new TextDecoder().decode(enc.encode({ action: KeyAction.PRESS, key, mods, utf8 })); + expect(encode(Key.UP)).toBe('\x1b[A'); + expect(encode(Key.ENTER)).toBe('\r'); + expect(encode(Key.C, Mods.CTRL, 'c')).toBe('\x03'); + expect(encode(Key.ONE, Mods.NONE, '1')).toBe('1'); + expect(encode(Key.GRAVE, Mods.NONE, '`')).toBe('`'); + expect(encode(Key.F1)).toBe('\x1bOP'); + expect(encode(Key.PASTE)).toBe(''); + enc.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, true); + expect(encode(Key.UP)).toBe('\x1bOA'); + enc.dispose(); + }); +}); diff --git a/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts new file mode 100644 index 0000000..8f1cf98 --- /dev/null +++ b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts @@ -0,0 +1,186 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { + Ghostty, + GhosttyTerminal, + MouseInput, + OfficialTerminal, +} from '../../ghostty-web/ghostty'; +import { Mods } from '../../ghostty-web/types'; +import { THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// The wasm mouse encoder (ADR 049, ADR 061): every tracking mode and report format, the +// modifiers, the padding and the last-cell de-dup, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: mouse encoder (ADR 049)', () => { + // A 40x10 grid of 10 px cells with 5 px of padding on every side: the screen is + // 410x110, and cell (col, row) 1-based spans x 5+10(col-1) .. 5+10col. + const GEOMETRY = { + screenWidth: 410, + screenHeight: 110, + cellWidth: 10, + cellHeight: 10, + paddingTop: 5, + paddingRight: 5, + paddingBottom: 5, + paddingLeft: 5, + }; + const at = (col: number, row: number) => ({ x: 5 + 10 * col - 5, y: 5 + 10 * row - 5 }); + const tracking = (modes: string, cols = 40) => { + const t = term(cols, 10); + t.write(modes); + t.setMouseGeometry({ ...GEOMETRY, screenWidth: 10 + 10 * cols }); + return t; + }; + const report = ( + t: GhosttyTerminal, + action: MouseInput['action'], + button: MouseInput['button'], + pos: { x: number; y: number }, + mods = Mods.NONE, + anyButtonPressed = action === 'press', + ) => + new TextDecoder('latin1').decode( + t.encodeMouse({ action, button, mods, ...pos, anyButtonPressed }), + ); + + test('SGR clicks carry Shift, Alt and Ctrl, not Super', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'left', at(3, 2))).toBe('\x1b[<0;3;2M'); + expect(report(t, 'release', 'left', at(3, 2))).toBe('\x1b[<0;3;2m'); + expect(report(t, 'press', 'left', at(3, 2), Mods.ALT)).toBe('\x1b[<8;3;2M'); + expect(report(t, 'press', 'left', at(3, 2), Mods.SUPER)).toBe('\x1b[<0;3;2M'); + expect(report(t, 'press', 'right', at(3, 2), Mods.SHIFT | Mods.CTRL)).toBe('\x1b[<22;3;2M'); + expect(report(t, 'press', 'middle', at(3, 2))).toBe('\x1b[<1;3;2M'); + t.free(); + }); + + test('back and forward are buttons 8 and 9 (128, 129)', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'eight', at(1, 1))).toBe('\x1b[<128;1;1M'); + expect(report(t, 'release', 'nine', at(1, 1))).toBe('\x1b[<129;1;1m'); + t.free(); + }); + + test('the wheel is buttons 4 and 5 in the format the app chose', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'four', at(7, 4), Mods.NONE, false)).toBe('\x1b[<64;7;4M'); + expect(report(t, 'press', 'five', at(7, 4), Mods.CTRL, false)).toBe('\x1b[<81;7;4M'); + t.write('\x1b[?1006l'); + expect(report(t, 'press', 'four', at(7, 4), Mods.NONE, false)).toBe("\x1b[M`'$"); + t.free(); + }); + + test('any-event tracking reports hover as 35, once per cell', () => { + const t = tracking('\x1b[?1003h\x1b[?1006h'); + const hover = (x: number, y: number) => report(t, 'motion', null, { x, y }); + expect(hover(26, 16)).toBe('\x1b[<35;3;2M'); + expect(hover(29, 19)).toBe(''); + expect(hover(36, 19)).toBe('\x1b[<35;4;2M'); + // A press sets the last cell too + expect(report(t, 'press', 'left', at(5, 2))).toBe('\x1b[<0;5;2M'); + expect(report(t, 'motion', 'left', at(5, 2), Mods.NONE, true)).toBe(''); + expect(report(t, 'motion', 'left', at(6, 2), Mods.NONE, true)).toBe('\x1b[<32;6;2M'); + t.free(); + }); + + test('a mode change resets the last cell', () => { + const t = tracking('\x1b[?1003h\x1b[?1006h'); + expect(report(t, 'motion', null, at(2, 2))).toBe('\x1b[<35;2;2M'); + expect(report(t, 'motion', null, at(2, 2))).toBe(''); + // The format last set wins + t.write('\x1b[?1015h'); + expect(report(t, 'motion', null, at(2, 2))).toBe('\x1b[67;2;2M'); + t.free(); + }); + + test('normal tracking (1000) reports no motion, button tracking (1002) only drags', () => { + const t = tracking('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'motion', 'left', at(2, 2), Mods.NONE, true)).toBe(''); + t.write('\x1b[?1002h'); + expect(report(t, 'motion', null, at(3, 2))).toBe(''); + expect(report(t, 'motion', 'left', at(3, 2), Mods.NONE, true)).toBe('\x1b[<32;3;2M'); + t.free(); + }); + + test('X10 mode 9 reports presses of left, middle and right only, without modifiers', () => { + const t = tracking('\x1b[?9h'); + expect(report(t, 'press', 'left', at(5, 3), Mods.SHIFT)).toBe('\x1b[M %#'); + expect(report(t, 'release', 'left', at(5, 3))).toBe(''); + expect(report(t, 'press', 'four', at(5, 3), Mods.NONE, false)).toBe(''); + expect(report(t, 'press', 'eight', at(5, 3))).toBe(''); + t.free(); + }); + + test('X10 format: releases are button 3, and columns past 95 need bytes above 0x7f', () => { + const t = tracking('\x1b[?1000h', 120); + expect(report(t, 'press', 'left', at(5, 3), Mods.ALT)).toBe('\x1b[M(%#'); + expect(report(t, 'release', 'left', at(5, 3))).toBe('\x1b[M#%#'); + expect(report(t, 'press', 'left', at(95, 1))).toBe('\x1b[M \x7f!'); + expect([ + ...t.encodeMouse({ + action: 'press', + button: 'left', + mods: 0, + ...at(96, 1), + anyButtonPressed: true, + }), + ]).toEqual([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0x21]); + t.free(); + }); + + test('UTF-8 (1005), urxvt (1015) and SGR-pixels (1016) formats', () => { + const t = tracking('\x1b[?1000h\x1b[?1005h', 300); + const utf8 = new TextDecoder().decode( + t.encodeMouse({ + action: 'press', + button: 'left', + mods: 0, + ...at(200, 3), + anyButtonPressed: true, + }), + ); + expect(utf8).toBe(`\x1b[M ${String.fromCodePoint(232)}#`); + t.write('\x1b[?1005l\x1b[?1015h'); + expect(report(t, 'press', 'left', at(200, 3))).toBe('\x1b[32;200;3M'); + t.write('\x1b[?1015l\x1b[?1016h'); + // Pixels from the grid's origin, not the screen's + expect(report(t, 'press', 'left', { x: 26, y: 17 })).toBe('\x1b[<0;21;12M'); + expect(report(t, 'release', 'left', { x: 2, y: 17 })).toBe('\x1b[<0;-3;12m'); + t.free(); + }); + + test('the padding is the nearest edge cell; outside the screen only drags and releases', () => { + const t = tracking('\x1b[?1002h\x1b[?1006h'); + expect(report(t, 'press', 'left', { x: 1, y: 1 })).toBe('\x1b[<0;1;1M'); + expect(report(t, 'press', 'left', { x: 408, y: 108 })).toBe('\x1b[<0;40;10M'); + // Dragged past the right edge: clamped to the edge cell + expect(report(t, 'motion', 'left', { x: 600, y: 50 }, Mods.NONE, true)).toBe('\x1b[<32;40;5M'); + expect(report(t, 'motion', 'left', { x: -50, y: 50 }, Mods.NONE, true)).toBe('\x1b[<32;1;5M'); + expect(report(t, 'release', 'left', { x: -50, y: 50 }, Mods.NONE, false)).toBe('\x1b[<0;1;5m'); + // Outside with no button held: nothing + expect(report(t, 'motion', null, { x: -50, y: 60 }, Mods.NONE, false)).toBe(''); + expect(report(t, 'press', 'left', { x: -50, y: 60 }, Mods.NONE, false)).toBe(''); + t.free(); + }); + + test('no tracking reports nothing, and a reset terminal starts without tracking', () => { + const t = tracking(''); + expect(report(t, 'press', 'left', at(1, 1))).toBe(''); + t.write('\x1b[?1000h\x1b[?1006h'); + expect(report(t, 'press', 'left', at(1, 1))).toBe('\x1b[<0;1;1M'); + t.write('\x1bc'); + expect(report(t, 'press', 'left', at(1, 1))).toBe(''); + t.free(); + }); +}); diff --git a/src/client/vendor/tests/engine/official-terminal.test.ts b/src/client/vendor/tests/engine/official-terminal.test.ts new file mode 100644 index 0000000..41e1fba --- /dev/null +++ b/src/client/vendor/tests/engine/official-terminal.test.ts @@ -0,0 +1,333 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import { Ghostty, type OfficialTerminal } from '../../ghostty-web/ghostty'; +import { markedSession, rgb, rowText, scrollbackText, THEME } from '../support/terminal-text'; +import { instantiateWasm, loadGhostty } from '../support/wasm'; + +// The official libghostty-vt terminal (ADR 042, ADR 061) on the vendored wasm: what the +// facade itself does: the cursor and modes, resize, writes, prompt jumps and memory growth. +// Its bindings are tested beside it in engine/, each through this facade. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: cursor and modes', () => { + test('reports the cursor in active-screen cells', () => { + const t = term(); + t.write('ab\r\ncde'); + expect(t.getCursor()).toMatchObject({ x: 3, y: 1, visible: true }); + t.write('\x1b[?25l'); + expect(t.getCursor().visible).toBe(false); + t.free(); + }); + + test('applies DECSCUSR styles', () => { + const t = term(); + t.write('\x1b[5 q'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[2 q'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.write('\x1b[4 q'); + expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: false }); + t.write('\x1b[0 q'); + expect(t.getCursor().style).toBe('block'); + t.free(); + }); + + test('the configured cursor is the default that Ps 0 and RIS restore (ADR 049)', () => { + const t = term(40, 10, { ...THEME, cursorStyle: 'bar', cursorBlink: true }); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[2 q'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.write('\x1b[0 q'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b[4 q\x1bc'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.free(); + }); + + test('a new default shows at once only while the app has not set a shape', () => { + const t = term(); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.setDefaultCursor('underline', true); + expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: true }); + t.write('\x1b[6 q'); + t.setDefaultCursor('block', false); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: false }); + t.write('\x1b[ q'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.free(); + }); + + test('mode 12 does not change the blink; DECSCUSR does, and Ps 0 restores the config', () => { + const t = term(40, 10, { ...THEME, cursorStyle: 'bar', cursorBlink: true }); + // vim's t_vs / t_ve, as Ghostty with cursor-style-blink set (ADR 049) + t.write('\x1b[?12l\x1b[?25h'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + expect(t.getMode(12)).toBe(true); + t.write('\x1b[?12h'); + expect(t.getCursor().blinking).toBe(true); + // DECSCUSR steady, then mode 12 on in the same chunk and in the next + t.write('\x1b[2 q\x1b[?12h'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); + t.write('\x1b[?12;25h'); + expect(t.getCursor().blinking).toBe(false); + t.write('\x1b[1 q\x1b[?12l'); + expect(t.getCursor()).toMatchObject({ style: 'block', blinking: true }); + t.write('\x1b[4 q'); + expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: false }); + t.write('\x1b[?12h\x1b[0 q\x1b[?12l'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + // A DECSCUSR split across writes, after the blink default changed + t.setDefaultCursor('bar', false); + expect(t.getCursor().blinking).toBe(false); + t.write('\x1b[5 '); + t.write('q\x1b[?12l'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); + t.write('\x1b'); + t.write('c\x1b[?12h'); + expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: false }); + t.free(); + }); + + test('follows Ghostty: the shape is per screen, DECSTR restores', () => { + const t = term(); + t.write('\x1b[6 q'); + t.write('\x1b[?1049h\x1b[2 q'); + expect(t.getCursor().style).toBe('block'); + t.write('\x1b[?1049l'); + expect(t.getCursor().style).toBe('bar'); + // A sequence split across writes, and Ps out of range + t.write('\x1b[4'); + t.write(' q\x1b[7 q'); + expect(t.getCursor().style).toBe('underline'); + t.write('\x1b[!p'); + expect(t.getCursor().style).toBe('block'); + t.free(); + }); + + test('switches to the alternate screen and back', () => { + const t = term(20, 4); + t.write('main'); + t.write('\x1b[?1049h'); + expect(t.isAlternateScreen()).toBe(true); + t.write('\x1b[Halt'); + expect(rowText(t, 0)).toBe('alt'); + expect(t.getScrollbackLength()).toBe(0); + t.write('\x1b[?1049l'); + expect(t.isAlternateScreen()).toBe(false); + expect(rowText(t, 0)).toBe('main'); + t.free(); + }); + + test('reports mouse tracking and the other DEC and ANSI modes', () => { + const t = term(); + expect(t.hasMouseTracking()).toBe(false); + t.write('\x1b[?1000h'); + expect(t.hasMouseTracking()).toBe(true); + expect(t.getMode(1000)).toBe(true); + t.write('\x1b[?1002h\x1b[?1006h'); + expect(t.getMode(1002)).toBe(true); + expect(t.getMode(1006)).toBe(true); + expect(t.getMode(1003)).toBe(false); + t.write('\x1b[?1003h'); + expect(t.getMode(1003)).toBe(true); + t.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l'); + expect(t.hasMouseTracking()).toBe(false); + t.write('\x1b[?2004h\x1b[?1004h\x1b[?1h\x1b[4h'); + expect(t.getMode(2004)).toBe(true); + expect(t.getMode(1004)).toBe(true); + expect(t.getMode(1)).toBe(true); + expect(t.getMode(4, true)).toBe(true); + expect(t.getMode(4, false)).toBe(false); + t.free(); + }); +}); + +describe('official engine: scrollback, resize and the viewport', () => { + test('reflows wrapped lines on resize', () => { + const t = term(20, 5); + t.write('abcdefghijklmnopqrstuvwxyz0123'); + expect(rowText(t, 0)).toBe('abcdefghijklmnopqrst'); + expect(rowText(t, 1)).toBe('uvwxyz0123'); + // A row is wrapped when it continues the row above (ghostty-web's semantics). + expect(t.isRowWrapped(0)).toBe(false); + expect(t.isRowWrapped(1)).toBe(true); + t.resize(30, 5); + expect(t.getDimensions()).toEqual({ cols: 30, rows: 5 }); + expect(rowText(t, 0)).toBe('abcdefghijklmnopqrstuvwxyz0123'); + expect(t.getLine(0)?.length).toBe(30); + t.resize(12, 6); + expect([rowText(t, 0), rowText(t, 1), rowText(t, 2)]).toEqual([ + 'abcdefghijkl', + 'mnopqrstuvwx', + 'yz0123', + ]); + expect(t.cols).toBe(12); + expect(t.rows).toBe(6); + t.free(); + }); + + test('sends one mode 2048 report per real size change', () => { + const t = term(80, 24); + t.setCellPixelSize(9, 18); + t.write('\x1b[?2048h'); + expect(t.readResponse()).toBe('\x1b[48;24;80;432;720t'); + t.resize(100, 30, 9, 18); + expect(t.readResponse()).toBe('\x1b[48;30;100;540;900t'); + t.resize(100, 30, 9, 18); + t.setCellPixelSize(9, 18); + expect(t.readResponse()).toBeNull(); + t.setCellPixelSize(10, 20); + expect(t.readResponse()).toBe('\x1b[48;30;100;600;1000t'); + t.free(); + }); + + test('makes one ghostty_terminal_resize call per grid or pixel change', async () => { + const real = (await instantiateWasm()).exports as Record<string, unknown>; + let resizes = 0; + const resize = real.ghostty_terminal_resize as (...args: number[]) => number; + const spy = { + ...real, + ghostty_terminal_resize: (...args: number[]) => { + resizes++; + return resize(...args); + }, + }; + const spied = new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]); + const t = spied.createTerminal(80, 24); + // open(): the initial cell size. + t.setCellPixelSize(9, 18); + expect(resizes).toBe(1); + // Repeated fits with nothing changed. + t.setCellPixelSize(9, 18); + t.resize(80, 24, 9, 18); + expect(resizes).toBe(1); + // A zoom that changes grid and pixels: one call. + t.resize(90, 26, 10, 20); + t.setCellPixelSize(10, 20); + expect(resizes).toBe(2); + // A zoom or DPR change that keeps the grid: one call. + t.resize(90, 26, 11, 22); + t.setCellPixelSize(11, 22); + expect(resizes).toBe(3); + t.free(); + }); +}); + +describe('official engine: cursor at a prompt (ADR 054)', () => { + test('follows OSC 133 marks, and is false without them and on the alternate screen', () => { + const t = term(); + t.write('$ '); + expect(t.cursorAtPrompt()).toBe(false); + t.write('\r\n\x1b]133;A\x07~ > \x1b]133;B\x07ls'); + expect(t.cursorAtPrompt()).toBe(true); + t.write('\x1b[?1049h'); + expect(t.cursorAtPrompt()).toBe(false); + t.write('\x1b[?1049l'); + expect(t.cursorAtPrompt()).toBe(true); + // The command runs: its output is not a prompt + t.write('\r\n\x1b]133;C\x07output\r\nmore'); + expect(t.cursorAtPrompt()).toBe(false); + t.free(); + }); + + test('writeUntilGround writes up to the end of a cut-off sequence', () => { + const t = term(); + const bytes = (s: string) => new TextEncoder().encode(s); + expect(t.writeUntilGround(bytes(''))).toBe(0); + expect(t.writeUntilGround(bytes('ab'))).toBe(0); + t.write('a\x1b[3'); + expect(t.writeUntilGround(new Uint8Array(0))).toBeNull(); + expect(t.writeUntilGround(bytes('1;'))).toBeNull(); + expect(t.writeUntilGround(bytes('2mbc'))).toBe(2); + t.write('\x1b]2;ti'); + expect(t.writeUntilGround(bytes('tle\x07x'))).toBe(4); + // A multi-byte character cut in two is a sequence too + t.write(new Uint8Array([0xc3])); + expect(t.writeUntilGround(new Uint8Array([0xa9, 0x41]))).toBe(1); + t.free(); + }); +}); + +describe('official engine: memory growth', () => { + test('reads correct cells and replies after the wasm memory has grown', () => { + const t = term(120, 40, { scrollbackLines: 0 }); + const memory = vt.ex.memory; + const before = memory.buffer.byteLength; + let i = 0; + while (memory.buffer.byteLength === before && i < 200000) { + let chunk = ''; + for (let j = 0; j < 500; j++, i++) { + chunk += `\x1b[38;2;${i % 256};${(i >> 8) % 256};${(i * 7) % 256}mrow ${i}\x1b[0m\r\n`; + } + t.write(chunk); + } + expect(memory.buffer.byteLength).toBeGreaterThan(before); + t.write('\x1b[6n'); + expect(t.readResponse()).toBe('\x1b[40;1R'); + expect(rowText(t, 38)).toBe(`row ${i - 1}`); + const cell = t.getLine(38)?.[0]; + expect(cell && rgb(cell)).toBe( + (((i - 1) % 256) << 16) | ((((i - 1) >> 8) % 256) << 8) | (((i - 1) * 7) % 256), + ); + expect(scrollbackText(t, 0)).toBe('row 0'); + t.free(); + }); +}); + +describe('official engine: prompt jumps (ADR 057)', () => { + /** Prompts on rows 0, 7 and 14, and the current one on row 21 of 22. */ + const session = () => { + const t = term(20, 5); + t.write(markedSession()); + return t; + }; + + test('promptJumpRow moves the top row by prompts, as Ghostty, and stops at either end', () => { + const t = session(); + expect(t.getScrollbackLength()).toBe(17); + // Up from the bottom: the nearest prompt above the top row + expect(t.promptJumpRow(17, -1)).toBe(14); + expect(t.promptJumpRow(14, -1)).toBe(7); + expect(t.promptJumpRow(17, -2)).toBe(7); + expect(t.promptJumpRow(7, -5)).toBe(0); + expect(t.promptJumpRow(0, -1)).toBe(0); + expect(t.promptJumpRow(10, -1)).toBe(7); + // Down past the last prompt in the scrollback: the bottom + expect(t.promptJumpRow(0, 1)).toBe(7); + expect(t.promptJumpRow(14, 1)).toBe(17); + expect(t.promptJumpRow(17, 1)).toBe(17); + expect(t.promptJumpRow(10, 0)).toBe(10); + t.free(); + }); + + test('promptJumpRow leaves the viewport at the bottom, so output and reads are unchanged', () => { + const t = session(); + t.promptJumpRow(17, -2); + expect(rowText(t, 4)).toBe('$'); + expect(scrollbackText(t, 7)).toBe('$ two'); + t.write('ls'); + expect(rowText(t, 4)).toBe('$ ls'); + t.free(); + }); + + test('promptJumpRow does nothing without marks or on the alternate screen', () => { + const t = term(20, 5); + t.write(Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\r\n')); + expect(t.getScrollbackLength()).toBe(25); + expect(t.promptJumpRow(25, -1)).toBe(25); + expect(t.promptJumpRow(10, 1)).toBe(10); + t.free(); + const alt = session(); + alt.write('\x1b[?1049h'); + expect(alt.promptJumpRow(0, -1)).toBe(0); + alt.free(); + }); +}); diff --git a/src/client/vendor/tests/engine/paste-focus-binding.test.ts b/src/client/vendor/tests/engine/paste-focus-binding.test.ts new file mode 100644 index 0000000..eb07129 --- /dev/null +++ b/src/client/vendor/tests/engine/paste-focus-binding.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty, GhosttyTerminal, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// The wasm paste and focus encoders (ADR 049, ADR 053, ADR 061): bracketed and plain +// pastes made safe, and focus reports, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: paste (ADR 049)', () => { + const paste = (t: GhosttyTerminal, text: string) => { + t.paste(text); + return t.readResponse(); + }; + + test('brackets every paste while mode 2004 is on, single lines too', () => { + const t = term(); + t.write('\x1b[?2004h'); + expect(paste(t, 'hello')).toBe('\x1b[200~hello\x1b[201~'); + expect(paste(t, 'one\ntwo\r\n')).toBe('\x1b[200~one\ntwo\r\n\x1b[201~'); + t.free(); + }); + + test('without 2004, sends the text with newlines as CR', () => { + const t = term(); + expect(paste(t, 'hello')).toBe('hello'); + expect(paste(t, 'one\ntwo')).toBe('one\rtwo'); + expect(paste(t, 'one\r\ntwo\n')).toBe('one\r\rtwo\r'); + t.free(); + }); + + test('replaces unsafe control bytes with spaces in both modes, keeping tabs', () => { + const t = term(); + const unsafe = 'a\x00b\x08c\x1bd\x7fe\x03f\x15g\x1ah\x16i\x17j\tk'; + expect(paste(t, unsafe)).toBe('a b c d e f g h i j\tk'); + t.write('\x1b[?2004h'); + // An end marker inside the text cannot end the paste early + expect(paste(t, 'x\x1b[201~rm -rf ~\n')).toBe('\x1b[200~x [201~rm -rf ~\n\x1b[201~'); + t.free(); + }); + + test('passes UTF-8 through, and an empty paste writes nothing', () => { + const t = term(); + expect(paste(t, 'héllo 中 🙂')).toBe('héllo 中 🙂'); + expect(paste(t, '')).toBeNull(); + t.free(); + }); + + test('a large paste arrives whole and in order', () => { + const t = term(); + t.write('\x1b[?2004h'); + const text = Array.from({ length: 5000 }, (_, i) => `line ${i}`).join('\n'); + expect(paste(t, text)).toBe(`\x1b[200~${text}\x1b[201~`); + t.free(); + }); +}); + +describe('official engine: focus reports (ADR 053)', () => { + test('encodes focus gained and lost as CSI I and CSI O', () => { + const t = term(); + const text = (focused: boolean) => new TextDecoder().decode(t.encodeFocus(focused)); + expect(text(true)).toBe('\x1b[I'); + expect(text(false)).toBe('\x1b[O'); + // The engine sends nothing itself when the mode is set: the embedder does. + t.write('\x1b[?1004h'); + expect(t.getMode(1004)).toBe(true); + expect(t.readResponse()).toBeNull(); + t.free(); + }); +}); diff --git a/src/client/vendor/tests/engine/search-binding.test.ts b/src/client/vendor/tests/engine/search-binding.test.ts new file mode 100644 index 0000000..c704a34 --- /dev/null +++ b/src/client/vendor/tests/engine/search-binding.test.ts @@ -0,0 +1,122 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// The wasm search (ADR 055, ADR 061): matches found a step at a time, newest first, the +// selected match, and the restart and pruning rules, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: search (ADR 055)', () => { + /** Runs the search to completion and returns its matches as [col, row, col, row] tuples. */ + function run(t: OfficialTerminal): number[][] { + while (t.searchStep(50)) {} + const m = t.searchMatches(); + return Array.from({ length: m.length / 4 }, (_, i) => Array.from(m.slice(i * 4, i * 4 + 4))); + } + + test('finds ASCII letters case-insensitively, newest first, scrollback included', () => { + const t = term(20, 3); + t.write('Error one\r\nok\r\nerror two\r\nok\r\nERROR three'); + t.setSearch('error'); + expect(run(t)).toEqual([ + [0, 4, 4, 4], + [0, 2, 4, 2], + [0, 0, 4, 0], + ]); + expect(t.getScrollbackLength()).toBe(2); + expect(t.searchState()).toEqual({ total: 3, selected: null }); + t.free(); + }); + + test('a match spans a soft wrap', () => { + const t = term(5, 3); + t.write('abcdefg'); + t.setSearch('def'); + expect(run(t)).toEqual([[3, 0, 0, 1]]); + t.free(); + }); + + test('next moves toward older output and wraps; previous moves back', () => { + const t = term(10, 4); + t.write('a x\r\nb x\r\nc x'); + t.setSearch('x'); + run(t); + const row = () => t.searchSelectedMatch()?.start.row; + expect(t.searchSelectedMatch()).toBeNull(); + expect(t.searchSelect('next')).toBe(true); + expect([t.searchState().selected, row()]).toEqual([0, 2]); + t.searchSelect('next'); + expect([t.searchState().selected, row()]).toEqual([1, 1]); + t.searchSelect('next'); + t.searchSelect('next'); + expect([t.searchState().selected, row()]).toEqual([0, 2]); + t.searchSelect('previous'); + expect([t.searchState().selected, row()]).toEqual([2, 0]); + expect(t.searchSelectedMatch()).toEqual({ start: { col: 2, row: 0 }, end: { col: 2, row: 0 } }); + t.free(); + }); + + test('picks up output after a step, and leaves the view alone', () => { + const t = term(10, 3); + t.write('x\r\n'); + t.setSearch('x'); + expect(run(t)).toHaveLength(1); + expect(t.searchStep(50)).toBe(false); + t.write('x\r\nx\r\nx\r\n'); + expect(run(t)).toHaveLength(4); + t.searchSelect('previous'); + expect(t.searchSelectedMatch()?.start.row).toBe(0); + // SCROLL_NONE: the top row of Ghostty's viewport is still the active screen's + expect(t.getLine(0)?.[0].codepoint).toBe('x'.codePointAt(0)); + t.free(); + }); + + test('a new needle restarts the search; an empty one ends it', () => { + const t = term(10, 3); + t.write('foo bar'); + t.setSearch('foo'); + expect(run(t)).toEqual([[0, 0, 2, 0]]); + t.setSearch('BAR'); + expect(run(t)).toEqual([[4, 0, 6, 0]]); + t.setSearch(''); + expect(t.searchStep(50)).toBe(false); + expect(t.searchMatches()).toHaveLength(0); + expect(t.searchState()).toEqual({ total: 0, selected: null }); + expect(t.searchSelect('next')).toBe(false); + t.free(); + }); + + test('matches pruned from the scrollback go away', () => { + const t = term(10, 2, { scrollbackLines: 1000 }); + t.write('x0\r\n'); + t.setSearch('x'); + expect(run(t)).toHaveLength(1); + t.write('y\r\n'.repeat(5000)); + expect(run(t)).toHaveLength(0); + t.free(); + }); + + test('reads more matches than its buffer held', () => { + const t = term(40, 5, { scrollbackLines: 1000 }); + t.setSearch('ab'); + t.write('ab\r\n'); + expect(run(t)).toHaveLength(1); + t.write('ab ab ab ab ab\r\n'.repeat(100)); + const all = run(t); + expect(all).toHaveLength(501); + expect(t.searchState().total).toBe(501); + // Newest to oldest: rows never increase + for (let i = 1; i < all.length; i++) expect(all[i][1]).toBeLessThanOrEqual(all[i - 1][1]); + t.free(); + }); +}); diff --git a/src/client/vendor/tests/engine/selection-binding.test.ts b/src/client/vendor/tests/engine/selection-binding.test.ts new file mode 100644 index 0000000..88c1479 --- /dev/null +++ b/src/client/vendor/tests/engine/selection-binding.test.ts @@ -0,0 +1,107 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; +import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { markedSession, THEME } from '../support/terminal-text'; +import { loadGhostty } from '../support/wasm'; + +// Ghostty's selection rules (ADR 050, ADR 057, ADR 061): words, lines and command output +// picked from a cell, and the selected text, tested through OfficialTerminal. + +let vt: Ghostty; + +beforeAll(async () => { + vt = await loadGhostty(); +}); + +function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { + return vt.createTerminal(cols, rows, config) as OfficialTerminal; +} + +describe('official engine: selection (ADR 050)', () => { + test('selectWord uses Ghostty word boundaries and follows soft wraps', () => { + const t = term(10, 4); + t.write('a=b?c&d x\r\nab "cd" e\r\nx foo/bar/baz'); + const at = (col: number, row: number) => t.selectWord({ col, row }); + expect(at(2, 0)).toEqual({ start: { col: 0, row: 0 }, end: { col: 6, row: 0 } }); + expect(at(4, 1)).toEqual({ start: { col: 4, row: 1 }, end: { col: 5, row: 1 } }); + // "foo/bar/baz" wraps after column 9: the word runs on into the next row + expect(at(3, 2)).toEqual({ start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }); + expect(at(1, 3)).toEqual({ start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }); + expect(at(9, 3)).toBeNull(); + t.free(); + }); + + test('selectWord and selectLine take screen rows, scrollback included', () => { + const t = term(10, 2); + t.write('first one\r\nsecond\r\nthird'); + expect(t.getScrollbackLength()).toBe(1); + expect(t.selectWord({ col: 1, row: 0 })).toEqual({ + start: { col: 0, row: 0 }, + end: { col: 4, row: 0 }, + }); + expect(t.selectLine({ col: 0, row: 2 })).toEqual({ + start: { col: 0, row: 2 }, + end: { col: 4, row: 2 }, + }); + expect(t.selectionText({ col: 0, row: 0 }, { col: 9, row: 2 })).toBe( + 'first one\nsecond\nthird', + ); + t.free(); + }); + + test('selectLine follows soft wraps and leaves out outer whitespace; a blank line is null', () => { + const t = term(6, 4); + t.write(' abcdefgh \r\n\r\nx'); + expect(t.selectLine({ col: 1, row: 1 })).toEqual({ + start: { col: 2, row: 0 }, + end: { col: 3, row: 1 }, + }); + expect(t.selectLine({ col: 0, row: 2 })).toBeNull(); + t.free(); + }); + + test('selectionText joins soft-wrapped rows, trims, takes either order and clamps', () => { + const t = term(5, 3); + t.write('abcdefg\r\nhi '); + expect(t.selectionText({ col: 0, row: 0 }, { col: 4, row: 2 })).toBe('abcdefg\nhi'); + expect(t.selectionText({ col: 4, row: 2 }, { col: 0, row: 0 })).toBe('abcdefg\nhi'); + expect(t.selectionText({ col: -3, row: -1 }, { col: 99, row: 99 })).toBe('abcdefg\nhi'); + expect(t.selectionText({ col: 3, row: 1 }, { col: 4, row: 1 })).toBe(''); + t.free(); + }); + + test('selectionText reads the alternate screen while it is active', () => { + const t = term(10, 2); + t.write('a\r\nb\r\nc\x1b[?1049h\x1b[Halt'); + expect(t.getScrollbackLength()).toBe(0); + expect(t.selectionText({ col: 0, row: 0 }, { col: 9, row: 0 })).toBe('alt'); + t.free(); + }); +}); + +describe('official engine: command output (ADR 057)', () => { + /** Prompts on rows 0, 7 and 14, and the current one on row 21 of 22. */ + const session = () => { + const t = term(20, 5); + t.write(markedSession()); + return t; + }; + + test('selectOutput selects the output of the command at a cell, null elsewhere', () => { + const t = session(); + const one = { start: { col: 0, row: 1 }, end: { col: 4, row: 6 } }; + expect(t.selectOutput({ col: 0, row: 1 })).toEqual(one); + expect(t.selectOutput({ col: 3, row: 4 })).toEqual(one); + expect(t.selectionText(one.start, one.end)).toBe('one 0\none 1\none 2\none 3\none 4\none 5'); + expect(t.selectOutput({ col: 0, row: 10 })).toEqual({ + start: { col: 0, row: 8 }, + end: { col: 4, row: 13 }, + }); + // The prompt row, and a terminal with no marks + expect(t.selectOutput({ col: 0, row: 21 })).toBeNull(); + t.free(); + const plain = term(20, 5); + plain.write('a\r\nb'); + expect(plain.selectOutput({ col: 0, row: 0 })).toBeNull(); + plain.free(); + }); +}); diff --git a/src/client/vendor/tests/official-terminal.test.ts b/src/client/vendor/tests/official-terminal.test.ts deleted file mode 100644 index 2128912..0000000 --- a/src/client/vendor/tests/official-terminal.test.ts +++ /dev/null @@ -1,1135 +0,0 @@ -import { beforeAll, describe, expect, test } from 'bun:test'; -import { - Ghostty, - type GhosttyTerminal, - type MouseInput, - type OfficialTerminal, -} from '../ghostty-web/ghostty'; -import { Key, KeyAction, KeyEncoderOption, Mods } from '../ghostty-web/types'; -import { bgRgb, FG, RED, rgb, rowText, scrollbackText, THEME } from './support/terminal-text'; -import { instantiateWasm, loadGhostty } from './support/wasm'; - -// The official libghostty-vt binding (ADR 042) on the vendored wasm, loaded from disk. -// Cell decoding and the scrollback pages are tested beside their parts in engine/. - -let vt: Ghostty; - -beforeAll(async () => { - vt = await loadGhostty(); -}); - -function term(cols = 40, rows = 10, config: object = THEME): OfficialTerminal { - return vt.createTerminal(cols, rows, config) as OfficialTerminal; -} - -describe('official engine: cells', () => { - test('marks OSC 8 hyperlinks and resolves their URIs on screen and in scrollback', () => { - const t = term(40, 5); - t.write('\x1b]8;;https://example.com/a\x1b\\link\x1b]8;;\x1b\\ plain'); - expect((t.getLine(0) ?? []).slice(0, 6).map((c) => c.hyperlink_id)).toEqual([1, 1, 1, 1, 0, 0]); - expect(t.getHyperlinkUri(0, 0)).toBe('https://example.com/a'); - expect(t.getHyperlinkUri(0, 5)).toBeNull(); - t.write('\r\n'.repeat(10)); - expect(t.getScrollbackLength()).toBeGreaterThan(0); - expect(t.getScrollbackHyperlinkUri(0, 3)).toBe('https://example.com/a'); - expect(t.getScrollbackHyperlinkUri(0, 6)).toBeNull(); - // Longer than the first buffer tried. - const long = `https://example.com/${'p'.repeat(1500)}`; - t.write(`\x1b]8;;${long}\x1b\\long\x1b]8;;\x1b\\`); - expect(t.getHyperlinkUri(t.getCursor().y, 0)).toBe(long); - t.free(); - }); - - test('uses an explicitly black theme background instead of the default', () => { - const t = term(40, 5, { fgColor: FG, bgColor: 0 }); - t.write('\x1b]11;?\x07'); - expect(t.readResponse()).toBe('\x1b]11;rgb:0000/0000/0000\x07'); - t.free(); - }); - - test("sets a black palette entry as black and keeps Ghostty's default for an unset one", () => { - const fgs = (config: object) => { - const t = term(10, 3, config); - t.write('\x1b[30mA\x1b[31mB'); - t.update(); - const line = t.getLine(0) ?? []; - t.free(); - return [rgb(line[0]), rgb(line[1])]; - }; - const ghosttyBlack = fgs({})[0]; - expect(ghosttyBlack).not.toBe(0); - expect(fgs({ palette: [0x000000, RED] })).toEqual([0, RED]); - expect(fgs({ palette: [undefined, RED] })).toEqual([ghosttyBlack, RED]); - }); - - // Ghostty's render state copies the default colours only when both are set, so the - // binding always sets both, falling back to webtty's default 0xcccccc on black. - const defaults = (t: GhosttyTerminal) => { - t.write('\x1b[1;1HA'); - t.update(); - const cell = (t.getLine(0) ?? [])[0]; - t.readResponse(); - t.write('\x1b]10;?\x07'); - const osc10 = t.readResponse(); - t.write('\x1b]11;?\x07'); - const osc11 = t.readResponse(); - return { - cellFg: rgb(cell), - cellBg: bgRgb(cell), - osc10, - osc11, - }; - }; - - test('falls back to the default background when only the foreground is given', () => { - for (const config of [{ fgColor: FG }, { fgColor: FG, bgColor: undefined }]) { - const t = term(10, 3, config); - expect(defaults(t)).toEqual({ - cellFg: FG, - cellBg: 0, - osc10: '\x1b]10;rgb:d4d4/d4d4/d4d4\x07', - osc11: '\x1b]11;rgb:0000/0000/0000\x07', - }); - t.free(); - } - }); - - test('falls back to the default foreground when only the background is given', () => { - const t = term(10, 3, { bgColor: 0x202020 }); - expect(defaults(t)).toEqual({ - cellFg: 0xcccccc, - cellBg: 0x202020, - osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', - osc11: '\x1b]11;rgb:2020/2020/2020\x07', - }); - t.free(); - }); - - test('without theme colours still applies OSC 11 and DECSCNM', () => { - for (const config of [undefined, { scrollbackLines: 1000 }]) { - const t = vt.createTerminal(10, 3, config) as OfficialTerminal; - expect(defaults(t)).toEqual({ - cellFg: 0xcccccc, - cellBg: 0, - osc10: '\x1b]10;rgb:cccc/cccc/cccc\x07', - osc11: '\x1b]11;rgb:0000/0000/0000\x07', - }); - t.write('\x1b]11;rgb:20/40/60\x1b\\'); - t.update(); - expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0x204060); - t.write('\x1b]111\x1b\\\x1b[?5h'); - t.update(); - expect(bgRgb((t.getLine(0) ?? [])[0])).toBe(0xcccccc); - expect(rgb((t.getLine(0) ?? [])[0])).toBe(0); - t.free(); - } - }); -}); - -describe('official engine: cursor and modes', () => { - test('reports the cursor in active-screen cells', () => { - const t = term(); - t.write('ab\r\ncde'); - expect(t.getCursor()).toMatchObject({ x: 3, y: 1, visible: true }); - t.write('\x1b[?25l'); - expect(t.getCursor().visible).toBe(false); - t.free(); - }); - - test('applies DECSCUSR styles', () => { - const t = term(); - t.write('\x1b[5 q'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - t.write('\x1b[2 q'); - expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); - t.write('\x1b[4 q'); - expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: false }); - t.write('\x1b[0 q'); - expect(t.getCursor().style).toBe('block'); - t.free(); - }); - - test('the configured cursor is the default that Ps 0 and RIS restore (ADR 049)', () => { - const t = term(40, 10, { ...THEME, cursorStyle: 'bar', cursorBlink: true }); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - t.write('\x1b[2 q'); - expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); - t.write('\x1b[0 q'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - t.write('\x1b[4 q\x1bc'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - t.free(); - }); - - test('a new default shows at once only while the app has not set a shape', () => { - const t = term(); - expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); - t.setDefaultCursor('underline', true); - expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: true }); - t.write('\x1b[6 q'); - t.setDefaultCursor('block', false); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: false }); - t.write('\x1b[ q'); - expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); - t.free(); - }); - - test('mode 12 does not change the blink; DECSCUSR does, and Ps 0 restores the config', () => { - const t = term(40, 10, { ...THEME, cursorStyle: 'bar', cursorBlink: true }); - // vim's t_vs / t_ve, as Ghostty with cursor-style-blink set (ADR 049) - t.write('\x1b[?12l\x1b[?25h'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - expect(t.getMode(12)).toBe(true); - t.write('\x1b[?12h'); - expect(t.getCursor().blinking).toBe(true); - // DECSCUSR steady, then mode 12 on in the same chunk and in the next - t.write('\x1b[2 q\x1b[?12h'); - expect(t.getCursor()).toMatchObject({ style: 'block', blinking: false }); - t.write('\x1b[?12;25h'); - expect(t.getCursor().blinking).toBe(false); - t.write('\x1b[1 q\x1b[?12l'); - expect(t.getCursor()).toMatchObject({ style: 'block', blinking: true }); - t.write('\x1b[4 q'); - expect(t.getCursor()).toMatchObject({ style: 'underline', blinking: false }); - t.write('\x1b[?12h\x1b[0 q\x1b[?12l'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - // A DECSCUSR split across writes, after the blink default changed - t.setDefaultCursor('bar', false); - expect(t.getCursor().blinking).toBe(false); - t.write('\x1b[5 '); - t.write('q\x1b[?12l'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: true }); - t.write('\x1b'); - t.write('c\x1b[?12h'); - expect(t.getCursor()).toMatchObject({ style: 'bar', blinking: false }); - t.free(); - }); - - test('follows Ghostty: the shape is per screen, DECSTR restores', () => { - const t = term(); - t.write('\x1b[6 q'); - t.write('\x1b[?1049h\x1b[2 q'); - expect(t.getCursor().style).toBe('block'); - t.write('\x1b[?1049l'); - expect(t.getCursor().style).toBe('bar'); - // A sequence split across writes, and Ps out of range - t.write('\x1b[4'); - t.write(' q\x1b[7 q'); - expect(t.getCursor().style).toBe('underline'); - t.write('\x1b[!p'); - expect(t.getCursor().style).toBe('block'); - t.free(); - }); - - test('switches to the alternate screen and back', () => { - const t = term(20, 4); - t.write('main'); - t.write('\x1b[?1049h'); - expect(t.isAlternateScreen()).toBe(true); - t.write('\x1b[Halt'); - expect(rowText(t, 0)).toBe('alt'); - expect(t.getScrollbackLength()).toBe(0); - t.write('\x1b[?1049l'); - expect(t.isAlternateScreen()).toBe(false); - expect(rowText(t, 0)).toBe('main'); - t.free(); - }); - - test('reports mouse tracking and the other DEC and ANSI modes', () => { - const t = term(); - expect(t.hasMouseTracking()).toBe(false); - t.write('\x1b[?1000h'); - expect(t.hasMouseTracking()).toBe(true); - expect(t.getMode(1000)).toBe(true); - t.write('\x1b[?1002h\x1b[?1006h'); - expect(t.getMode(1002)).toBe(true); - expect(t.getMode(1006)).toBe(true); - expect(t.getMode(1003)).toBe(false); - t.write('\x1b[?1003h'); - expect(t.getMode(1003)).toBe(true); - t.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l'); - expect(t.hasMouseTracking()).toBe(false); - t.write('\x1b[?2004h\x1b[?1004h\x1b[?1h\x1b[4h'); - expect(t.getMode(2004)).toBe(true); - expect(t.getMode(1004)).toBe(true); - expect(t.getMode(1)).toBe(true); - expect(t.getMode(4, true)).toBe(true); - expect(t.getMode(4, false)).toBe(false); - t.free(); - }); -}); - -describe('official engine: scrollback, resize and the viewport', () => { - test('reflows wrapped lines on resize', () => { - const t = term(20, 5); - t.write('abcdefghijklmnopqrstuvwxyz0123'); - expect(rowText(t, 0)).toBe('abcdefghijklmnopqrst'); - expect(rowText(t, 1)).toBe('uvwxyz0123'); - // A row is wrapped when it continues the row above (ghostty-web's semantics). - expect(t.isRowWrapped(0)).toBe(false); - expect(t.isRowWrapped(1)).toBe(true); - t.resize(30, 5); - expect(t.getDimensions()).toEqual({ cols: 30, rows: 5 }); - expect(rowText(t, 0)).toBe('abcdefghijklmnopqrstuvwxyz0123'); - expect(t.getLine(0)?.length).toBe(30); - t.resize(12, 6); - expect([rowText(t, 0), rowText(t, 1), rowText(t, 2)]).toEqual([ - 'abcdefghijkl', - 'mnopqrstuvwx', - 'yz0123', - ]); - expect(t.cols).toBe(12); - expect(t.rows).toBe(6); - t.free(); - }); -}); - -describe('official engine: scrollback in lines (ADR 046)', () => { - /** Writes `n` numbered lines and returns how many scrollback rows the terminal kept. */ - function keep(config: object | undefined, n: number, cols = 80): number { - const t = vt.createTerminal(cols, 24, config) as OfficialTerminal; - let chunk = ''; - for (let i = 0; i < n; i++) { - chunk += `line ${i}\r\n`; - if (chunk.length > 1 << 20) { - t.write(chunk); - chunk = ''; - } - } - t.write(chunk); - t.update(); - const kept = t.getScrollbackLength(); - t.free(); - return kept; - } - - test('keeps about scrollbackLines lines, a page at most under', () => { - const kept = keep({ scrollbackLines: 10000 }, 15000); - expect(kept).toBeGreaterThan(9000); - expect(kept).toBeLessThanOrEqual(10000 + 200); - }); - - test('scrollbackLines 0 removes the line limit', () => { - expect(keep({ scrollbackLines: 0 }, 20000)).toBe(20000 - 24 + 1); - }); - - test('without scrollbackLines keeps the default 10,000 lines', () => { - for (const config of [undefined, THEME]) { - const kept = keep(config, 15000); - expect(kept).toBeGreaterThan(9000); - expect(kept).toBeLessThanOrEqual(10000 + 200); - } - }); -}); - -describe('official engine: effects and replies', () => { - test('reports the title and the bell once', () => { - const t = term(); - t.write('\x1b]2;first\x07'); - expect(t.readEffects()).toEqual({ - bell: false, - title: 'first', - clipboard: null, - notifications: [], - }); - t.write('\x07\x1b]0;second\x1b\\'); - expect(t.readEffects()).toEqual({ - bell: true, - title: 'second', - clipboard: null, - notifications: [], - }); - expect(t.readEffects()).toEqual({ - bell: false, - title: null, - clipboard: null, - notifications: [], - }); - t.free(); - }); - - test('answers DA1, DA2, DSR, XTWINOPS and DECRQM through WRITE_PTY', () => { - const t = term(80, 24); - t.setCellPixelSize(9, 18); - const ask = (q: string) => { - t.write(q); - return t.readResponse(); - }; - expect(t.hasResponse()).toBe(false); - expect(ask('\x1b[c')).toBe('\x1b[?62;22c'); - expect(ask('\x1b[>c')).toBe('\x1b[>1;10;0c'); - expect(ask('\x1b[5n')).toBe('\x1b[0n'); - expect(ask('\x1b[3;7H\x1b[6n')).toBe('\x1b[3;7R'); - expect(ask('\x1b[14t')).toBe('\x1b[4;432;720t'); - expect(ask('\x1b[16t')).toBe('\x1b[6;18;9t'); - expect(ask('\x1b[18t')).toBe('\x1b[8;24;80t'); - expect(ask('\x1b[?2027$p')).toBe('\x1b[?2027;1$y'); - expect(ask('\x1b[c\x1b[5n')).toBe('\x1b[?62;22c\x1b[0n'); - expect(t.readResponse()).toBeNull(); - t.free(); - }); - - test('does not answer the kitty keyboard query', () => { - // Keys are never encoded with the kitty flags, so the protocol must not look on. - const t = term(); - t.write('\x1b[?u'); - expect(t.readResponse()).toBeNull(); - t.write('\x1b[>31u\x1b[?u'); - expect(t.readResponse()).toBeNull(); - // Replies around it still arrive, in order. - t.write('\x1b[c\x1b[?u\x1b[5n'); - expect(t.readResponse()).toBe('\x1b[?62;22c\x1b[0n'); - t.free(); - }); - - test('sends one mode 2048 report per real size change', () => { - const t = term(80, 24); - t.setCellPixelSize(9, 18); - t.write('\x1b[?2048h'); - expect(t.readResponse()).toBe('\x1b[48;24;80;432;720t'); - t.resize(100, 30, 9, 18); - expect(t.readResponse()).toBe('\x1b[48;30;100;540;900t'); - t.resize(100, 30, 9, 18); - t.setCellPixelSize(9, 18); - expect(t.readResponse()).toBeNull(); - t.setCellPixelSize(10, 20); - expect(t.readResponse()).toBe('\x1b[48;30;100;600;1000t'); - t.free(); - }); - - test('makes one ghostty_terminal_resize call per grid or pixel change', async () => { - const real = (await instantiateWasm()).exports as Record<string, unknown>; - let resizes = 0; - const resize = real.ghostty_terminal_resize as (...args: number[]) => number; - const spy = { - ...real, - ghostty_terminal_resize: (...args: number[]) => { - resizes++; - return resize(...args); - }, - }; - const spied = new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]); - const t = spied.createTerminal(80, 24); - // open(): the initial cell size. - t.setCellPixelSize(9, 18); - expect(resizes).toBe(1); - // Repeated fits with nothing changed. - t.setCellPixelSize(9, 18); - t.resize(80, 24, 9, 18); - expect(resizes).toBe(1); - // A zoom that changes grid and pixels: one call. - t.resize(90, 26, 10, 20); - t.setCellPixelSize(10, 20); - expect(resizes).toBe(2); - // A zoom or DPR change that keeps the grid: one call. - t.resize(90, 26, 11, 22); - t.setCellPixelSize(11, 22); - expect(resizes).toBe(3); - t.free(); - }); -}); - -describe('official engine: clipboard writes (ADR 052)', () => { - const b64 = (bytes: string | Uint8Array) => Buffer.from(bytes).toString('base64'); - /** The clipboard text one write reports, or null for none. */ - function copied(seq: string, config: object = THEME): string | null { - const t = term(80, 24, config); - t.write(seq); - const { clipboard } = t.readEffects(); - expect(t.readEffects().clipboard).toBeNull(); - // OSC 52 has no reply: nothing is ever sent back, query or write. - expect(t.readResponse()).toBeNull(); - t.free(); - return clipboard; - } - - test('OSC 52 to the clipboard, ended by BEL or ST, decoded as UTF-8', () => { - expect(copied(`\x1b]52;c;${b64('hello')}\x07`)).toBe('hello'); - expect(copied(`\x1b]52;c;${b64('héllo 中文 🎉\nline 2')}\x1b\\`)).toBe('héllo 中文 🎉\nline 2'); - }); - - test('an empty or unknown selection is the clipboard, as in Ghostty', () => { - expect(copied(`\x1b]52;;${b64('x')}\x07`)).toBe('x'); - expect(copied(`\x1b]52;0;${b64('zero')}\x07`)).toBe('zero'); - }); - - test('the selection and primary clipboards are not written', () => { - expect(copied(`\x1b]52;s;${b64('sel')}\x07`)).toBeNull(); - expect(copied(`\x1b]52;p;${b64('pri')}\x07`)).toBeNull(); - // Ghostty takes one selection character only. - expect(copied(`\x1b]52;pc;${b64('both')}\x07`)).toBeNull(); - }); - - test('an empty payload clears the clipboard', () => { - expect(copied('\x1b]52;c;\x07')).toBe(''); - }); - - test('a query is never answered', () => { - expect(copied('\x1b]52;c;?\x07')).toBeNull(); - }); - - test('invalid base64 is ignored whole; missing padding is accepted', () => { - expect(copied('\x1b]52;c;!!!!\x07')).toBeNull(); - expect(copied(`\x1b]52;c;${b64('ok').slice(0, 3)}?\x07`)).toBeNull(); - expect(copied('\x1b]52;c;aGk\x07')).toBe('hi'); - }); - - test('bytes that are not UTF-8 become U+FFFD', () => { - expect(copied(`\x1b]52;c;${b64(new Uint8Array([0x61, 0xff, 0x62]))}\x07`)).toBe('a\ufffdb'); - }); - - test('iTerm2 OSC 1337 Copy reaches the clipboard too', () => { - expect(copied(`\x1b]1337;Copy=:${b64('iterm')}\x07`)).toBe('iterm'); - }); - - test('a sequence split across writes is reported when it ends; the last write wins', () => { - const t = term(); - t.write(`\x1b]52;c;${b64('split')}`); - expect(t.readEffects().clipboard).toBeNull(); - t.write(`\x07\x1b]52;c;${b64('first')}\x07\x1b]52;c;${b64('last')}\x07`); - expect(t.readEffects().clipboard).toBe('last'); - t.free(); - }); - - test('a sequence over 8 MiB is dropped, and the terminal carries on', () => { - const t = term(); - const fits = 'a'.repeat(5 * 1024 * 1024); - t.write(`\x1b]52;c;${b64(fits)}\x07`); - expect(t.readEffects().clipboard?.length).toBe(fits.length); - t.write(`\x1b]52;c;${b64('a'.repeat(6 * 1024 * 1024))}\x07after`); - expect(t.readEffects().clipboard).toBeNull(); - expect(rowText(t, 0)).toBe('after'); - t.free(); - }); - - test('with clipboardWrite off, nothing is reported', () => { - const off = { ...THEME, clipboardWrite: false }; - expect(copied(`\x1b]52;c;${b64('hello')}\x07`, off)).toBeNull(); - expect(copied('\x1b]52;c;\x07', off)).toBeNull(); - expect(copied(`\x1b]1337;Copy=:${b64('iterm')}\x07`, off)).toBeNull(); - }); -}); - -describe('official engine: focus reports (ADR 053)', () => { - test('encodes focus gained and lost as CSI I and CSI O', () => { - const t = term(); - const text = (focused: boolean) => new TextDecoder().decode(t.encodeFocus(focused)); - expect(text(true)).toBe('\x1b[I'); - expect(text(false)).toBe('\x1b[O'); - // The engine sends nothing itself when the mode is set: the embedder does. - t.write('\x1b[?1004h'); - expect(t.getMode(1004)).toBe(true); - expect(t.readResponse()).toBeNull(); - t.free(); - }); -}); - -describe('official engine: cursor at a prompt (ADR 054)', () => { - test('follows OSC 133 marks, and is false without them and on the alternate screen', () => { - const t = term(); - t.write('$ '); - expect(t.cursorAtPrompt()).toBe(false); - t.write('\r\n\x1b]133;A\x07~ > \x1b]133;B\x07ls'); - expect(t.cursorAtPrompt()).toBe(true); - t.write('\x1b[?1049h'); - expect(t.cursorAtPrompt()).toBe(false); - t.write('\x1b[?1049l'); - expect(t.cursorAtPrompt()).toBe(true); - // The command runs: its output is not a prompt - t.write('\r\n\x1b]133;C\x07output\r\nmore'); - expect(t.cursorAtPrompt()).toBe(false); - t.free(); - }); - - test('writeUntilGround writes up to the end of a cut-off sequence', () => { - const t = term(); - const bytes = (s: string) => new TextEncoder().encode(s); - expect(t.writeUntilGround(bytes(''))).toBe(0); - expect(t.writeUntilGround(bytes('ab'))).toBe(0); - t.write('a\x1b[3'); - expect(t.writeUntilGround(new Uint8Array(0))).toBeNull(); - expect(t.writeUntilGround(bytes('1;'))).toBeNull(); - expect(t.writeUntilGround(bytes('2mbc'))).toBe(2); - t.write('\x1b]2;ti'); - expect(t.writeUntilGround(bytes('tle\x07x'))).toBe(4); - // A multi-byte character cut in two is a sequence too - t.write(new Uint8Array([0xc3])); - expect(t.writeUntilGround(new Uint8Array([0xa9, 0x41]))).toBe(1); - t.free(); - }); -}); - -describe('official engine: desktop notifications (ADR 056)', () => { - /** The notifications one write reports. */ - function notified(seq: string, config: object = THEME) { - const t = term(80, 24, config); - t.write(seq); - const { notifications } = t.readEffects(); - expect(t.readEffects().notifications).toEqual([]); - // Neither sequence has a reply. - expect(t.readResponse()).toBeNull(); - t.free(); - return notifications; - } - - test('OSC 9 is a notification with no title, ended by BEL or ST', () => { - expect(notified('\x1b]9;Build done\x07')).toEqual([{ title: '', body: 'Build done' }]); - expect(notified('\x1b]9;héllo 中文 🎉; with; semicolons\x1b\\')).toEqual([ - { title: '', body: 'héllo 中文 🎉; with; semicolons' }, - ]); - }); - - test('OSC 777;notify has a title and a body; the body may hold semicolons', () => { - expect(notified('\x1b]777;notify;Claude Code;Needs your input\x07')).toEqual([ - { title: 'Claude Code', body: 'Needs your input' }, - ]); - expect(notified('\x1b]777;notify;t;a;b\x07')).toEqual([{ title: 't', body: 'a;b' }]); - expect(notified('\x1b]777;notify;;body\x07')).toEqual([{ title: '', body: 'body' }]); - }); - - test("ConEmu's OSC 9;<n> forms are not notifications", () => { - for (const seq of [ - '9;4;1;50', // progress - '9;4;0', - '9;1;100', // sleep - '9;2;message box', - '9;3;tab title', - '9;5', // wait for input - '9;6;macro', - '9;7;cmd', - '9;8;PATH', - '9;9;/tmp', - '9;10', - '9;11;comment', - '9;12', // prompt start - ]) { - expect(notified(`\x1b]${seq}\x07`)).toEqual([]); - } - }); - - test('an OSC 9 that only looks like ConEmu is a notification, as in Ghostty', () => { - expect(notified('\x1b]9;4\x07')).toEqual([{ title: '', body: '4' }]); - expect(notified('\x1b]9;42 tests passed\x07')).toEqual([ - { title: '', body: '42 tests passed' }, - ]); - }); - - test('OSC 777 other than notify, or with no title, is ignored', () => { - expect(notified('\x1b]777;other;a;b\x07')).toEqual([]); - expect(notified('\x1b]777;notify;only a title\x07')).toEqual([]); - }); - - test('a sequence split across writes is reported when it ends', () => { - const t = term(); - t.write('\x1b]777;notify;Do'); - expect(t.readEffects().notifications).toEqual([]); - t.write('ne;all green\x07\x1b]9;second\x07'); - expect(t.readEffects().notifications).toEqual([ - { title: 'Done', body: 'all green' }, - { title: '', body: 'second' }, - ]); - t.free(); - }); - - test('with desktopNotifications off, nothing is reported', () => { - const off = { ...THEME, desktopNotifications: false }; - expect(notified('\x1b]9;Build done\x07', off)).toEqual([]); - expect(notified('\x1b]777;notify;t;b\x07', off)).toEqual([]); - }); -}); - -describe('official engine: memory growth', () => { - test('reads correct cells and replies after the wasm memory has grown', () => { - const t = term(120, 40, { scrollbackLines: 0 }); - const memory = vt.ex.memory; - const before = memory.buffer.byteLength; - let i = 0; - while (memory.buffer.byteLength === before && i < 200000) { - let chunk = ''; - for (let j = 0; j < 500; j++, i++) { - chunk += `\x1b[38;2;${i % 256};${(i >> 8) % 256};${(i * 7) % 256}mrow ${i}\x1b[0m\r\n`; - } - t.write(chunk); - } - expect(memory.buffer.byteLength).toBeGreaterThan(before); - t.write('\x1b[6n'); - expect(t.readResponse()).toBe('\x1b[40;1R'); - expect(rowText(t, 38)).toBe(`row ${i - 1}`); - const cell = t.getLine(38)?.[0]; - expect(cell && rgb(cell)).toBe( - (((i - 1) % 256) << 16) | ((((i - 1) >> 8) % 256) << 8) | (((i - 1) * 7) % 256), - ); - expect(scrollbackText(t, 0)).toBe('row 0'); - t.free(); - }); -}); - -describe('official engine: key encoder', () => { - test('encodes keys by their official names', () => { - const enc = vt.createKeyEncoder(); - const encode = (key: Key, mods = Mods.NONE, utf8?: string) => - new TextDecoder().decode(enc.encode({ action: KeyAction.PRESS, key, mods, utf8 })); - expect(encode(Key.UP)).toBe('\x1b[A'); - expect(encode(Key.ENTER)).toBe('\r'); - expect(encode(Key.C, Mods.CTRL, 'c')).toBe('\x03'); - expect(encode(Key.ONE, Mods.NONE, '1')).toBe('1'); - expect(encode(Key.GRAVE, Mods.NONE, '`')).toBe('`'); - expect(encode(Key.F1)).toBe('\x1bOP'); - expect(encode(Key.PASTE)).toBe(''); - enc.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, true); - expect(encode(Key.UP)).toBe('\x1bOA'); - enc.dispose(); - }); -}); - -describe('official engine: mouse encoder (ADR 049)', () => { - // A 40x10 grid of 10 px cells with 5 px of padding on every side: the screen is - // 410x110, and cell (col, row) 1-based spans x 5+10(col-1) .. 5+10col. - const GEOMETRY = { - screenWidth: 410, - screenHeight: 110, - cellWidth: 10, - cellHeight: 10, - paddingTop: 5, - paddingRight: 5, - paddingBottom: 5, - paddingLeft: 5, - }; - const at = (col: number, row: number) => ({ x: 5 + 10 * col - 5, y: 5 + 10 * row - 5 }); - const tracking = (modes: string, cols = 40) => { - const t = term(cols, 10); - t.write(modes); - t.setMouseGeometry({ ...GEOMETRY, screenWidth: 10 + 10 * cols }); - return t; - }; - const report = ( - t: GhosttyTerminal, - action: MouseInput['action'], - button: MouseInput['button'], - pos: { x: number; y: number }, - mods = Mods.NONE, - anyButtonPressed = action === 'press', - ) => - new TextDecoder('latin1').decode( - t.encodeMouse({ action, button, mods, ...pos, anyButtonPressed }), - ); - - test('SGR clicks carry Shift, Alt and Ctrl, not Super', () => { - const t = tracking('\x1b[?1000h\x1b[?1006h'); - expect(report(t, 'press', 'left', at(3, 2))).toBe('\x1b[<0;3;2M'); - expect(report(t, 'release', 'left', at(3, 2))).toBe('\x1b[<0;3;2m'); - expect(report(t, 'press', 'left', at(3, 2), Mods.ALT)).toBe('\x1b[<8;3;2M'); - expect(report(t, 'press', 'left', at(3, 2), Mods.SUPER)).toBe('\x1b[<0;3;2M'); - expect(report(t, 'press', 'right', at(3, 2), Mods.SHIFT | Mods.CTRL)).toBe('\x1b[<22;3;2M'); - expect(report(t, 'press', 'middle', at(3, 2))).toBe('\x1b[<1;3;2M'); - t.free(); - }); - - test('back and forward are buttons 8 and 9 (128, 129)', () => { - const t = tracking('\x1b[?1000h\x1b[?1006h'); - expect(report(t, 'press', 'eight', at(1, 1))).toBe('\x1b[<128;1;1M'); - expect(report(t, 'release', 'nine', at(1, 1))).toBe('\x1b[<129;1;1m'); - t.free(); - }); - - test('the wheel is buttons 4 and 5 in the format the app chose', () => { - const t = tracking('\x1b[?1000h\x1b[?1006h'); - expect(report(t, 'press', 'four', at(7, 4), Mods.NONE, false)).toBe('\x1b[<64;7;4M'); - expect(report(t, 'press', 'five', at(7, 4), Mods.CTRL, false)).toBe('\x1b[<81;7;4M'); - t.write('\x1b[?1006l'); - expect(report(t, 'press', 'four', at(7, 4), Mods.NONE, false)).toBe("\x1b[M`'$"); - t.free(); - }); - - test('any-event tracking reports hover as 35, once per cell', () => { - const t = tracking('\x1b[?1003h\x1b[?1006h'); - const hover = (x: number, y: number) => report(t, 'motion', null, { x, y }); - expect(hover(26, 16)).toBe('\x1b[<35;3;2M'); - expect(hover(29, 19)).toBe(''); - expect(hover(36, 19)).toBe('\x1b[<35;4;2M'); - // A press sets the last cell too - expect(report(t, 'press', 'left', at(5, 2))).toBe('\x1b[<0;5;2M'); - expect(report(t, 'motion', 'left', at(5, 2), Mods.NONE, true)).toBe(''); - expect(report(t, 'motion', 'left', at(6, 2), Mods.NONE, true)).toBe('\x1b[<32;6;2M'); - t.free(); - }); - - test('a mode change resets the last cell', () => { - const t = tracking('\x1b[?1003h\x1b[?1006h'); - expect(report(t, 'motion', null, at(2, 2))).toBe('\x1b[<35;2;2M'); - expect(report(t, 'motion', null, at(2, 2))).toBe(''); - // The format last set wins - t.write('\x1b[?1015h'); - expect(report(t, 'motion', null, at(2, 2))).toBe('\x1b[67;2;2M'); - t.free(); - }); - - test('normal tracking (1000) reports no motion, button tracking (1002) only drags', () => { - const t = tracking('\x1b[?1000h\x1b[?1006h'); - expect(report(t, 'motion', 'left', at(2, 2), Mods.NONE, true)).toBe(''); - t.write('\x1b[?1002h'); - expect(report(t, 'motion', null, at(3, 2))).toBe(''); - expect(report(t, 'motion', 'left', at(3, 2), Mods.NONE, true)).toBe('\x1b[<32;3;2M'); - t.free(); - }); - - test('X10 mode 9 reports presses of left, middle and right only, without modifiers', () => { - const t = tracking('\x1b[?9h'); - expect(report(t, 'press', 'left', at(5, 3), Mods.SHIFT)).toBe('\x1b[M %#'); - expect(report(t, 'release', 'left', at(5, 3))).toBe(''); - expect(report(t, 'press', 'four', at(5, 3), Mods.NONE, false)).toBe(''); - expect(report(t, 'press', 'eight', at(5, 3))).toBe(''); - t.free(); - }); - - test('X10 format: releases are button 3, and columns past 95 need bytes above 0x7f', () => { - const t = tracking('\x1b[?1000h', 120); - expect(report(t, 'press', 'left', at(5, 3), Mods.ALT)).toBe('\x1b[M(%#'); - expect(report(t, 'release', 'left', at(5, 3))).toBe('\x1b[M#%#'); - expect(report(t, 'press', 'left', at(95, 1))).toBe('\x1b[M \x7f!'); - expect([ - ...t.encodeMouse({ - action: 'press', - button: 'left', - mods: 0, - ...at(96, 1), - anyButtonPressed: true, - }), - ]).toEqual([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0x21]); - t.free(); - }); - - test('UTF-8 (1005), urxvt (1015) and SGR-pixels (1016) formats', () => { - const t = tracking('\x1b[?1000h\x1b[?1005h', 300); - const utf8 = new TextDecoder().decode( - t.encodeMouse({ - action: 'press', - button: 'left', - mods: 0, - ...at(200, 3), - anyButtonPressed: true, - }), - ); - expect(utf8).toBe(`\x1b[M ${String.fromCodePoint(232)}#`); - t.write('\x1b[?1005l\x1b[?1015h'); - expect(report(t, 'press', 'left', at(200, 3))).toBe('\x1b[32;200;3M'); - t.write('\x1b[?1015l\x1b[?1016h'); - // Pixels from the grid's origin, not the screen's - expect(report(t, 'press', 'left', { x: 26, y: 17 })).toBe('\x1b[<0;21;12M'); - expect(report(t, 'release', 'left', { x: 2, y: 17 })).toBe('\x1b[<0;-3;12m'); - t.free(); - }); - - test('the padding is the nearest edge cell; outside the screen only drags and releases', () => { - const t = tracking('\x1b[?1002h\x1b[?1006h'); - expect(report(t, 'press', 'left', { x: 1, y: 1 })).toBe('\x1b[<0;1;1M'); - expect(report(t, 'press', 'left', { x: 408, y: 108 })).toBe('\x1b[<0;40;10M'); - // Dragged past the right edge: clamped to the edge cell - expect(report(t, 'motion', 'left', { x: 600, y: 50 }, Mods.NONE, true)).toBe('\x1b[<32;40;5M'); - expect(report(t, 'motion', 'left', { x: -50, y: 50 }, Mods.NONE, true)).toBe('\x1b[<32;1;5M'); - expect(report(t, 'release', 'left', { x: -50, y: 50 }, Mods.NONE, false)).toBe('\x1b[<0;1;5m'); - // Outside with no button held: nothing - expect(report(t, 'motion', null, { x: -50, y: 60 }, Mods.NONE, false)).toBe(''); - expect(report(t, 'press', 'left', { x: -50, y: 60 }, Mods.NONE, false)).toBe(''); - t.free(); - }); - - test('no tracking reports nothing, and a reset terminal starts without tracking', () => { - const t = tracking(''); - expect(report(t, 'press', 'left', at(1, 1))).toBe(''); - t.write('\x1b[?1000h\x1b[?1006h'); - expect(report(t, 'press', 'left', at(1, 1))).toBe('\x1b[<0;1;1M'); - t.write('\x1bc'); - expect(report(t, 'press', 'left', at(1, 1))).toBe(''); - t.free(); - }); -}); - -describe('official engine: paste (ADR 049)', () => { - const paste = (t: GhosttyTerminal, text: string) => { - t.paste(text); - return t.readResponse(); - }; - - test('brackets every paste while mode 2004 is on, single lines too', () => { - const t = term(); - t.write('\x1b[?2004h'); - expect(paste(t, 'hello')).toBe('\x1b[200~hello\x1b[201~'); - expect(paste(t, 'one\ntwo\r\n')).toBe('\x1b[200~one\ntwo\r\n\x1b[201~'); - t.free(); - }); - - test('without 2004, sends the text with newlines as CR', () => { - const t = term(); - expect(paste(t, 'hello')).toBe('hello'); - expect(paste(t, 'one\ntwo')).toBe('one\rtwo'); - expect(paste(t, 'one\r\ntwo\n')).toBe('one\r\rtwo\r'); - t.free(); - }); - - test('replaces unsafe control bytes with spaces in both modes, keeping tabs', () => { - const t = term(); - const unsafe = 'a\x00b\x08c\x1bd\x7fe\x03f\x15g\x1ah\x16i\x17j\tk'; - expect(paste(t, unsafe)).toBe('a b c d e f g h i j\tk'); - t.write('\x1b[?2004h'); - // An end marker inside the text cannot end the paste early - expect(paste(t, 'x\x1b[201~rm -rf ~\n')).toBe('\x1b[200~x [201~rm -rf ~\n\x1b[201~'); - t.free(); - }); - - test('passes UTF-8 through, and an empty paste writes nothing', () => { - const t = term(); - expect(paste(t, 'héllo 中 🙂')).toBe('héllo 中 🙂'); - expect(paste(t, '')).toBeNull(); - t.free(); - }); - - test('a large paste arrives whole and in order', () => { - const t = term(); - t.write('\x1b[?2004h'); - const text = Array.from({ length: 5000 }, (_, i) => `line ${i}`).join('\n'); - expect(paste(t, text)).toBe(`\x1b[200~${text}\x1b[201~`); - t.free(); - }); -}); - -describe('official engine: selection (ADR 050)', () => { - test('selectWord uses Ghostty word boundaries and follows soft wraps', () => { - const t = term(10, 4); - t.write('a=b?c&d x\r\nab "cd" e\r\nx foo/bar/baz'); - const at = (col: number, row: number) => t.selectWord({ col, row }); - expect(at(2, 0)).toEqual({ start: { col: 0, row: 0 }, end: { col: 6, row: 0 } }); - expect(at(4, 1)).toEqual({ start: { col: 4, row: 1 }, end: { col: 5, row: 1 } }); - // "foo/bar/baz" wraps after column 9: the word runs on into the next row - expect(at(3, 2)).toEqual({ start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }); - expect(at(1, 3)).toEqual({ start: { col: 2, row: 2 }, end: { col: 2, row: 3 } }); - expect(at(9, 3)).toBeNull(); - t.free(); - }); - - test('selectWord and selectLine take screen rows, scrollback included', () => { - const t = term(10, 2); - t.write('first one\r\nsecond\r\nthird'); - expect(t.getScrollbackLength()).toBe(1); - expect(t.selectWord({ col: 1, row: 0 })).toEqual({ - start: { col: 0, row: 0 }, - end: { col: 4, row: 0 }, - }); - expect(t.selectLine({ col: 0, row: 2 })).toEqual({ - start: { col: 0, row: 2 }, - end: { col: 4, row: 2 }, - }); - expect(t.selectionText({ col: 0, row: 0 }, { col: 9, row: 2 })).toBe( - 'first one\nsecond\nthird', - ); - t.free(); - }); - - test('selectLine follows soft wraps and leaves out outer whitespace; a blank line is null', () => { - const t = term(6, 4); - t.write(' abcdefgh \r\n\r\nx'); - expect(t.selectLine({ col: 1, row: 1 })).toEqual({ - start: { col: 2, row: 0 }, - end: { col: 3, row: 1 }, - }); - expect(t.selectLine({ col: 0, row: 2 })).toBeNull(); - t.free(); - }); - - test('selectionText joins soft-wrapped rows, trims, takes either order and clamps', () => { - const t = term(5, 3); - t.write('abcdefg\r\nhi '); - expect(t.selectionText({ col: 0, row: 0 }, { col: 4, row: 2 })).toBe('abcdefg\nhi'); - expect(t.selectionText({ col: 4, row: 2 }, { col: 0, row: 0 })).toBe('abcdefg\nhi'); - expect(t.selectionText({ col: -3, row: -1 }, { col: 99, row: 99 })).toBe('abcdefg\nhi'); - expect(t.selectionText({ col: 3, row: 1 }, { col: 4, row: 1 })).toBe(''); - t.free(); - }); - - test('selectionText reads the alternate screen while it is active', () => { - const t = term(10, 2); - t.write('a\r\nb\r\nc\x1b[?1049h\x1b[Halt'); - expect(t.getScrollbackLength()).toBe(0); - expect(t.selectionText({ col: 0, row: 0 }, { col: 9, row: 0 })).toBe('alt'); - t.free(); - }); -}); - -describe('official engine: OSC 133 prompts (ADR 057)', () => { - /** A command as fish 4 marks it: prompt, command line, output, end. */ - const command = (name: string, lines: number) => - `\x1b]133;A\x07$ \x1b]133;B\x07${name}\r\n\x1b]133;C\x07` + - Array.from({ length: lines }, (_, i) => `${name} ${i}`).join('\r\n') + - '\r\n\x1b]133;D;0\x07'; - /** Prompts on rows 0, 7 and 14, and the current one on row 21 of 22. */ - const session = () => { - const t = term(20, 5); - t.write(`${command('one', 6)}${command('two', 6)}${command('three', 6)}\x1b]133;A\x07$ `); - return t; - }; - - test('promptJumpRow moves the top row by prompts, as Ghostty, and stops at either end', () => { - const t = session(); - expect(t.getScrollbackLength()).toBe(17); - // Up from the bottom: the nearest prompt above the top row - expect(t.promptJumpRow(17, -1)).toBe(14); - expect(t.promptJumpRow(14, -1)).toBe(7); - expect(t.promptJumpRow(17, -2)).toBe(7); - expect(t.promptJumpRow(7, -5)).toBe(0); - expect(t.promptJumpRow(0, -1)).toBe(0); - expect(t.promptJumpRow(10, -1)).toBe(7); - // Down past the last prompt in the scrollback: the bottom - expect(t.promptJumpRow(0, 1)).toBe(7); - expect(t.promptJumpRow(14, 1)).toBe(17); - expect(t.promptJumpRow(17, 1)).toBe(17); - expect(t.promptJumpRow(10, 0)).toBe(10); - t.free(); - }); - - test('promptJumpRow leaves the viewport at the bottom, so output and reads are unchanged', () => { - const t = session(); - t.promptJumpRow(17, -2); - expect(rowText(t, 4)).toBe('$'); - expect(scrollbackText(t, 7)).toBe('$ two'); - t.write('ls'); - expect(rowText(t, 4)).toBe('$ ls'); - t.free(); - }); - - test('promptJumpRow does nothing without marks or on the alternate screen', () => { - const t = term(20, 5); - t.write(Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\r\n')); - expect(t.getScrollbackLength()).toBe(25); - expect(t.promptJumpRow(25, -1)).toBe(25); - expect(t.promptJumpRow(10, 1)).toBe(10); - t.free(); - const alt = session(); - alt.write('\x1b[?1049h'); - expect(alt.promptJumpRow(0, -1)).toBe(0); - alt.free(); - }); - - test('selectOutput selects the output of the command at a cell, null elsewhere', () => { - const t = session(); - const one = { start: { col: 0, row: 1 }, end: { col: 4, row: 6 } }; - expect(t.selectOutput({ col: 0, row: 1 })).toEqual(one); - expect(t.selectOutput({ col: 3, row: 4 })).toEqual(one); - expect(t.selectionText(one.start, one.end)).toBe('one 0\none 1\none 2\none 3\none 4\none 5'); - expect(t.selectOutput({ col: 0, row: 10 })).toEqual({ - start: { col: 0, row: 8 }, - end: { col: 4, row: 13 }, - }); - // The prompt row, and a terminal with no marks - expect(t.selectOutput({ col: 0, row: 21 })).toBeNull(); - t.free(); - const plain = term(20, 5); - plain.write('a\r\nb'); - expect(plain.selectOutput({ col: 0, row: 0 })).toBeNull(); - plain.free(); - }); -}); - -describe('official engine: search (ADR 055)', () => { - /** Runs the search to completion and returns its matches as [col, row, col, row] tuples. */ - function run(t: OfficialTerminal): number[][] { - while (t.searchStep(50)) {} - const m = t.searchMatches(); - return Array.from({ length: m.length / 4 }, (_, i) => Array.from(m.slice(i * 4, i * 4 + 4))); - } - - test('finds ASCII letters case-insensitively, newest first, scrollback included', () => { - const t = term(20, 3); - t.write('Error one\r\nok\r\nerror two\r\nok\r\nERROR three'); - t.setSearch('error'); - expect(run(t)).toEqual([ - [0, 4, 4, 4], - [0, 2, 4, 2], - [0, 0, 4, 0], - ]); - expect(t.getScrollbackLength()).toBe(2); - expect(t.searchState()).toEqual({ total: 3, selected: null }); - t.free(); - }); - - test('a match spans a soft wrap', () => { - const t = term(5, 3); - t.write('abcdefg'); - t.setSearch('def'); - expect(run(t)).toEqual([[3, 0, 0, 1]]); - t.free(); - }); - - test('next moves toward older output and wraps; previous moves back', () => { - const t = term(10, 4); - t.write('a x\r\nb x\r\nc x'); - t.setSearch('x'); - run(t); - const row = () => t.searchSelectedMatch()?.start.row; - expect(t.searchSelectedMatch()).toBeNull(); - expect(t.searchSelect('next')).toBe(true); - expect([t.searchState().selected, row()]).toEqual([0, 2]); - t.searchSelect('next'); - expect([t.searchState().selected, row()]).toEqual([1, 1]); - t.searchSelect('next'); - t.searchSelect('next'); - expect([t.searchState().selected, row()]).toEqual([0, 2]); - t.searchSelect('previous'); - expect([t.searchState().selected, row()]).toEqual([2, 0]); - expect(t.searchSelectedMatch()).toEqual({ start: { col: 2, row: 0 }, end: { col: 2, row: 0 } }); - t.free(); - }); - - test('picks up output after a step, and leaves the view alone', () => { - const t = term(10, 3); - t.write('x\r\n'); - t.setSearch('x'); - expect(run(t)).toHaveLength(1); - expect(t.searchStep(50)).toBe(false); - t.write('x\r\nx\r\nx\r\n'); - expect(run(t)).toHaveLength(4); - t.searchSelect('previous'); - expect(t.searchSelectedMatch()?.start.row).toBe(0); - // SCROLL_NONE: the top row of Ghostty's viewport is still the active screen's - expect(t.getLine(0)?.[0].codepoint).toBe('x'.codePointAt(0)); - t.free(); - }); - - test('a new needle restarts the search; an empty one ends it', () => { - const t = term(10, 3); - t.write('foo bar'); - t.setSearch('foo'); - expect(run(t)).toEqual([[0, 0, 2, 0]]); - t.setSearch('BAR'); - expect(run(t)).toEqual([[4, 0, 6, 0]]); - t.setSearch(''); - expect(t.searchStep(50)).toBe(false); - expect(t.searchMatches()).toHaveLength(0); - expect(t.searchState()).toEqual({ total: 0, selected: null }); - expect(t.searchSelect('next')).toBe(false); - t.free(); - }); - - test('matches pruned from the scrollback go away', () => { - const t = term(10, 2, { scrollbackLines: 1000 }); - t.write('x0\r\n'); - t.setSearch('x'); - expect(run(t)).toHaveLength(1); - t.write('y\r\n'.repeat(5000)); - expect(run(t)).toHaveLength(0); - t.free(); - }); - - test('reads more matches than its buffer held', () => { - const t = term(40, 5, { scrollbackLines: 1000 }); - t.setSearch('ab'); - t.write('ab\r\n'); - expect(run(t)).toHaveLength(1); - t.write('ab ab ab ab ab\r\n'.repeat(100)); - const all = run(t); - expect(all).toHaveLength(501); - expect(t.searchState().total).toBe(501); - // Newest to oldest: rows never increase - for (let i = 1; i < all.length; i++) expect(all[i][1]).toBeLessThanOrEqual(all[i - 1][1]); - t.free(); - }); -}); diff --git a/src/client/vendor/tests/support/terminal-text.ts b/src/client/vendor/tests/support/terminal-text.ts index 2c94063..10f3b31 100644 --- a/src/client/vendor/tests/support/terminal-text.ts +++ b/src/client/vendor/tests/support/terminal-text.ts @@ -53,3 +53,13 @@ export const bgRgb = (c: { bg_r: number; bg_g: number; bg_b: number }) => /** `n` lines `line 1` … `line n`, joined by CRLF. */ export const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n'); + +/** A command as fish 4 marks it with OSC 133 (ADR 057): prompt, command line, output, end. */ +export const markedCommand = (name: string, outputLines: number) => + `\x1b]133;A\x07$ \x1b]133;B\x07${name}\r\n\x1b]133;C\x07` + + Array.from({ length: outputLines }, (_, i) => `${name} ${i}`).join('\r\n') + + '\r\n\x1b]133;D;0\x07'; + +/** On a 20x5 grid: prompts on rows 0, 7 and 14, and the current one on row 21 of 22. */ +export const markedSession = () => + `${markedCommand('one', 6)}${markedCommand('two', 6)}${markedCommand('three', 6)}\x1b]133;A\x07$ `; From e4bc6faca633272377a4145e32291059dedf21be Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:16:40 -0400 Subject: [PATCH 236/295] refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade Every remaining OfficialTerminal area gets a named home on the shared EngineContext (ADR 061): - engine/grid-refs.ts: GridRefs (grid and screen refs, screenCellOf, isRowWrapped, hyperlinkAt) - engine/selection-binding.ts: SelectionBinding, plus the selection struct helpers the search shares - engine/search-binding.ts: SearchBinding (handle, match buffer, free) - engine/mouse-encoder-binding.ts: MouseEncoderBinding - engine/paste-focus-binding.ts: PasteFocusBinding (paste reader callback, focus reports) - engine/engine-effects.ts: EngineEffects (FnTable callbacks, clipboard write, readTitle after the write, the effect queue; released in free()) - engine/engine-config.ts: EngineConfig (applyConfig, writeRgb, default cursor and the DECSCUSR blink kept in mode 12) - EngineContext gains getMode, the cell pixel size and the shared mode and string slots, since several bindings need them. Each binding declares its own scratch slots; the facade lists the specs once (terminalSlotSpecs). official-terminal.ts drops from 1258 to 450 lines and leaves the ratchet allowlist. Callbacks still never re-enter vt_write; free() order is unchanged (search, terminal, callback slots, encoders, pages, render state, scratch). Behaviour unchanged. --- scripts/architecture/ratchet-allowlist.ts | 6 - src/client/vendor/ghostty-web/PROVENANCE.md | 8 + .../ghostty-web/engine/engine-config.ts | 156 +++ .../ghostty-web/engine/engine-context.ts | 25 +- .../ghostty-web/engine/engine-effects.ts | 263 ++++ .../vendor/ghostty-web/engine/grid-refs.ts | 106 ++ .../engine/mouse-encoder-binding.ts | 140 +++ .../ghostty-web/engine/official-terminal.ts | 1082 +++-------------- .../ghostty-web/engine/paste-focus-binding.ts | 122 ++ .../ghostty-web/engine/search-binding.ts | 193 +++ .../ghostty-web/engine/selection-binding.ts | 143 +++ 11 files changed, 1290 insertions(+), 954 deletions(-) create mode 100644 src/client/vendor/ghostty-web/engine/engine-config.ts create mode 100644 src/client/vendor/ghostty-web/engine/engine-effects.ts create mode 100644 src/client/vendor/ghostty-web/engine/grid-refs.ts create mode 100644 src/client/vendor/ghostty-web/engine/mouse-encoder-binding.ts create mode 100644 src/client/vendor/ghostty-web/engine/paste-focus-binding.ts create mode 100644 src/client/vendor/ghostty-web/engine/search-binding.ts create mode 100644 src/client/vendor/ghostty-web/engine/selection-binding.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index ce2d7ca..7fa2734 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -5,7 +5,6 @@ import type { Allowlist } from './ratchet'; -const ENGINE_SPLIT = 'vendor engine split, ghostty.ts into engine/ (ADR 061)'; const VENDOR_TESTS = 'vendor tests split with their facades into vendor/tests/ (ADR 061, ADR 064)'; const SERVER_HARNESS = 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; @@ -17,11 +16,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - // OfficialTerminal, moved out of ghostty.ts (2160 lines) and split into engine/ bindings. - 'src/client/vendor/ghostty-web/engine/official-terminal.ts': { - lines: 1258, - step: ENGINE_SPLIT, - }, 'src/client/vendor/ghostty-web/input-handler.ts': { lines: 856, step: 'vendor input split into input/ (ADR 061)', diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index eb69972..453384b 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -92,3 +92,11 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `engine/official-terminal.ts`: `refresh`, `readCursorAndColors`, `rgbAt`, `readRowRaw`, `decodeRow`, `readStyle`, `readGrapheme`, `allocView`, the page decode loop of `readPage` (now `decodePage`), `DecodedStyle`, `GRAPHEME_SCRATCH`, the render-state handles, dirty state, active-screen copy, cursor and colour fields and their scratch slots; `update`, `getCursor`, `isRowDirty`, `markClean`, `getViewport` bodies (webtty) | `engine/render-state.ts` (`RenderState`) | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | | `engine/official-terminal.ts`: `readPage` (the cache, now `scrollbackRow`), `rowAt`, `PAGE_CACHE_SIZE`, the page render state and cache fields (webtty) | `engine/scrollback-pages.ts` (`ScrollbackPages`) | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | | `engine/official-terminal.ts`: `RowRef`, `blankCell`, `setRgb`, `copyRow`, `graphemeOf` (now functions taking the row) (webtty) | `engine/row-ref.ts` | refactor(client): engine split, part 3: EngineContext, render state, scrollback pages (hot path) | +| `engine/official-terminal.ts`: `gridRef`, `screenRef`, `screenCellOf`, `hyperlinkAt`, the `isRowWrapped` body, and the point, grid-ref, coord and row slots (webtty) | `engine/grid-refs.ts` (`GridRefs`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `selectWord`, `selectLine`, `selectOutput`, `selectionText` bodies, `selectAt`, `clearSelectionStruct` (now taking the struct's address), and the selection slots (webtty) | `engine/selection-binding.ts` (`SelectionBinding`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `setSearch`, `searchStep`, `searchSelect`, `searchState`, `searchSelectedMatch`, `searchMatches` bodies (the buffer growth now `growMatches`, the read `readMatches`), the search fields and slots (webtty) | `engine/search-binding.ts` (`SearchBinding`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `setMouseGeometry`, `encodeMouse` (the mode check now `syncModes`), `ensureMouseEncoder`, `MOUSE_REPORT_MAX`, `MOUSE_MODES`, the mouse fields and slots (webtty) | `engine/mouse-encoder-binding.ts` (`MouseEncoderBinding`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `paste`, `ensurePasteReader`, `encodeFocus`, `FOCUS_REPORT_MAX`, the paste fields and the focus slot (webtty) | `engine/paste-focus-binding.ts` (`PasteFocusBinding`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `installCallbacks` (now `installReplies`, `installReports`, `installNotifications` and the clipboard install), `clipboardWrite`, `stringAt`, `readTitle`, `hasResponse`, `readResponse`, `readEffects` bodies, the effect fields and the clipboard reply slot (webtty) | `engine/engine-effects.ts` (`EngineEffects`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `applyConfig` (the colours now `applyColors`), `setDefaultCursor`, `syncBlink`, `writeRgb`, the blink fields and the DECSCUSR scan of `feed` (now `afterWrite`), the scrollback, fallback colour and mode constants, and the palette slot (webtty) | `engine/engine-config.ts` (`EngineConfig`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `engine/official-terminal.ts`: `getMode`, the cell pixel size fields, and the mode and string slots several bindings share (webtty) | `engine/engine-context.ts` (`EngineContext`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | diff --git a/src/client/vendor/ghostty-web/engine/engine-config.ts b/src/client/vendor/ghostty-web/engine/engine-config.ts new file mode 100644 index 0000000..b8308f8 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-config.ts @@ -0,0 +1,156 @@ +// webtty (ADR 042, ADR 046, ADR 049, ADR 061), not from upstream. +// The terminal's configuration (object): the scrollback limits, default modes, cursor +// and colours applied at creation, and the cursor blink that DECSCUSR sets, kept in +// Ghostty's mode 12 bit after every write. + +import type { RenderStateCursor } from './cell-types'; +import { scanCursorBlink } from './cursor-blink'; +import type { EngineContext } from './engine-context'; +import type { GhosttyTerminalConfig } from './engine-types'; +import type { OfficialLayout } from './official-layout'; + +/** The scrollback byte ceiling under the line limit (ADR 046). */ +export const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; +/** History in lines when the config gives none (ADR 046, ADR 047). */ +export const DEFAULT_SCROLLBACK_LINES = 10000; +/** webtty's default foreground, used when the theme leaves it unset. */ +export const FALLBACK_FOREGROUND = 0xcccccc; +/** webtty's default background, used when the theme leaves it unset. */ +export const FALLBACK_BACKGROUND = 0x000000; +/** DEC private mode 12, AT&T cursor blink (ATT610). */ +const MODE_CURSOR_BLINK = 12; +/** Grapheme clustering, on by default. */ +const MODE_GRAPHEME_CLUSTER = 2027; + +/** The scratch slots the config declares on the shared context. */ +export function configSlots(L: OfficialLayout) { + return { + palette: [256 * L.colors.rgbSize, 8], + } as const; +} + +/** The names of the config's scratch slots. */ +export type ConfigSlot = keyof ReturnType<typeof configSlots>; + +/** Writes 0xRRGGBB as a GhosttyColorRgb at `ptr`. */ +export function writeRgb(ctx: EngineContext, ptr: number, rgb: number): void { + const u8 = ctx.u8(); + const C = ctx.L.colors; + u8[ptr + C.r] = (rgb >>> 16) & 0xff; + u8[ptr + C.g] = (rgb >>> 8) & 0xff; + u8[ptr + C.b] = rgb & 0xff; +} + +/** The configuration and the cursor blink of one terminal. */ +export class EngineConfig { + private readonly ctx: EngineContext<ConfigSlot>; + // The blink DECSCUSR set, or the default while it has not set one; DEC mode 12 is + // ignored, so Ghostty's mode bit is put back to it after every write (ADR 049). + private blinkDefault = false; + private blinkIsDefault = true; + private blink = false; + private blinkCarry = ''; + + constructor(ctx: EngineContext<ConfigSlot>) { + this.ctx = ctx; + } + + /** Applies a new terminal's config: scrollback, default modes, cursor and colours. */ + apply(config: GhosttyTerminalConfig | undefined): void { + const { L, at } = this.ctx; + // Scrollback in lines under a 32 MiB ceiling (ADR 046). Whichever limit is reached + // first applies. 0 bytes would disable scrollback and a NULL byte limit would + // remove the ceiling, so neither is ever passed; NULL lines removes the line limit. + const lines = config?.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES; + this.ctx.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); + this.ctx.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); + if (lines > 0) this.ctx.dv().setUint32(at.u32, lines, true); + this.ctx.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); + + // Grapheme clustering (mode 2027) on by default, so RIS keeps it. + const dv = this.ctx.dv(); + dv.setUint16(at.mode + L.mode.mode, MODE_GRAPHEME_CLUSTER, true); + dv.setUint8(at.mode + L.mode.value, 1); + this.ctx.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); + + // The configured cursor, which DECSCUSR Ps 0 and RIS restore (ADR 049). + this.setDefaultCursor(config?.cursorStyle ?? 'block', config?.cursorBlink ?? false); + this.applyColors(config); + } + + /** The cursor DECSCUSR Ps 0 and RIS restore; shown at once while the app set none. */ + setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { + const { L, at } = this.ctx; + this.ctx.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); + this.ctx.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); + this.ctx.u8()[at.u8] = blink ? 1 : 0; + this.ctx.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); + this.blinkDefault = blink; + if (this.blinkIsDefault) this.blink = blink; + this.syncBlink(); + // The cursor is read from the render state, which only a content change refreshes. + this.ctx.generation++; + } + + /** Follows DECSCUSR in output that was just written, then puts mode 12 back. */ + afterWrite(text: string): void { + const scan = scanCursorBlink(text, this.blinkCarry); + this.blinkCarry = scan.carry; + if (scan.ps !== undefined) { + this.blinkIsDefault = scan.ps === 0; + this.blink = scan.ps === 0 ? this.blinkDefault : scan.ps % 2 === 1; + } + this.syncBlink(); + } + + /** + * Foreground and background are always both set: Ghostty's render state copies the + * default colours only when both are set (render.zig `bg_fg`), otherwise it keeps + * white on black and ignores the given colour, OSC 10/11 and DECSCNM. An unset one + * falls back to webtty's default. + */ + private applyColors(config: GhosttyTerminalConfig | undefined): void { + const { L, at } = this.ctx; + const setColor = (option: number, value: number, what: string) => { + writeRgb(this.ctx, at.u32, value); + this.ctx.setOption(option, at.u32, what); + }; + setColor(L.opt.COLOR_FOREGROUND, config?.fgColor ?? FALLBACK_FOREGROUND, 'COLOR_FOREGROUND'); + setColor(L.opt.COLOR_BACKGROUND, config?.bgColor ?? FALLBACK_BACKGROUND, 'COLOR_BACKGROUND'); + if (!config) return; + if (config.cursorColor !== undefined) { + setColor(L.opt.COLOR_CURSOR, config.cursorColor, 'COLOR_CURSOR'); + } + const palette = config.palette ?? []; + const entries = palette + .map((value, i) => ({ value, i })) + .filter((e): e is { value: number; i: number } => e.i < 16 && e.value !== undefined); + if (entries.length === 0) return; + // Unset entries and 16-255 keep Ghostty's default palette. + const rc = this.ctx.ex.ghostty_terminal_get( + this.ctx.term, + L.tdata.COLOR_PALETTE_DEFAULT, + at.palette + ); + if (rc !== 0) throw new Error(`libghostty-vt COLOR_PALETTE_DEFAULT failed: ${rc}`); + for (const { value, i } of entries) writeRgb(this.ctx, at.palette + i * L.colors.rgbSize, value); + this.ctx.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); + } + + /** + * Puts Ghostty's mode 12 bit, which its render state reports as the cursor blink, + * back to the blink DECSCUSR set. Ghostty with `cursor-style-blink` set ignores mode + * 12 (`setMode` in its `termio/stream_handler.zig`), and webtty's `cursorStyleBlink` + * is always set, so a program's `CSI ? 12 l` (vim's `t_ve`) no longer stops the + * blink for good. (webtty, ADR 049) + */ + private syncBlink(): void { + if (this.ctx.getMode(MODE_CURSOR_BLINK) === this.blink) return; + const { at, L } = this.ctx; + const M = L.mode; + const dv = this.ctx.dv(); + dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); + dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); + this.ctx.setOption(L.opt.MODE, at.mode, 'MODE 12'); + } +} diff --git a/src/client/vendor/ghostty-web/engine/engine-context.ts b/src/client/vendor/ghostty-web/engine/engine-context.ts index 0251b21..ae4f775 100644 --- a/src/client/vendor/ghostty-web/engine/engine-context.ts +++ b/src/client/vendor/ghostty-web/engine/engine-context.ts @@ -1,7 +1,8 @@ // webtty (ADR 042, ADR 061), not from upstream. // The state one terminal's bindings share (object): the wasm exports and layout, the -// terminal handle, the scratch slots, the grid size, the content generation, and the -// terminal info read once per generation. Memory views are built per call, never stored. +// terminal handle, the scratch slots, the grid and cell pixel size, the content generation, +// the terminal info read once per generation, and the mode reads. Memory views are built +// per call, never stored. import type { EngineHost } from './engine-types'; import type { OfficialLayout } from './official-layout'; @@ -13,7 +14,8 @@ export type SlotSpec<K extends string> = Record<K, readonly [number, number]>; /** * The slots the context itself uses: the terminal info multi-get, the viewport scroll - * struct, and the one-word out-parameters every binding shares. + * struct, and the out-parameters and small structs every binding shares (one word, a + * GhosttyMode and a GhosttyString). */ function contextSlots(L: OfficialLayout) { return { @@ -26,6 +28,8 @@ function contextSlots(L: OfficialLayout) { scroll: [L.scroll.size, 8], u32: [8, 8], u8: [8, 8], + mode: [L.mode.size, 8], + str: [L.string.size, 8], } as const; } @@ -48,6 +52,9 @@ export class EngineContext<K extends string = never> { term: number; cols: number; rows: number; + /** The cell size in pixels the SIZE reports give; 0 until the renderer sets it. */ + cellWidthPx = 0; + cellHeightPx = 0; /** * Bumped by every content change (scrolling is not one): gates the render-state * refresh and the info read, and keys the scrollback page cache. @@ -118,6 +125,18 @@ export class EngineContext<K extends string = never> { if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); } + /** Whether a DEC private (or, with `isAnsi`, an ANSI) mode is set. */ + getMode(mode: number, isAnsi = false): boolean { + const at = this.at; + const M = this.L.mode; + const dv = this.dv(); + // GhosttyMode (modes.h ghostty_mode_new): number in bits 0-14, bit 15 = ANSI. + dv.setUint16(at.mode + M.mode, (mode & 0x7fff) | (isAnsi ? 0x8000 : 0), true); + dv.setUint8(at.mode + M.value, 0); + const rc = this.ex.ghostty_terminal_get(this.term, this.L.tdata.MODE, at.mode); + return rc === 0 && this.u8()[at.mode + M.value] !== 0; + } + /** * Moves Ghostty's viewport (`GhosttyTerminalScrollViewport`). webtty's view is its own * offset, so only page reads and prompt jumps move it, and they move it back. diff --git a/src/client/vendor/ghostty-web/engine/engine-effects.ts b/src/client/vendor/ghostty-web/engine/engine-effects.ts new file mode 100644 index 0000000..b7a1e2b --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/engine-effects.ts @@ -0,0 +1,263 @@ +// webtty (ADR 042, ADR 052, ADR 056, ADR 061), not from upstream. +// The terminal's effects (object): the libghostty-vt callbacks installed through +// `FnTable` (replies, bell, title, size and device-attribute reports, clipboard writes, +// desktop notifications) and the queue of what they reported, read after each write. + +import { type InstalledFn, WasmValType } from '../fn-table'; +import type { EngineContext } from './engine-context'; +import type { DesktopNotification, TerminalEffects } from './engine-types'; +import { isKittyKeyboardReply } from './kitty-keyboard-reply'; +import type { OfficialLayout } from './official-layout'; +import { isTextMime } from './text-mime'; + +/** The scratch slots the effects declare on the shared context. */ +export function effectsSlots(L: OfficialLayout) { + return { + clipReply: [L.clipboardWrite.replySize, 8], + } as const; +} + +/** The names of the effects' scratch slots. */ +export type EffectsSlot = keyof ReturnType<typeof effectsSlots>; + +/** The two callbacks the terminal config can leave uninstalled. */ +export interface EffectsOptions { + clipboardWrite: boolean; + desktopNotifications: boolean; +} + +const I32 = WasmValType.I32; + +/** A callback body; it returns a value only for the callbacks that return a bool. */ +type Callback = (...args: number[]) => number | undefined; + +/** + * Everything the wasm reports through callbacks (ADR 042). The callbacks copy their + * payload out before returning and never re-enter `vt_write`: the title is only flagged + * there and read by `afterWrite`. They are released by `free()`, after the terminal. + */ +export class EngineEffects { + private readonly ctx: EngineContext<EffectsSlot>; + private readonly callbacks: InstalledFn[] = []; + private readonly responses: Uint8Array[] = []; + private bell = false; + private titleChanged = false; + private pendingTitle: string | null = null; + private pendingClipboard: string | null = null; + private pendingNotifications: DesktopNotification[] = []; + + constructor(ctx: EngineContext<EffectsSlot>, options: EffectsOptions) { + this.ctx = ctx; + this.installReplies(); + this.installReports(); + // CLIPBOARD_WRITE(terminal, userdata, write): left NULL with clipboardWrite off, so + // Ghostty ignores OSC 52 and OSC 1337 Copy and refuses OSC 5522 writes (ADR 052). + if (options.clipboardWrite) { + this.install(this.ctx.L.opt.CLIPBOARD_WRITE, 'CLIPBOARD_WRITE', 3, false, (_t, _u, write) => { + this.clipboardWrite(write); + return undefined; + }); + } + if (options.desktopNotifications) this.installNotifications(); + } + + /** Reads what a write flagged but left for after it: the title. */ + afterWrite(): void { + if (this.titleChanged) this.readTitle(); + } + + /** Whether a reply to the app is waiting. */ + hasResponse(): boolean { + return this.responses.length > 0; + } + + /** The waiting replies as one string, or null; the queue is emptied. */ + readResponse(): string | null { + if (this.responses.length === 0) return null; + let total = 0; + for (const r of this.responses) total += r.length; + const all = new Uint8Array(total); + let off = 0; + for (const r of this.responses) { + all.set(r, off); + off += r.length; + } + this.responses.length = 0; + return new TextDecoder().decode(all); + } + + /** The bell, title, clipboard and notifications since the last read; then cleared. */ + readEffects(): TerminalEffects { + const effects = { + bell: this.bell, + title: this.pendingTitle, + clipboard: this.pendingClipboard, + notifications: this.pendingNotifications, + }; + this.bell = false; + this.pendingTitle = null; + this.pendingClipboard = null; + this.pendingNotifications = []; + return effects; + } + + /** + * Releases the callbacks' table slots; call only after the terminal is freed, when + * nothing can call them (fn-table.ts rule 3). Safe to call again. + */ + free(): void { + for (const fn of this.callbacks) fn.release(); + this.callbacks.length = 0; + } + + /** Installs `fn` with `params` i32 parameters and sets it as terminal option `option`. */ + private install( + option: number, + what: string, + params: number, + returnsBool: boolean, + fn: Callback + ): void { + const installed = this.ctx.vt.fnTable.install( + fn as (...args: number[]) => number | void, + new Array(params).fill(I32), + returnsBool ? [I32] : [] + ); + this.callbacks.push(installed); + this.ctx.setOption(option, installed.index, what); + } + + /** WRITE_PTY, BELL and TITLE_CHANGED: what output asks to send back or show. */ + private installReplies(): void { + const opt = this.ctx.L.opt; + // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. + // Each call carries one whole reply. The kitty keyboard query reply is dropped: + // webtty's input handler never encodes keys with the kitty flags, so answering + // `CSI ? u` would tell apps the protocol is on when it is not (ADR 042). + this.install(opt.WRITE_PTY, 'WRITE_PTY', 4, false, (_t, _u, ptr, len) => { + const reply = this.ctx.u8().slice(ptr, ptr + len); + if (!isKittyKeyboardReply(reply)) this.responses.push(reply); + return undefined; + }); + this.install(opt.BELL, 'BELL', 2, false, () => { + this.bell = true; + return undefined; + }); + // The title is read after vt_write returns (DATA_TITLE), not inside the callback. + this.install(opt.TITLE_CHANGED, 'TITLE_CHANGED', 2, false, () => { + this.titleChanged = true; + return undefined; + }); + } + + /** SIZE and DEVICE_ATTRIBUTES: the answers to size and DA queries. */ + private installReports(): void { + const L = this.ctx.L; + // SIZE(terminal, userdata, out): XTWINOPS 14/16/18/19 t and mode 2048 reports. + this.install(L.opt.SIZE, 'SIZE', 3, true, (_t, _u, out) => { + const Z = L.sizeReport; + const dv = this.ctx.dv(); + dv.setUint16(out + Z.rows, this.ctx.rows, true); + dv.setUint16(out + Z.columns, this.ctx.cols, true); + dv.setUint32(out + Z.cellWidth, this.ctx.cellWidthPx, true); + dv.setUint32(out + Z.cellHeight, this.ctx.cellHeightPx, true); + return 1; + }); + // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, the replies webtty gave on + // the Coder wasm (without it, Ghostty's secondary DA is ESC[>1;0;0c). + this.install(L.opt.DEVICE_ATTRIBUTES, 'DEVICE_ATTRIBUTES', 3, true, (_t, _u, out) => { + const D = L.da; + const dv = this.ctx.dv(); + const primary = out + D.primary; + const secondary = out + D.secondary; + dv.setUint16(primary + D.conformance, 62, true); + dv.setUint16(primary + D.features, 22, true); + dv.setUint32(primary + D.numFeatures, 1, true); + dv.setUint16(secondary + D.deviceType, 1, true); + dv.setUint16(secondary + D.firmware, 10, true); + dv.setUint16(secondary + D.rom, 0, true); + dv.setUint32(out + D.tertiary + D.unitId, 0, true); + return 1; + }); + } + + /** + * DESKTOP_NOTIFICATION(terminal, userdata, notification): OSC 9 (but not ConEmu's + * OSC 9;<n> forms, which Ghostty's parser takes first) and OSC 777;notify. The + * strings are borrowed, so they are copied out here. Left NULL with + * desktopNotifications off, so Ghostty ignores them (webtty, ADR 056). + */ + private installNotifications(): void { + const L = this.ctx.L; + this.install(L.opt.DESKTOP_NOTIFICATION, 'DESKTOP_NOTIFICATION', 3, false, (_t, _u, n) => { + const N = L.notification; + // A sized struct: read only the fields its size covers. + if (this.ctx.dv().getUint32(n + N.size, true) < N.bodyEnd) return undefined; + const text = (ptr: number) => new TextDecoder().decode(this.stringAt(ptr)); + this.pendingNotifications.push({ title: text(n + N.title), body: text(n + N.body) }); + return undefined; + }); + } + + /** + * Takes one clipboard write (`GhosttyClipboardWrite`, already base64-decoded by + * Ghostty) and answers it before returning, as the API requires. Only the standard + * clipboard is written: the browser has no other, and Ghostty on macOS keeps `s` in + * a private pasteboard and drops `p`. The text is the first plain-text + * representation, decoded as UTF-8 with invalid bytes replaced (U+FFFD); no + * representations is a clear, kept as `''`. The browser write happens later, after + * `readEffects`, so success here means the write was accepted. (webtty, ADR 052) + */ + private clipboardWrite(write: number): void { + const C = this.ctx.L.clipboardWrite; + const dv = this.ctx.dv(); + let result = C.UNSUPPORTED; + if (dv.getInt32(write + C.location, true) === C.STANDARD) { + const contents = dv.getUint32(write + C.contents, true); + const count = dv.getUint32(write + C.contentsLen, true); + if (count === 0) { + this.pendingClipboard = ''; + result = C.SUCCESS; + } + for (let i = 0; i < count; i++) { + const content = contents + i * C.contentSize; + const mime = this.stringAt(content + C.mime); + if (!isTextMime(new TextDecoder().decode(mime))) continue; + this.pendingClipboard = new TextDecoder().decode(this.stringAt(content + C.data)); + result = C.SUCCESS; + break; + } + } + const reply = this.ctx.at.clipReply; + const out = this.ctx.dv(); + new Uint8Array(this.ctx.ex.memory.buffer, reply, C.replySize).fill(0); + out.setUint32(reply + C.replySizeField, C.replySize, true); + out.setInt32(reply + C.replyResult, result, true); + const fn = this.ctx.ex.__indirect_function_table.get(out.getUint32(write + C.reply, true)) as ( + write: number, + reply: number + ) => void; + fn(write, reply); + } + + /** A copy of the bytes a `GhosttyString` at `ptr` points to. */ + private stringAt(ptr: number): Uint8Array { + const S = this.ctx.L.string; + const dv = this.ctx.dv(); + const data = dv.getUint32(ptr + S.ptr, true); + const len = dv.getUint32(ptr + S.len, true); + return len === 0 ? new Uint8Array(0) : this.ctx.u8().slice(data, data + len); + } + + /** Reads the title (DATA_TITLE) into the pending effects. */ + private readTitle(): void { + this.titleChanged = false; + const { at, L } = this.ctx; + const S = L.string; + if (this.ctx.ex.ghostty_terminal_get(this.ctx.term, L.tdata.TITLE, at.str) !== 0) return; + const dv = this.ctx.dv(); + const ptr = dv.getUint32(at.str + S.ptr, true); + const len = dv.getUint32(at.str + S.len, true); + this.pendingTitle = len ? new TextDecoder().decode(this.ctx.u8().slice(ptr, ptr + len)) : ''; + } +} diff --git a/src/client/vendor/ghostty-web/engine/grid-refs.ts b/src/client/vendor/ghostty-web/engine/grid-refs.ts new file mode 100644 index 0000000..26cd367 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/grid-refs.ts @@ -0,0 +1,106 @@ +// webtty (ADR 042, ADR 050, ADR 061), not from upstream. +// Single-cell lookups through Ghostty grid references (object): points resolved to grid +// refs, grid refs back to screen cells, row wrap flags and OSC 8 hyperlink URIs. The +// selection and search bindings use it to cross between screen cells and grid refs. + +import type { EngineContext } from './engine-context'; +import type { ScreenCell } from './engine-types'; +import type { OfficialLayout } from './official-layout'; + +/** The scratch slots the grid refs declare on the shared context. */ +export function gridRefSlots(L: OfficialLayout) { + return { + point: [L.point.size, 8], + gridRef: [L.gridRef.size, 8], + coord: [8, 8], + row64: [8, 8], + } as const; +} + +/** The names of the grid refs' scratch slots. */ +export type GridRefSlot = keyof ReturnType<typeof gridRefSlots>; + +/** Room for most hyperlink URIs; a longer one is read again at the size Ghostty reports. */ +const HYPERLINK_FIRST_CAP = 256; + +/** Grid-ref reads on one terminal (ADR 042): a point in, a cell, flag or URI out. */ +export class GridRefs { + private readonly ctx: EngineContext<GridRefSlot>; + + constructor(ctx: EngineContext<GridRefSlot>) { + this.ctx = ctx; + } + + /** Resolves a point to a grid reference at `out` (by default the `gridRef` slot). */ + gridRef(tag: number, x: number, y: number, out: number = this.ctx.at.gridRef): boolean { + const { L, at } = this.ctx; + const P = L.point; + this.ctx.u8().fill(0, at.point, at.point + P.size); + const dv = this.ctx.dv(); + dv.setInt32(at.point + P.tag, tag, true); + dv.setUint16(at.point + P.value + P.x, x, true); + dv.setUint32(at.point + P.value + P.y, y, true); + this.ctx.u8().fill(0, out, out + L.gridRef.size); + dv.setUint32(out + L.gridRef.sizeField, L.gridRef.size, true); + return this.ctx.ex.ghostty_terminal_grid_ref(this.ctx.term, at.point, out) === 0; + } + + /** The grid reference of a screen cell at `out`; false off the grid. */ + screenRef(cell: ScreenCell, out: number): boolean { + if (cell.col < 0 || cell.col >= this.ctx.cols || cell.row < 0) return false; + return this.gridRef(this.ctx.L.point.SCREEN, cell.col, cell.row, out); + } + + /** The screen cell of the grid reference at `ref`, or null if it has none. */ + screenCellOf(ref: number): ScreenCell | null { + const { at, L } = this.ctx; + const P = L.point; + const rc = this.ctx.ex.ghostty_terminal_point_from_grid_ref( + this.ctx.term, + ref, + P.SCREEN, + at.coord + ); + if (rc !== 0) return null; + const dv = this.ctx.dv(); + return { col: dv.getUint16(at.coord + P.x, true), row: dv.getUint32(at.coord + P.y, true) }; + } + + /** Whether active row `row` continues on the next row (a soft wrap). */ + isRowWrapped(row: number): boolean { + if (row < 0 || row >= this.ctx.rows) return false; + const { ex, at, L } = this.ctx; + if (!this.gridRef(L.point.ACTIVE, 0, row)) return false; + if (ex.ghostty_grid_ref_row(at.gridRef, at.row64) !== 0) return false; + const rowValue = this.ctx.dv().getBigUint64(at.row64, true); + if (ex.ghostty_row_get(rowValue, L.rowWrapContinuation, at.u8) !== 0) return false; + return this.ctx.u8()[at.u8] !== 0; + } + + /** The OSC 8 URI of the cell at point (`tag`, `x`, `y`), or null if it has none. */ + hyperlinkAt(tag: number, x: number, y: number): string | null { + if (x < 0 || x >= this.ctx.cols || y < 0) return null; + const { ex, at, L } = this.ctx; + if (!this.gridRef(tag, x, y)) return null; + // Most URIs fit; a longer one (Ghostty keeps OSC 8 URIs up to about 2 KiB) is + // read again with the size Ghostty reports. + let cap = HYPERLINK_FIRST_CAP; + for (let attempt = 0; attempt < 2; attempt++) { + const size = cap; + const buf = ex.ghostty_wasm_alloc(size); + try { + const rc = ex.ghostty_grid_ref_hyperlink_uri(at.gridRef, buf, size, at.u32); + const n = this.ctx.dv().getUint32(at.u32, true); + if (rc === L.result.OUT_OF_SPACE) { + cap = n; + continue; + } + if (rc !== 0 || n === 0) return null; + return new TextDecoder().decode(this.ctx.u8().slice(buf, buf + n)); + } finally { + ex.ghostty_wasm_free(buf, size); + } + } + return null; + } +} diff --git a/src/client/vendor/ghostty-web/engine/mouse-encoder-binding.ts b/src/client/vendor/ghostty-web/engine/mouse-encoder-binding.ts new file mode 100644 index 0000000..75dbf10 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/mouse-encoder-binding.ts @@ -0,0 +1,140 @@ +// webtty (ADR 049, ADR 061), not from upstream. +// The mouse encoder binding (object): wraps one libghostty-vt mouse encoder and its +// event, made on the first report, turning mouse input into the report bytes for the +// app's tracking mode and format. Not to be confused with input/mouse-report.ts. + +import type { EngineContext } from './engine-context'; +import type { MouseGeometry, MouseInput } from './engine-types'; +import type { OfficialLayout } from './official-layout'; + +/** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ +export const MOUSE_REPORT_MAX = 64; + +/** + * The modes that set the mouse encoder's tracking mode and format: X10, normal, button, + * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. + */ +export const MOUSE_MODES = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; + +/** The scratch slots the mouse encoder binding declares on the shared context. */ +export function mouseEncoderSlots(L: OfficialLayout) { + return { + mouseSize: [L.mouse.size, 8], + mousePos: [L.mouse.positionSize, 8], + mouseOut: [MOUSE_REPORT_MAX, 8], + } as const; +} + +/** The names of the mouse encoder binding's scratch slots. */ +export type MouseEncoderSlot = keyof ReturnType<typeof mouseEncoderSlots>; + +/** The geometry fields, in the order their key joins them. */ +function geometryKey(g: MouseGeometry): string { + return [ + g.screenWidth, + g.screenHeight, + g.cellWidth, + g.cellHeight, + g.paddingTop, + g.paddingRight, + g.paddingBottom, + g.paddingLeft, + ].join(','); +} + +/** Mouse reports through Ghostty's encoder (ADR 049). */ +export class MouseEncoderBinding { + private readonly ctx: EngineContext<MouseEncoderSlot>; + // Encoder and event, made on the first report. `modes` is the MOUSE_MODES bitmask the + // encoder was last set from; `geometry` the last size, as its key. + private encoder = 0; + private event = 0; + private modes = -1; + private geometry = ''; + + constructor(ctx: EngineContext<MouseEncoderSlot>) { + this.ctx = ctx; + } + + /** Sets the screen and cell size reports are computed from; unchanged sizes are skipped. */ + setMouseGeometry(g: MouseGeometry): void { + // Setting the size clears the encoder's last cell, so only a change is set. + const key = geometryKey(g); + if (key === this.geometry) return; + const enc = this.ensureEncoder(); + const { at, L } = this.ctx; + const M = L.mouse; + const dv = this.ctx.dv(); + const u32 = (n: number) => Math.max(0, Math.round(n)) >>> 0; + dv.setUint32(at.mouseSize + M.sizeField, M.size, true); + dv.setUint32(at.mouseSize + M.screenWidth, u32(g.screenWidth), true); + dv.setUint32(at.mouseSize + M.screenHeight, u32(g.screenHeight), true); + // Ghostty ignores a zero cell size. + dv.setUint32(at.mouseSize + M.cellWidth, Math.max(1, u32(g.cellWidth)), true); + dv.setUint32(at.mouseSize + M.cellHeight, Math.max(1, u32(g.cellHeight)), true); + dv.setUint32(at.mouseSize + M.paddingTop, u32(g.paddingTop), true); + dv.setUint32(at.mouseSize + M.paddingRight, u32(g.paddingRight), true); + dv.setUint32(at.mouseSize + M.paddingBottom, u32(g.paddingBottom), true); + dv.setUint32(at.mouseSize + M.paddingLeft, u32(g.paddingLeft), true); + this.ctx.ex.ghostty_mouse_encoder_setopt(enc, M.SIZE, at.mouseSize); + this.geometry = key; + } + + /** The report for one mouse input, empty when the app's mode reports nothing. */ + encodeMouse(input: MouseInput): Uint8Array { + const { ex, at, L } = this.ctx; + const M = L.mouse; + const enc = this.ensureEncoder(); + this.syncModes(enc); + this.ctx.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; + ex.ghostty_mouse_encoder_setopt(enc, M.ANY_BUTTON_PRESSED, at.u8); + const ev = this.event; + ex.ghostty_mouse_event_set_action(ev, M.action[input.action]); + if (input.button) ex.ghostty_mouse_event_set_button(ev, M.button[input.button]); + else ex.ghostty_mouse_event_clear_button(ev); + ex.ghostty_mouse_event_set_mods(ev, input.mods); + const dv = this.ctx.dv(); + dv.setFloat32(at.mousePos + M.x, input.x, true); + dv.setFloat32(at.mousePos + M.y, input.y, true); + ex.ghostty_mouse_event_set_position(ev, at.mousePos); + const rc = ex.ghostty_mouse_encoder_encode(enc, ev, at.mouseOut, MOUSE_REPORT_MAX, at.u32); + if (rc !== 0) return new Uint8Array(0); + const n = this.ctx.dv().getUint32(at.u32, true); + return this.ctx.u8().slice(at.mouseOut, at.mouseOut + n); + } + + /** Frees the encoder and its event, if made. Safe to call again. */ + free(): void { + const ex = this.ctx.ex; + if (this.encoder) ex.ghostty_mouse_encoder_free(this.encoder); + if (this.event) ex.ghostty_mouse_event_free(this.event); + this.encoder = 0; + this.event = 0; + } + + /** + * Sets the tracking mode and format from the terminal. That call also clears the + * encoder's last cell, so it is made only when a mouse mode changed: per-cell motion + * deduplication would otherwise reset on every event. + */ + private syncModes(enc: number): void { + let modes = 0; + MOUSE_MODES.forEach((mode, i) => { + if (this.ctx.getMode(mode)) modes |= 1 << i; + }); + if (modes === this.modes) return; + this.ctx.ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.ctx.term); + this.modes = modes; + } + + /** The mouse encoder, made with per-cell motion deduplication on, and its event. */ + private ensureEncoder(): number { + if (this.encoder) return this.encoder; + const { ex, vt, at } = this.ctx; + this.encoder = vt.newHandle('mouse_encoder_new', (slot) => ex.ghostty_mouse_encoder_new(0, slot)); + this.event = vt.newHandle('mouse_event_new', (slot) => ex.ghostty_mouse_event_new(0, slot)); + this.ctx.u8()[at.u8] = 1; + ex.ghostty_mouse_encoder_setopt(this.encoder, this.ctx.L.mouse.TRACK_LAST_CELL, at.u8); + return this.encoder; + } +} diff --git a/src/client/vendor/ghostty-web/engine/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts index 23b8433..c2a0595 100644 --- a/src/client/vendor/ghostty-web/engine/official-terminal.ts +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -1,15 +1,14 @@ // webtty (ADR 042, ADR 047, ADR 061), not from upstream. // The terminal on the official libghostty-vt wasm (facade): `OfficialTerminal` implements // the `GhosttyTerminal` interface the rest of the vendored layer uses, on the wasm instance -// (`EngineHost`) that `Ghostty.createTerminal` passes it. Moved whole out of ghostty.ts (ADR 061); -// its parts share one `EngineContext`: the render state and the scrollback pages so far. +// (`EngineHost`) that `Ghostty.createTerminal` passes it. It owns the writes, the size and +// the view offset, and delegates every other area to a binding on one `EngineContext`. -import { type InstalledFn, WasmValType } from '../fn-table'; import { DirtyState, type GhosttyCell, type RenderStateCursor } from './cell-types'; -import { scanCursorBlink } from './cursor-blink'; +import { type ConfigSlot, configSlots, EngineConfig } from './engine-config'; import { EngineContext } from './engine-context'; +import { type EffectsSlot, EngineEffects, effectsSlots } from './engine-effects'; import type { - DesktopNotification, EngineHost, GhosttyTerminal, GhosttyTerminalConfig, @@ -20,13 +19,19 @@ import type { SearchState, TerminalEffects, } from './engine-types'; -import { isKittyKeyboardReply } from './kitty-keyboard-reply'; +import { type GridRefSlot, GridRefs, gridRefSlots } from './grid-refs'; +import { + type MouseEncoderSlot, + MouseEncoderBinding, + mouseEncoderSlots, +} from './mouse-encoder-binding'; import type { OfficialLayout } from './official-layout'; +import { type PasteFocusSlot, PasteFocusBinding, pasteFocusSlots } from './paste-focus-binding'; import { RenderState, type RenderStateSlot, renderStateSlots } from './render-state'; import { copyRow, graphemeOf } from './row-ref'; import { ScrollbackPages } from './scrollback-pages'; -import { isTextMime } from './text-mime'; -import type { OfficialWasmExports } from './wasm-exports'; +import { type SearchSlot, SearchBinding, searchSlots } from './search-binding'; +import { type SelectionSlot, SelectionBinding, selectionSlots } from './selection-binding'; // Rules of the official libghostty-vt binding (ADR 041, ADR 042): // - Struct offsets, enum values and the packed GhosttyCell bit positions come from @@ -41,56 +46,48 @@ import type { OfficialWasmExports } from './wasm-exports'; // copy their payload before returning. // - Callbacks never re-enter `vt_write`. -const I32 = WasmValType.I32; -/** The scrollback byte ceiling under the line limit (ADR 046). */ -const SCROLLBACK_CEILING_BYTES = 32 * 1024 * 1024; -/** History in lines when the config gives none (ADR 046, ADR 047). */ -const DEFAULT_SCROLLBACK_LINES = 10000; -/** webtty's default colours, used when the theme leaves one unset. */ -const FALLBACK_FOREGROUND = 0xcccccc; -const FALLBACK_BACKGROUND = 0x000000; -/** Room for one mouse report; the longest, SGR-pixels, is under 32 bytes. */ -const MOUSE_REPORT_MAX = 64; -/** Room for one focus report (`CSI I` / `CSI O`, 3 bytes). */ -const FOCUS_REPORT_MAX = 8; -/** - * The modes that set the mouse encoder's tracking mode and format: X10, normal, button, - * any-event tracking, and the UTF-8, SGR, urxvt and SGR-pixels formats. - */ -const MOUSE_MODES = [9, 1000, 1002, 1003, 1005, 1006, 1015, 1016]; -/** DEC private mode 12, AT&T cursor blink (ATT610). */ -const MODE_CURSOR_BLINK = 12; /** Bytes as one char each, for scanning ASCII sequences in byte output. */ const LATIN1 = new TextDecoder('latin1'); -/** The scratch slots of the parts still in the facade, until they become bindings (ADR 061). */ -function terminalSlots(L: OfficialLayout) { - return { - point: [L.point.size, 8], - gridRef: [L.gridRef.size, 8], - sel: [L.selection.size, 8], - selOpts: [ - Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), - 8, - ], - coord: [8, 8], - outPtr: [4, 4], - outLen: [4, 4], - row64: [8, 8], - str: [L.string.size, 8], - mode: [L.mode.size, 8], - scrollbar: [L.scrollbar.size, 8], - palette: [256 * L.colors.rgbSize, 8], - mouseSize: [L.mouse.size, 8], - mousePos: [L.mouse.positionSize, 8], - mouseOut: [MOUSE_REPORT_MAX, 8], - focusOut: [FOCUS_REPORT_MAX, 8], - clipReply: [L.clipboardWrite.replySize, 8], - searchBuf: [L.search.bufferSize, 8], - } as const; +/** The facade's own scratch slot: the scrollbar a prompt jump reads. */ +function facadeSlots(L: OfficialLayout) { + return { scrollbar: [L.scrollbar.size, 8] } as const; +} + +type FacadeSlot = keyof ReturnType<typeof facadeSlots>; +type TerminalSlot = + | FacadeSlot + | RenderStateSlot + | GridRefSlot + | SelectionSlot + | SearchSlot + | MouseEncoderSlot + | PasteFocusSlot + | EffectsSlot + | ConfigSlot; + +/** + * The scratch slot specs of the facade and each binding, in one list so a test can + * check that no two declare the same name (a later spread would silently share it). + */ +export function terminalSlotSpecs(L: OfficialLayout) { + return [ + renderStateSlots(L), + gridRefSlots(L), + selectionSlots(L), + searchSlots(L), + mouseEncoderSlots(L), + pasteFocusSlots(), + effectsSlots(L), + configSlots(L), + facadeSlots(L), + ] as const; } -type TerminalSlot = keyof ReturnType<typeof terminalSlots>; +/** Every slot the terminal's context allocates, beyond the context's own. */ +function terminalSlots(L: OfficialLayout): Record<TerminalSlot, readonly [number, number]> { + return Object.assign({}, ...terminalSlotSpecs(L)); +} /** * A terminal on the official libghostty-vt wasm (ADR 042). @@ -98,76 +95,42 @@ type TerminalSlot = keyof ReturnType<typeof terminalSlots>; * Rows are read through `RenderState`, which keeps a JS copy of the active screen up to * date once per generation, and `ScrollbackPages`, which reads scrollback a page at a * time and caches it until the content changes, so scrolling only records the offset - * and serves rows from the copy and the cache. The other wasm objects (selection, - * search, mouse and paste encoders, callbacks) are still driven from here. + * and serves rows from the copy and the cache. Selection, search, mouse, paste and + * focus reports, the callbacks and the config each have a binding (ADR 061). */ export class OfficialTerminal implements GhosttyTerminal { - private readonly vt: EngineHost; - private readonly ctx: EngineContext<TerminalSlot | RenderStateSlot>; - private readonly ex: OfficialWasmExports; - private readonly L: OfficialLayout; - private readonly at: EngineContext<TerminalSlot | RenderStateSlot>['at']; + private readonly ctx: EngineContext<TerminalSlot>; private readonly render: RenderState; private readonly pages: ScrollbackPages; - private readonly callbacks: InstalledFn[] = []; - private cellWidthPx = 0; - private cellHeightPx = 0; - - // The blink DECSCUSR set, or the default while it has not set one; DEC mode 12 is - // ignored, so Ghostty's mode bit is put back to it after every write (ADR 049). - private blinkDefault = false; - private blinkIsDefault = true; - private blink = false; - private blinkCarry = ''; + private readonly refs: GridRefs; + private readonly selection: SelectionBinding; + private readonly search: SearchBinding; + private readonly mouse: MouseEncoderBinding; + private readonly pasteFocus: PasteFocusBinding; + private readonly effects: EngineEffects; + private readonly config: EngineConfig; // The view's offset: lines scrolled up from the bottom. Ghostty's viewport stays at // the bottom; only page reads move it, and they move it back. private offset = 0; - // Mouse encoder and event, made on the first report (ADR 049). `mouseModes` is the - // MOUSE_MODES bitmask the encoder was last set from; `mouseGeometry` the last size. - private mouseEncoder = 0; - private mouseEvent = 0; - private mouseModes = -1; - private mouseGeometry = ''; - - // Paste: the mime reader callback, installed on the first paste, and the text it - // writes during a `paste` call (ADR 049). - private pasteReader = 0; - private pasteText = { ptr: 0, len: 0 }; - - // Effects. - private readonly responses: Uint8Array[] = []; - private bell = false; - private titleChanged = false; - private pendingTitle: string | null = null; - private pendingClipboard: string | null = null; - private pendingNotifications: DesktopNotification[] = []; - - // Search (ADR 055): the handle while a needle is set, the generation it last fed, and - // the wasm buffer matches are read into, grown as needed. - private search = 0; - private searchFedGen = 0; - private matchBuf = 0; - private matchCap = 0; - constructor(vt: EngineHost, cols: number, rows: number, config?: GhosttyTerminalConfig) { - this.vt = vt; - this.ex = vt.ex; - this.L = vt.L; - this.ctx = new EngineContext(vt, cols, rows, { - ...renderStateSlots(vt.L), - ...terminalSlots(vt.L), + const ctx = new EngineContext(vt, cols, rows, terminalSlots(vt.L)); + this.ctx = ctx; + this.render = new RenderState(ctx); + this.pages = new ScrollbackPages(ctx, this.render); + this.refs = new GridRefs(ctx); + this.selection = new SelectionBinding(ctx, this.refs); + this.search = new SearchBinding(ctx, this.refs); + this.mouse = new MouseEncoderBinding(ctx); + this.pasteFocus = new PasteFocusBinding(ctx); + // The callbacks are installed before the config is applied, as Ghostty sees them. + this.effects = new EngineEffects(ctx, { + clipboardWrite: config?.clipboardWrite ?? true, + desktopNotifications: config?.desktopNotifications ?? true, }); - this.at = this.ctx.at; - this.render = new RenderState(this.ctx); - this.pages = new ScrollbackPages(this.ctx, this.render); - - this.installCallbacks( - config?.clipboardWrite ?? true, - config?.desktopNotifications ?? true - ); - this.applyConfig(config); + this.config = new EngineConfig(ctx); + this.config.apply(config); } get cols(): number { @@ -177,9 +140,8 @@ export class OfficialTerminal implements GhosttyTerminal { get rows(): number { return this.ctx.rows; } - // ========================================================================== + // Lifecycle - // ========================================================================== write(data: string | Uint8Array): void { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; @@ -198,88 +160,63 @@ export class OfficialTerminal implements GhosttyTerminal { this.offset = 0; this.render.markAllDirty(); } - const ex = this.ex; + const { ex, at, L } = this.ctx; let consumed: number | null = bytes.length; // until_ground takes NULL for no bytes, and then only says whether it is at ground const ptr = bytes.length > 0 ? ex.ghostty_wasm_alloc(bytes.length) : 0; if (bytes.length > 0 && !ptr) throw new Error('libghostty-vt: out of memory'); if (ptr) this.ctx.u8().set(bytes, ptr); if (untilGround) { - const rc = ex.ghostty_terminal_vt_write_until_ground(this.ctx.term, ptr, bytes.length, this.at.u32); - consumed = rc === this.L.result.SUCCESS ? this.ctx.dv().getUint32(this.at.u32, true) : null; + const rc = ex.ghostty_terminal_vt_write_until_ground(this.ctx.term, ptr, bytes.length, at.u32); + consumed = rc === L.result.SUCCESS ? this.ctx.dv().getUint32(at.u32, true) : null; } else if (ptr) { ex.ghostty_terminal_vt_write(this.ctx.term, ptr, bytes.length); } if (ptr) ex.ghostty_wasm_free(ptr, bytes.length); const fed = consumed ?? bytes.length; - if (fed > 0) { - const scan = scanCursorBlink( - text ?? LATIN1.decode(bytes.subarray(0, fed)), - this.blinkCarry - ); - this.blinkCarry = scan.carry; - if (scan.ps !== undefined) { - this.blinkIsDefault = scan.ps === 0; - this.blink = scan.ps === 0 ? this.blinkDefault : scan.ps % 2 === 1; - } - this.syncBlink(); - } + if (fed > 0) this.config.afterWrite(text ?? LATIN1.decode(bytes.subarray(0, fed))); this.ctx.generation++; - if (this.titleChanged) this.readTitle(); + this.effects.afterWrite(); return consumed; } resize(cols: number, rows: number, cellWidthPx?: number, cellHeightPx?: number): void { - if (cols === this.ctx.cols && rows === this.ctx.rows) { + const ctx = this.ctx; + if (cols === ctx.cols && rows === ctx.rows) { if (cellWidthPx !== undefined && cellHeightPx !== undefined) { this.setCellPixelSize(cellWidthPx, cellHeightPx); } return; } - this.ctx.cols = cols; - this.ctx.rows = rows; + ctx.cols = cols; + ctx.rows = rows; if (cellWidthPx !== undefined && cellHeightPx !== undefined) { - this.cellWidthPx = Math.max(0, Math.round(cellWidthPx)); - this.cellHeightPx = Math.max(0, Math.round(cellHeightPx)); + ctx.cellWidthPx = Math.max(0, Math.round(cellWidthPx)); + ctx.cellHeightPx = Math.max(0, Math.round(cellHeightPx)); } // One call per real change: with mode 2048 on, every call sends a size report. - const rc = this.ex.ghostty_terminal_resize( - this.ctx.term, - cols, - rows, - this.cellWidthPx, - this.cellHeightPx - ); + const rc = ctx.ex.ghostty_terminal_resize(ctx.term, cols, rows, ctx.cellWidthPx, ctx.cellHeightPx); if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); - this.ctx.generation++; + ctx.generation++; this.render.resized(); // Reflow keeps Ghostty's viewport at the bottom; only the offset is clamped. if (this.offset > 0) this.setViewportOffset(this.offset); } setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { + const ctx = this.ctx; const w = Math.max(0, Math.round(cellWidthPx)); const h = Math.max(0, Math.round(cellHeightPx)); - if (w === this.cellWidthPx && h === this.cellHeightPx) return; - this.cellWidthPx = w; - this.cellHeightPx = h; - const rc = this.ex.ghostty_terminal_resize(this.ctx.term, this.ctx.cols, this.ctx.rows, w, h); + if (w === ctx.cellWidthPx && h === ctx.cellHeightPx) return; + ctx.cellWidthPx = w; + ctx.cellHeightPx = h; + const rc = ctx.ex.ghostty_terminal_resize(ctx.term, ctx.cols, ctx.rows, w, h); if (rc !== 0) throw new Error(`libghostty-vt terminal_resize failed: ${rc}`); - this.ctx.generation++; + ctx.generation++; } setDefaultCursor(style: RenderStateCursor['style'], blink: boolean): void { - const L = this.L; - const at = this.at; - this.ctx.dv().setInt32(at.u32, L.defaultCursorStyle[style], true); - this.ctx.setOption(L.opt.DEFAULT_CURSOR_STYLE, at.u32, 'DEFAULT_CURSOR_STYLE'); - this.ctx.u8()[at.u8] = blink ? 1 : 0; - this.ctx.setOption(L.opt.DEFAULT_CURSOR_BLINK, at.u8, 'DEFAULT_CURSOR_BLINK'); - this.blinkDefault = blink; - if (this.blinkIsDefault) this.blink = blink; - this.syncBlink(); - // The cursor is read from the render state, which only a content change refreshes. - this.ctx.generation++; + this.config.setDefaultCursor(style, blink); } /** @@ -298,26 +235,20 @@ export class OfficialTerminal implements GhosttyTerminal { } free(): void { - const ex = this.ex; if (!this.ctx.term) return; - this.setSearch(''); - ex.ghostty_terminal_free(this.ctx.term); + this.search.free(); + this.ctx.ex.ghostty_terminal_free(this.ctx.term); this.ctx.term = 0; - // Slots are released only after nothing can call them (fn-table.ts rule 3). - for (const fn of this.callbacks) fn.release(); - this.callbacks.length = 0; - if (this.mouseEncoder) ex.ghostty_mouse_encoder_free(this.mouseEncoder); - if (this.mouseEvent) ex.ghostty_mouse_event_free(this.mouseEvent); - this.mouseEncoder = 0; - this.mouseEvent = 0; + // Callback slots are released only after nothing can call them (fn-table.ts rule 3). + this.effects.free(); + this.pasteFocus.free(); + this.mouse.free(); this.pages.free(); this.render.free(); this.ctx.scratch.free(); } - // ========================================================================== // Render state - // ========================================================================== update(): DirtyState { return this.render.update(); @@ -358,9 +289,7 @@ export class OfficialTerminal implements GhosttyTerminal { return { cols: this.ctx.cols, rows: this.ctx.rows }; } - // ========================================================================== // Modes - // ========================================================================== isAlternateScreen(): boolean { this.ctx.info(); @@ -368,48 +297,42 @@ export class OfficialTerminal implements GhosttyTerminal { } hasMouseTracking(): boolean { - const at = this.at; - const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.MOUSE_TRACKING, at.u8); - return rc === 0 && this.ctx.u8()[at.u8] !== 0; + return this.getFlag(this.ctx.L.tdata.MOUSE_TRACKING); } cursorAtPrompt(): boolean { - const at = this.at; - const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.CURSOR_AT_PROMPT, at.u8); - return rc === 0 && this.ctx.u8()[at.u8] !== 0; + return this.getFlag(this.ctx.L.tdata.CURSOR_AT_PROMPT); } promptJumpRow(top: number, delta: number): number { - const at = this.at; - const S = this.L.scroll; + const { ex, at, L } = this.ctx; + const S = L.scroll; this.ctx.info(); // Ghostty moves its viewport from its top row, so it is put on webtty's for the move this.ctx.scrollViewport(S.ROW, top); try { this.ctx.scrollViewport(S.DELTA_PROMPT, delta); - const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.SCROLLBAR, at.scrollbar); + const rc = ex.ghostty_terminal_get(this.ctx.term, L.tdata.SCROLLBAR, at.scrollbar); if (rc !== 0) return top; // The offset is a u64; a screen row fits in the low 32 bits - return this.ctx.dv().getUint32(at.scrollbar + this.L.scrollbar.offset, true); + return this.ctx.dv().getUint32(at.scrollbar + L.scrollbar.offset, true); } finally { this.ctx.scrollViewport(S.BOTTOM, 0); } } getMode(mode: number, isAnsi: boolean = false): boolean { - const at = this.at; - const M = this.L.mode; - const dv = this.ctx.dv(); - // GhosttyMode (modes.h ghostty_mode_new): number in bits 0-14, bit 15 = ANSI. - dv.setUint16(at.mode + M.mode, (mode & 0x7fff) | (isAnsi ? 0x8000 : 0), true); - dv.setUint8(at.mode + M.value, 0); - const rc = this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.MODE, at.mode); - return rc === 0 && this.ctx.u8()[at.mode + M.value] !== 0; + return this.ctx.getMode(mode, isAnsi); + } + + /** A one-byte bool terminal data item; false if Ghostty refuses the read. */ + private getFlag(data: number): boolean { + const at = this.ctx.at; + const rc = this.ctx.ex.ghostty_terminal_get(this.ctx.term, data, at.u8); + return rc === 0 && this.ctx.u8()[at.u8] !== 0; } - // ========================================================================== // Scrollback, graphemes, hyperlinks - // ========================================================================== getScrollbackLength(): number { this.ctx.info(); @@ -425,22 +348,16 @@ export class OfficialTerminal implements GhosttyTerminal { } isRowWrapped(row: number): boolean { - if (row < 0 || row >= this.ctx.rows) return false; - const at = this.at; - if (!this.gridRef(this.L.point.ACTIVE, 0, row)) return false; - if (this.ex.ghostty_grid_ref_row(at.gridRef, at.row64) !== 0) return false; - const rowValue = this.ctx.dv().getBigUint64(at.row64, true); - if (this.ex.ghostty_row_get(rowValue, this.L.rowWrapContinuation, at.u8) !== 0) return false; - return this.ctx.u8()[at.u8] !== 0; + return this.refs.isRowWrapped(row); } getHyperlinkUri(row: number, col: number): string | null { if (row < 0 || row >= this.ctx.rows) return null; - return this.hyperlinkAt(this.L.point.ACTIVE, col, row); + return this.refs.hyperlinkAt(this.ctx.L.point.ACTIVE, col, row); } getScrollbackHyperlinkUri(offset: number, col: number): string | null { - return this.hyperlinkAt(this.L.point.HISTORY, col, offset); + return this.refs.hyperlinkAt(this.ctx.L.point.HISTORY, col, offset); } getGraphemeString(row: number, col: number): string { @@ -455,804 +372,79 @@ export class OfficialTerminal implements GhosttyTerminal { return graphemeOf(this.pages.rowAt(offset), col); } - // ========================================================================== // Selection (ADR 050) - // ========================================================================== selectWord(cell: ScreenCell): ScreenRange | null { - const W = this.L.selection.word; - return this.selectAt(W, cell, (opts, out) => - this.ex.ghostty_terminal_select_word(this.ctx.term, opts, out) - ); + return this.selection.selectWord(cell); } selectLine(cell: ScreenCell): ScreenRange | null { - const W = this.L.selection.line; - return this.selectAt(W, cell, (opts, out) => - this.ex.ghostty_terminal_select_line(this.ctx.term, opts, out) - ); + return this.selection.selectLine(cell); } selectOutput(cell: ScreenCell): ScreenRange | null { - const at = this.at; - const S = this.L.selection; - this.ctx.info(); - if (!this.screenRef(cell, at.gridRef)) return null; - this.clearSelectionStruct(); - if (this.ex.ghostty_terminal_select_output(this.ctx.term, at.gridRef, at.sel) !== 0) return null; - const start = this.screenCellOf(at.sel + S.start); - const end = this.screenCellOf(at.sel + S.end); - return start && end ? { start, end } : null; + return this.selection.selectOutput(cell); } selectionText(start: ScreenCell, end: ScreenCell): string { - const ex = this.ex; - const at = this.at; - const S = this.L.selection; - const F = S.format; - this.ctx.info(); - // Rows that left history, or a selection made before a resize, are clamped. - const last = this.ctx.sbRows + this.ctx.rows - 1; - const clamp = (c: ScreenCell): ScreenCell => ({ - col: Math.max(0, Math.min(c.col, this.ctx.cols - 1)), - row: Math.max(0, Math.min(c.row, last)), - }); - this.clearSelectionStruct(); - if (!this.screenRef(clamp(start), at.sel + S.start)) return ''; - if (!this.screenRef(clamp(end), at.sel + S.end)) return ''; - this.ctx.u8().fill(0, at.selOpts, at.selOpts + F.size); - const dv = this.ctx.dv(); - dv.setUint32(at.selOpts + F.sizeField, F.size, true); - dv.setInt32(at.selOpts + F.emit, S.PLAIN, true); - // Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed. - dv.setUint8(at.selOpts + F.unwrap, 1); - dv.setUint8(at.selOpts + F.trim, 1); - dv.setUint32(at.selOpts + F.selection, at.sel, true); - dv.setUint32(at.outPtr, 0, true); - dv.setUint32(at.outLen, 0, true); - const rc = ex.ghostty_terminal_selection_format_alloc( - this.ctx.term, - 0, - at.selOpts, - at.outPtr, - at.outLen - ); - const ptr = this.ctx.dv().getUint32(at.outPtr, true); - const len = this.ctx.dv().getUint32(at.outLen, true); - if (rc !== 0 || !ptr) return ''; - const text = new TextDecoder().decode(this.ctx.u8().slice(ptr, ptr + len)); - ex.ghostty_free(0, ptr, len); - return text; + return this.selection.selectionText(start, end); } - // ========================================================================== // Search (ADR 055) - // ========================================================================== setSearch(needle: string): void { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - if (!needle) { - if (this.search) ex.ghostty_search_free(this.search); - this.search = 0; - if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * this.L.selection.size); - this.matchBuf = 0; - this.matchCap = 0; - return; - } - if (!this.search) { - this.search = this.vt.newHandle('search_new', (slot) => - ex.ghostty_search_new(0, slot, this.ctx.term) - ); - // The view is webtty's own; Ghostty's viewport stays at the bottom - this.ctx.dv().setInt32(at.u32, S.SCROLL_NONE, true); - ex.ghostty_search_set(this.search, S.SELECT_SCROLL, at.u32); - } - const bytes = new TextEncoder().encode(needle); - const buf = ex.ghostty_wasm_alloc(bytes.length); - try { - this.ctx.u8().set(bytes, buf); - const dv = this.ctx.dv(); - dv.setUint32(at.str + this.L.string.ptr, buf, true); - dv.setUint32(at.str + this.L.string.len, bytes.length, true); - // An unchanged needle keeps its results - ex.ghostty_search_set(this.search, S.NEEDLE, at.str); - } finally { - ex.ghostty_wasm_free(buf, bytes.length); - } - this.searchFedGen = 0; + this.search.setSearch(needle); } searchStep(budgetMs: number): boolean { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - if (!this.search) return false; - let worked = false; - // Feeding is the only way the search sees output, resizes and resets - if (this.searchFedGen !== this.ctx.generation) { - ex.ghostty_search_feed(this.search); - this.searchFedGen = this.ctx.generation; - worked = true; - } - if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) return worked; - let status = this.ctx.dv().getInt32(at.u32, true); - const end = performance.now() + budgetMs; - while (status !== S.COMPLETE && performance.now() < end) { - if (status === S.FEED_REQUIRED) ex.ghostty_search_feed(this.search); - else ex.ghostty_search_tick(this.search, 0); - if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) break; - status = this.ctx.dv().getInt32(at.u32, true); - worked = true; - } - return worked; + return this.search.searchStep(budgetMs); } searchSelect(direction: 'next' | 'previous'): boolean { - const S = this.L.search; - if (!this.search) return false; - const option = direction === 'next' ? S.SELECT_NEXT : S.SELECT_PREV; - return this.ex.ghostty_search_set(this.search, option, 0) === 0; + return this.search.searchSelect(direction); } searchState(): SearchState { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - if (!this.search) return { total: 0, selected: null }; - const read = (data: number): number | null => - ex.ghostty_search_get(this.search, data, at.u32) === 0 - ? this.ctx.dv().getUint32(at.u32, true) - : null; - return { total: read(S.TOTAL_MATCHES) ?? 0, selected: read(S.SELECTED_INDEX) }; + return this.search.searchState(); } searchSelectedMatch(): ScreenRange | null { - const at = this.at; - const Sel = this.L.selection; - if (!this.search) return null; - this.clearSelectionStruct(); - if (this.ex.ghostty_search_get(this.search, this.L.search.SELECTED_MATCH, at.sel) !== 0) { - return null; - } - const start = this.screenCellOf(at.sel + Sel.start); - const end = this.screenCellOf(at.sel + Sel.end); - return start && end ? { start, end } : null; + return this.search.searchSelectedMatch(); } searchMatches(): Int32Array { - const ex = this.ex; - const at = this.at; - const S = this.L.search; - const Sel = this.L.selection; - if (!this.search) return new Int32Array(0); - // Matches are read where they are, before anything changes the terminal; the - // buffer is grown to the count Ghostty reports, then read again. - for (let attempt = 0; attempt < 2; attempt++) { - const dv = this.ctx.dv(); - dv.setUint32(at.searchBuf + S.ptr, this.matchBuf, true); - dv.setUint32(at.searchBuf + S.cap, this.matchCap, true); - dv.setUint32(at.searchBuf + S.len, 0, true); - for (let i = 0; i < this.matchCap; i++) { - dv.setUint32(this.matchBuf + i * Sel.size + Sel.sizeField, Sel.size, true); - } - const rc = ex.ghostty_search_get(this.search, S.MATCHES, at.searchBuf); - const len = this.ctx.dv().getUint32(at.searchBuf + S.len, true); - if (rc === this.L.result.OUT_OF_SPACE && attempt === 0) { - if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * Sel.size); - // Room for the matches output adds before the next read - this.matchCap = len + (len >> 2) + 16; - this.matchBuf = ex.ghostty_wasm_alloc(this.matchCap * Sel.size); - if (!this.matchBuf) { - this.matchCap = 0; - return new Int32Array(0); - } - continue; - } - if (rc !== 0) return new Int32Array(0); - const out = new Int32Array(len * 4); - for (let i = 0; i < len; i++) { - const sel = this.matchBuf + i * Sel.size; - const start = this.screenCellOf(sel + Sel.start); - const end = this.screenCellOf(sel + Sel.end); - // A match that has no screen point (none expected) is left at row -1 - out[i * 4] = start?.col ?? 0; - out[i * 4 + 1] = start?.row ?? -1; - out[i * 4 + 2] = end?.col ?? 0; - out[i * 4 + 3] = end?.row ?? -1; - } - return out; - } - return new Int32Array(0); + return this.search.searchMatches(); } - // ========================================================================== - // Mouse reports (ADR 049) - // ========================================================================== + // Mouse, focus and paste (ADR 049, ADR 053) setMouseGeometry(geometry: MouseGeometry): void { - const g = geometry; - // Setting the size clears the encoder's last cell, so only a change is set. - const key = [ - g.screenWidth, - g.screenHeight, - g.cellWidth, - g.cellHeight, - g.paddingTop, - g.paddingRight, - g.paddingBottom, - g.paddingLeft, - ].join(','); - if (key === this.mouseGeometry) return; - const enc = this.ensureMouseEncoder(); - const at = this.at; - const M = this.L.mouse; - const dv = this.ctx.dv(); - const u32 = (n: number) => Math.max(0, Math.round(n)) >>> 0; - dv.setUint32(at.mouseSize + M.sizeField, M.size, true); - dv.setUint32(at.mouseSize + M.screenWidth, u32(g.screenWidth), true); - dv.setUint32(at.mouseSize + M.screenHeight, u32(g.screenHeight), true); - // Ghostty ignores a zero cell size. - dv.setUint32(at.mouseSize + M.cellWidth, Math.max(1, u32(g.cellWidth)), true); - dv.setUint32(at.mouseSize + M.cellHeight, Math.max(1, u32(g.cellHeight)), true); - dv.setUint32(at.mouseSize + M.paddingTop, u32(g.paddingTop), true); - dv.setUint32(at.mouseSize + M.paddingRight, u32(g.paddingRight), true); - dv.setUint32(at.mouseSize + M.paddingBottom, u32(g.paddingBottom), true); - dv.setUint32(at.mouseSize + M.paddingLeft, u32(g.paddingLeft), true); - this.ex.ghostty_mouse_encoder_setopt(enc, M.SIZE, at.mouseSize); - this.mouseGeometry = key; + this.mouse.setMouseGeometry(geometry); } encodeMouse(input: MouseInput): Uint8Array { - const ex = this.ex; - const at = this.at; - const M = this.L.mouse; - const enc = this.ensureMouseEncoder(); - // The tracking mode and format, from the terminal. That call also clears the - // encoder's last cell, so it is made only when a mouse mode changed: per-cell motion - // deduplication would otherwise reset on every event. - let modes = 0; - MOUSE_MODES.forEach((mode, i) => { - if (this.getMode(mode)) modes |= 1 << i; - }); - if (modes !== this.mouseModes) { - ex.ghostty_mouse_encoder_setopt_from_terminal(enc, this.ctx.term); - this.mouseModes = modes; - } - this.ctx.u8()[at.u8] = input.anyButtonPressed ? 1 : 0; - ex.ghostty_mouse_encoder_setopt(enc, M.ANY_BUTTON_PRESSED, at.u8); - const ev = this.mouseEvent; - ex.ghostty_mouse_event_set_action(ev, M.action[input.action]); - if (input.button) ex.ghostty_mouse_event_set_button(ev, M.button[input.button]); - else ex.ghostty_mouse_event_clear_button(ev); - ex.ghostty_mouse_event_set_mods(ev, input.mods); - const dv = this.ctx.dv(); - dv.setFloat32(at.mousePos + M.x, input.x, true); - dv.setFloat32(at.mousePos + M.y, input.y, true); - ex.ghostty_mouse_event_set_position(ev, at.mousePos); - const rc = ex.ghostty_mouse_encoder_encode(enc, ev, at.mouseOut, MOUSE_REPORT_MAX, at.u32); - if (rc !== 0) return new Uint8Array(0); - const n = this.ctx.dv().getUint32(at.u32, true); - return this.ctx.u8().slice(at.mouseOut, at.mouseOut + n); + return this.mouse.encodeMouse(input); } - /** The mouse encoder, made with per-cell motion deduplication on, and its event. */ - private ensureMouseEncoder(): number { - if (this.mouseEncoder) return this.mouseEncoder; - const ex = this.ex; - const vt = this.vt; - this.mouseEncoder = vt.newHandle('mouse_encoder_new', (slot) => - ex.ghostty_mouse_encoder_new(0, slot) - ); - this.mouseEvent = vt.newHandle('mouse_event_new', (slot) => - ex.ghostty_mouse_event_new(0, slot) - ); - this.ctx.u8()[this.at.u8] = 1; - ex.ghostty_mouse_encoder_setopt(this.mouseEncoder, this.L.mouse.TRACK_LAST_CELL, this.at.u8); - return this.mouseEncoder; - } - - // ========================================================================== - // Focus reports (ADR 053) - // ========================================================================== - encodeFocus(focused: boolean): Uint8Array { - const at = this.at; - const F = this.L.focus; - const event = focused ? F.GAINED : F.LOST; - const rc = this.ex.ghostty_focus_encode(event, at.focusOut, FOCUS_REPORT_MAX, at.u32); - if (rc !== 0) return new Uint8Array(0); - const n = this.ctx.dv().getUint32(at.u32, true); - return this.ctx.u8().slice(at.focusOut, at.focusOut + n); + return this.pasteFocus.encodeFocus(focused); } - // ========================================================================== - // Paste (ADR 049) - // ========================================================================== - paste(text: string): void { - const ex = this.ex; - const P = this.L.paste; - const S = this.L.string; - const bytes = new TextEncoder().encode(text); - const mime = new TextEncoder().encode('text/plain'); - // One allocation: the paste struct, the one-entry mime array, the mime and the text. - const mimesAt = Math.ceil(P.size / 8) * 8; - const mimeAt = mimesAt + S.size; - const textAt = mimeAt + mime.length; - const len = textAt + bytes.length; - const base = ex.ghostty_wasm_alloc(len); - if (!base) throw new Error('libghostty-vt: out of memory'); - try { - const u8 = this.ctx.u8(); - u8.fill(0, base, base + P.size); - u8.set(mime, base + mimeAt); - u8.set(bytes, base + textAt); - const dv = this.ctx.dv(); - dv.setUint32(base + mimesAt + S.ptr, base + mimeAt, true); - dv.setUint32(base + mimesAt + S.len, mime.length, true); - dv.setUint32(base + P.sizeField, P.size, true); - dv.setInt32(base + P.location, P.STANDARD, true); - dv.setInt32(base + P.source, P.CLIPBOARD, true); - dv.setUint32(base + P.mimes, base + mimesAt, true); - dv.setUint32(base + P.mimesLen, 1, true); - dv.setUint32(base + P.reader + P.readerRead, this.ensurePasteReader(), true); - // Text that could run commands (a newline without 2004) is sent, never refused: - // ADR 037, and webtty has no confirmation dialog. - dv.setUint8(base + P.allowUnsafe, 1); - this.pasteText = { ptr: base + textAt, len: bytes.length }; - const rc = ex.ghostty_terminal_paste(this.ctx.term, base, 0); - if (rc !== 0) throw new Error(`libghostty-vt terminal_paste failed: ${rc}`); - } finally { - this.pasteText = { ptr: 0, len: 0 }; - ex.ghostty_wasm_free(base, len); - } + this.pasteFocus.paste(text); } - /** - * The GhosttyMimeReader callback: `(userdata, mime, writer)`, the GhosttyString and - * GhosttyWriter structs passed by pointer on wasm32. It writes the text of the paste in - * progress through the writer, a function in the wasm's table, and returns its result. - */ - private ensurePasteReader(): number { - if (this.pasteReader) return this.pasteReader; - const P = this.L.paste; - const table = this.ex.__indirect_function_table; - const installed = this.vt.fnTable.install( - ((_userdata: number, _mime: number, writer: number) => { - const { ptr, len } = this.pasteText; - if (len === 0) return 1; - const dv = this.ctx.dv(); - const write = table.get(dv.getUint32(writer + P.writerWrite, true)) as ( - userdata: number, - data: number, - len: number - ) => number; - return write(dv.getUint32(writer + P.writerUserdata, true), ptr, len) ? 1 : 0; - }) as (...args: number[]) => number, - [I32, I32, I32], - [I32] - ); - this.callbacks.push(installed); - this.pasteReader = installed.index; - return this.pasteReader; - } - - // ========================================================================== // Replies and effects - // ========================================================================== hasResponse(): boolean { - return this.responses.length > 0; + return this.effects.hasResponse(); } readResponse(): string | null { - if (this.responses.length === 0) return null; - let total = 0; - for (const r of this.responses) total += r.length; - const all = new Uint8Array(total); - let off = 0; - for (const r of this.responses) { - all.set(r, off); - off += r.length; - } - this.responses.length = 0; - return new TextDecoder().decode(all); + return this.effects.readResponse(); } readEffects(): TerminalEffects { - const effects = { - bell: this.bell, - title: this.pendingTitle, - clipboard: this.pendingClipboard, - notifications: this.pendingNotifications, - }; - this.bell = false; - this.pendingTitle = null; - this.pendingClipboard = null; - this.pendingNotifications = []; - return effects; - } - - // ========================================================================== - // Private: colours and the cursor blink - // ========================================================================== - - private writeRgb(ptr: number, rgb: number): void { - const u8 = this.ctx.u8(); - const C = this.L.colors; - u8[ptr + C.r] = (rgb >>> 16) & 0xff; - u8[ptr + C.g] = (rgb >>> 8) & 0xff; - u8[ptr + C.b] = rgb & 0xff; - } - - /** - * Puts Ghostty's mode 12 bit, which its render state reports as the cursor blink, - * back to the blink DECSCUSR set. Ghostty with `cursor-style-blink` set ignores mode - * 12 (`setMode` in its `termio/stream_handler.zig`), and webtty's `cursorStyleBlink` - * is always set, so a program's `CSI ? 12 l` (vim's `t_ve`) no longer stops the - * blink for good. (webtty, ADR 049) - */ - private syncBlink(): void { - if (this.getMode(MODE_CURSOR_BLINK) === this.blink) return; - const at = this.at; - const M = this.L.mode; - const dv = this.ctx.dv(); - dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); - dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); - this.ctx.setOption(this.L.opt.MODE, at.mode, 'MODE 12'); - } - - // ========================================================================== - // Private: setup - // ========================================================================== - - private installCallbacks(clipboardWrite: boolean, desktopNotifications: boolean): void { - const L = this.L; - const table = this.vt.fnTable; - const install = ( - option: number, - what: string, - fn: (...args: number[]) => number | undefined, - params: number, - returnsBool: boolean - ) => { - const installed = table.install( - fn as (...args: number[]) => number | void, - new Array(params).fill(I32), - returnsBool ? [I32] : [] - ); - this.callbacks.push(installed); - this.ctx.setOption(option, installed.index, what); - }; - - // WRITE_PTY(terminal, userdata, data, len): copy the reply out before returning. - // Each call carries one whole reply. The kitty keyboard query reply is dropped: - // webtty's input handler never encodes keys with the kitty flags, so answering - // `CSI ? u` would tell apps the protocol is on when it is not (ADR 042). - install( - L.opt.WRITE_PTY, - 'WRITE_PTY', - (_t, _u, ptr, len) => { - const reply = this.ctx.u8().slice(ptr, ptr + len); - if (!isKittyKeyboardReply(reply)) this.responses.push(reply); - return undefined; - }, - 4, - false - ); - install( - L.opt.BELL, - 'BELL', - () => { - this.bell = true; - return undefined; - }, - 2, - false - ); - // The title is read after vt_write returns (DATA_TITLE), not inside the callback. - install( - L.opt.TITLE_CHANGED, - 'TITLE_CHANGED', - () => { - this.titleChanged = true; - return undefined; - }, - 2, - false - ); - // SIZE(terminal, userdata, out): XTWINOPS 14/16/18/19 t and mode 2048 reports. - install( - L.opt.SIZE, - 'SIZE', - (_t, _u, out) => { - const Z = L.sizeReport; - const dv = this.ctx.dv(); - dv.setUint16(out + Z.rows, this.ctx.rows, true); - dv.setUint16(out + Z.columns, this.ctx.cols, true); - dv.setUint32(out + Z.cellWidth, this.cellWidthPx, true); - dv.setUint32(out + Z.cellHeight, this.cellHeightPx, true); - return 1; - }, - 3, - true - ); - // DEVICE_ATTRIBUTES: ESC[?62;22c and ESC[>1;10;0c, the replies webtty gave on - // the Coder wasm (without it, Ghostty's secondary DA is ESC[>1;0;0c). - install( - L.opt.DEVICE_ATTRIBUTES, - 'DEVICE_ATTRIBUTES', - (_t, _u, out) => { - const D = L.da; - const dv = this.ctx.dv(); - const primary = out + D.primary; - const secondary = out + D.secondary; - dv.setUint16(primary + D.conformance, 62, true); - dv.setUint16(primary + D.features, 22, true); - dv.setUint32(primary + D.numFeatures, 1, true); - dv.setUint16(secondary + D.deviceType, 1, true); - dv.setUint16(secondary + D.firmware, 10, true); - dv.setUint16(secondary + D.rom, 0, true); - dv.setUint32(out + D.tertiary + D.unitId, 0, true); - return 1; - }, - 3, - true - ); - // CLIPBOARD_WRITE(terminal, userdata, write): left NULL with clipboardWrite off, so - // Ghostty ignores OSC 52 and OSC 1337 Copy and refuses OSC 5522 writes (ADR 052). - if (clipboardWrite) { - install( - L.opt.CLIPBOARD_WRITE, - 'CLIPBOARD_WRITE', - (_t, _u, write) => { - this.clipboardWrite(write); - return undefined; - }, - 3, - false - ); - } - // DESKTOP_NOTIFICATION(terminal, userdata, notification): OSC 9 (but not ConEmu's - // OSC 9;<n> forms, which Ghostty's parser takes first) and OSC 777;notify. The - // strings are borrowed, so they are copied out here. Left NULL with - // desktopNotifications off, so Ghostty ignores them (webtty, ADR 056). - if (desktopNotifications) { - install( - L.opt.DESKTOP_NOTIFICATION, - 'DESKTOP_NOTIFICATION', - (_t, _u, notification) => { - const N = this.L.notification; - // A sized struct: read only the fields its size covers. - if (this.ctx.dv().getUint32(notification + N.size, true) < N.bodyEnd) return undefined; - const text = (ptr: number) => new TextDecoder().decode(this.stringAt(ptr)); - this.pendingNotifications.push({ - title: text(notification + N.title), - body: text(notification + N.body), - }); - return undefined; - }, - 3, - false - ); - } - } - - /** - * Takes one clipboard write (`GhosttyClipboardWrite`, already base64-decoded by - * Ghostty) and answers it before returning, as the API requires. Only the standard - * clipboard is written: the browser has no other, and Ghostty on macOS keeps `s` in - * a private pasteboard and drops `p`. The text is the first plain-text - * representation, decoded as UTF-8 with invalid bytes replaced (U+FFFD); no - * representations is a clear, kept as `''`. The browser write happens later, after - * `readEffects`, so success here means the write was accepted. (webtty, ADR 052) - */ - private clipboardWrite(write: number): void { - const C = this.L.clipboardWrite; - const dv = this.ctx.dv(); - let result = C.UNSUPPORTED; - if (dv.getInt32(write + C.location, true) === C.STANDARD) { - const contents = dv.getUint32(write + C.contents, true); - const count = dv.getUint32(write + C.contentsLen, true); - if (count === 0) { - this.pendingClipboard = ''; - result = C.SUCCESS; - } - for (let i = 0; i < count; i++) { - const content = contents + i * C.contentSize; - const mime = this.stringAt(content + C.mime); - if (!isTextMime(new TextDecoder().decode(mime))) continue; - this.pendingClipboard = new TextDecoder().decode(this.stringAt(content + C.data)); - result = C.SUCCESS; - break; - } - } - const reply = this.at.clipReply; - const out = this.ctx.dv(); - new Uint8Array(this.ex.memory.buffer, reply, C.replySize).fill(0); - out.setUint32(reply + C.replySizeField, C.replySize, true); - out.setInt32(reply + C.replyResult, result, true); - const fn = this.ex.__indirect_function_table.get(out.getUint32(write + C.reply, true)) as ( - write: number, - reply: number - ) => void; - fn(write, reply); - } - - /** A copy of the bytes a `GhosttyString` at `ptr` points to. */ - private stringAt(ptr: number): Uint8Array { - const S = this.L.string; - const dv = this.ctx.dv(); - const data = dv.getUint32(ptr + S.ptr, true); - const len = dv.getUint32(ptr + S.len, true); - return len === 0 ? new Uint8Array(0) : this.ctx.u8().slice(data, data + len); - } - - private applyConfig(config: GhosttyTerminalConfig | undefined): void { - const L = this.L; - const at = this.at; - - // Scrollback in lines under a 32 MiB ceiling (ADR 046). Whichever limit is reached - // first applies. 0 bytes would disable scrollback and a NULL byte limit would - // remove the ceiling, so neither is ever passed; NULL lines removes the line limit. - const lines = config?.scrollbackLines ?? DEFAULT_SCROLLBACK_LINES; - this.ctx.dv().setUint32(at.u32, SCROLLBACK_CEILING_BYTES, true); - this.ctx.setOption(L.opt.SCROLLBACK_MAX_BYTES, at.u32, 'SCROLLBACK_MAX_BYTES'); - if (lines > 0) this.ctx.dv().setUint32(at.u32, lines, true); - this.ctx.setOption(L.opt.SCROLLBACK_MAX_LINES, lines > 0 ? at.u32 : 0, 'SCROLLBACK_MAX_LINES'); - - // Grapheme clustering (mode 2027) on by default, so RIS keeps it. - const dv = this.ctx.dv(); - dv.setUint16(at.mode + L.mode.mode, 2027, true); - dv.setUint8(at.mode + L.mode.value, 1); - this.ctx.setOption(L.opt.MODE_DEFAULT, at.mode, 'MODE_DEFAULT 2027'); - - // The configured cursor, which DECSCUSR Ps 0 and RIS restore (ADR 049). - this.setDefaultCursor(config?.cursorStyle ?? 'block', config?.cursorBlink ?? false); - - const setColor = (option: number, value: number, what: string) => { - this.writeRgb(at.u32, value); - this.ctx.setOption(option, at.u32, what); - }; - // Foreground and background are always both set: Ghostty's render state copies - // the default colours only when both are set (render.zig `bg_fg`), otherwise it - // keeps white on black and ignores the given colour, OSC 10/11 and DECSCNM. - // An unset one falls back to webtty's default. - setColor( - L.opt.COLOR_FOREGROUND, - config?.fgColor ?? FALLBACK_FOREGROUND, - 'COLOR_FOREGROUND' - ); - setColor( - L.opt.COLOR_BACKGROUND, - config?.bgColor ?? FALLBACK_BACKGROUND, - 'COLOR_BACKGROUND' - ); - if (!config) return; - if (config.cursorColor !== undefined) { - setColor(L.opt.COLOR_CURSOR, config.cursorColor, 'COLOR_CURSOR'); - } - const palette = config.palette ?? []; - const entries = palette - .map((value, i) => ({ value, i })) - .filter((e): e is { value: number; i: number } => e.i < 16 && e.value !== undefined); - if (entries.length > 0) { - // Unset entries and 16-255 keep Ghostty's default palette. - const rc = this.ex.ghostty_terminal_get( - this.ctx.term, - L.tdata.COLOR_PALETTE_DEFAULT, - at.palette - ); - if (rc !== 0) throw new Error(`libghostty-vt COLOR_PALETTE_DEFAULT failed: ${rc}`); - for (const { value, i } of entries) this.writeRgb(at.palette + i * L.colors.rgbSize, value); - this.ctx.setOption(L.opt.COLOR_PALETTE, at.palette, 'COLOR_PALETTE'); - } - } - - // ========================================================================== - // Private: single-cell lookups (grid_ref) - // ========================================================================== - - /** Resolves a point to a grid reference at `out` (by default `at.gridRef`). */ - private gridRef(tag: number, x: number, y: number, out: number = this.at.gridRef): boolean { - const L = this.L; - const at = this.at; - const P = L.point; - this.ctx.u8().fill(0, at.point, at.point + P.size); - const dv = this.ctx.dv(); - dv.setInt32(at.point + P.tag, tag, true); - dv.setUint16(at.point + P.value + P.x, x, true); - dv.setUint32(at.point + P.value + P.y, y, true); - this.ctx.u8().fill(0, out, out + L.gridRef.size); - dv.setUint32(out + L.gridRef.sizeField, L.gridRef.size, true); - return this.ex.ghostty_terminal_grid_ref(this.ctx.term, at.point, out) === 0; - } - - // ========================================================================== - // Private: selection (ADR 050) - // ========================================================================== - - private screenRef(cell: ScreenCell, out: number): boolean { - if (cell.col < 0 || cell.col >= this.ctx.cols || cell.row < 0) return false; - return this.gridRef(this.L.point.SCREEN, cell.col, cell.row, out); - } - - /** The screen cell of the grid reference at `ref`, or null if it has none. */ - private screenCellOf(ref: number): ScreenCell | null { - const at = this.at; - const P = this.L.point; - const rc = this.ex.ghostty_terminal_point_from_grid_ref(this.ctx.term, ref, P.SCREEN, at.coord); - if (rc !== 0) return null; - const dv = this.ctx.dv(); - return { col: dv.getUint16(at.coord + P.x, true), row: dv.getUint32(at.coord + P.y, true) }; - } - - /** Zeroes the GhosttySelection at `at.sel` and sets its size field. */ - private clearSelectionStruct(): void { - const S = this.L.selection; - this.ctx.u8().fill(0, this.at.sel, this.at.sel + S.size); - this.ctx.dv().setUint32(this.at.sel + S.sizeField, S.size, true); - } - - /** - * Runs a `select_*` call whose options are a sized struct with one grid reference, - * set to `cell`, and returns the selection it derives in screen cells. - */ - private selectAt( - options: { size: number; sizeField: number; ref: number }, - cell: ScreenCell, - call: (options: number, outSelection: number) => number - ): ScreenRange | null { - const at = this.at; - const S = this.L.selection; - this.ctx.info(); - this.ctx.u8().fill(0, at.selOpts, at.selOpts + options.size); - this.ctx.dv().setUint32(at.selOpts + options.sizeField, options.size, true); - if (!this.screenRef(cell, at.selOpts + options.ref)) return null; - this.clearSelectionStruct(); - if (call(at.selOpts, at.sel) !== 0) return null; - const start = this.screenCellOf(at.sel + S.start); - const end = this.screenCellOf(at.sel + S.end); - return start && end ? { start, end } : null; - } - - private hyperlinkAt(tag: number, x: number, y: number): string | null { - if (x < 0 || x >= this.ctx.cols || y < 0) return null; - const ex = this.ex; - const at = this.at; - if (!this.gridRef(tag, x, y)) return null; - // Most URIs fit; a longer one (Ghostty keeps OSC 8 URIs up to about 2 KiB) is - // read again with the size Ghostty reports. - let cap = 256; - for (let attempt = 0; attempt < 2; attempt++) { - const size = cap; - const buf = ex.ghostty_wasm_alloc(size); - try { - const rc = ex.ghostty_grid_ref_hyperlink_uri(at.gridRef, buf, size, at.u32); - const n = this.ctx.dv().getUint32(at.u32, true); - if (rc === this.L.result.OUT_OF_SPACE) { - cap = n; - continue; - } - if (rc !== 0 || n === 0) return null; - return new TextDecoder().decode(this.ctx.u8().slice(buf, buf + n)); - } finally { - ex.ghostty_wasm_free(buf, size); - } - } - return null; - } - - private readTitle(): void { - this.titleChanged = false; - const at = this.at; - const S = this.L.string; - if (this.ex.ghostty_terminal_get(this.ctx.term, this.L.tdata.TITLE, at.str) !== 0) return; - const dv = this.ctx.dv(); - const ptr = dv.getUint32(at.str + S.ptr, true); - const len = dv.getUint32(at.str + S.len, true); - this.pendingTitle = len ? new TextDecoder().decode(this.ctx.u8().slice(ptr, ptr + len)) : ''; + return this.effects.readEffects(); } } diff --git a/src/client/vendor/ghostty-web/engine/paste-focus-binding.ts b/src/client/vendor/ghostty-web/engine/paste-focus-binding.ts new file mode 100644 index 0000000..c764028 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/paste-focus-binding.ts @@ -0,0 +1,122 @@ +// webtty (ADR 049, ADR 053, ADR 061), not from upstream. +// The paste and focus binding (object): pastes text through `ghostty_terminal_paste`, +// which brackets and sanitises it for the app's modes, with a mime reader callback +// installed on the first paste, and encodes focus reports. + +import { type InstalledFn, WasmValType } from '../fn-table'; +import type { EngineContext } from './engine-context'; + +/** Room for one focus report (`CSI I` / `CSI O`, 3 bytes). */ +export const FOCUS_REPORT_MAX = 8; + +/** The scratch slots the paste and focus binding declares on the shared context. */ +export function pasteFocusSlots() { + return { + focusOut: [FOCUS_REPORT_MAX, 8], + } as const; +} + +/** The names of the paste and focus binding's scratch slots. */ +export type PasteFocusSlot = keyof ReturnType<typeof pasteFocusSlots>; + +const I32 = WasmValType.I32; + +/** Paste (ADR 049) and focus reports (ADR 053) on one terminal. */ +export class PasteFocusBinding { + private readonly ctx: EngineContext<PasteFocusSlot>; + // The mime reader callback, installed on the first paste, and the text it writes + // during a `paste` call. + private reader: InstalledFn | null = null; + private text = { ptr: 0, len: 0 }; + + constructor(ctx: EngineContext<PasteFocusSlot>) { + this.ctx = ctx; + } + + /** Pastes `text` as from the clipboard; the reply, if any, is a WRITE_PTY effect. */ + paste(text: string): void { + const ex = this.ctx.ex; + const P = this.ctx.L.paste; + const S = this.ctx.L.string; + const bytes = new TextEncoder().encode(text); + const mime = new TextEncoder().encode('text/plain'); + // One allocation: the paste struct, the one-entry mime array, the mime and the text. + const mimesAt = Math.ceil(P.size / 8) * 8; + const mimeAt = mimesAt + S.size; + const textAt = mimeAt + mime.length; + const len = textAt + bytes.length; + const base = ex.ghostty_wasm_alloc(len); + if (!base) throw new Error('libghostty-vt: out of memory'); + try { + const u8 = this.ctx.u8(); + u8.fill(0, base, base + P.size); + u8.set(mime, base + mimeAt); + u8.set(bytes, base + textAt); + const dv = this.ctx.dv(); + dv.setUint32(base + mimesAt + S.ptr, base + mimeAt, true); + dv.setUint32(base + mimesAt + S.len, mime.length, true); + dv.setUint32(base + P.sizeField, P.size, true); + dv.setInt32(base + P.location, P.STANDARD, true); + dv.setInt32(base + P.source, P.CLIPBOARD, true); + dv.setUint32(base + P.mimes, base + mimesAt, true); + dv.setUint32(base + P.mimesLen, 1, true); + dv.setUint32(base + P.reader + P.readerRead, this.ensureReader(), true); + // Text that could run commands (a newline without 2004) is sent, never refused: + // ADR 037, and webtty has no confirmation dialog. + dv.setUint8(base + P.allowUnsafe, 1); + this.text = { ptr: base + textAt, len: bytes.length }; + const rc = ex.ghostty_terminal_paste(this.ctx.term, base, 0); + if (rc !== 0) throw new Error(`libghostty-vt terminal_paste failed: ${rc}`); + } finally { + this.text = { ptr: 0, len: 0 }; + ex.ghostty_wasm_free(base, len); + } + } + + /** The focus gained or lost report (`CSI I` / `CSI O`). */ + encodeFocus(focused: boolean): Uint8Array { + const { at, L } = this.ctx; + const F = L.focus; + const event = focused ? F.GAINED : F.LOST; + const rc = this.ctx.ex.ghostty_focus_encode(event, at.focusOut, FOCUS_REPORT_MAX, at.u32); + if (rc !== 0) return new Uint8Array(0); + const n = this.ctx.dv().getUint32(at.u32, true); + return this.ctx.u8().slice(at.focusOut, at.focusOut + n); + } + + /** + * Releases the reader's table slot; call only after the terminal is freed, when + * nothing can call it (fn-table.ts rule 3). Safe to call again. + */ + free(): void { + this.reader?.release(); + this.reader = null; + } + + /** + * The GhosttyMimeReader callback: `(userdata, mime, writer)`, the GhosttyString and + * GhosttyWriter structs passed by pointer on wasm32. It writes the text of the paste in + * progress through the writer, a function in the wasm's table, and returns its result. + */ + private ensureReader(): number { + if (this.reader) return this.reader.index; + const P = this.ctx.L.paste; + const table = this.ctx.ex.__indirect_function_table; + this.reader = this.ctx.vt.fnTable.install( + ((_userdata: number, _mime: number, writer: number) => { + const { ptr, len } = this.text; + if (len === 0) return 1; + const dv = this.ctx.dv(); + const write = table.get(dv.getUint32(writer + P.writerWrite, true)) as ( + userdata: number, + data: number, + len: number + ) => number; + return write(dv.getUint32(writer + P.writerUserdata, true), ptr, len) ? 1 : 0; + }) as (...args: number[]) => number, + [I32, I32, I32], + [I32] + ); + return this.reader.index; + } +} diff --git a/src/client/vendor/ghostty-web/engine/search-binding.ts b/src/client/vendor/ghostty-web/engine/search-binding.ts new file mode 100644 index 0000000..d72eb4b --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/search-binding.ts @@ -0,0 +1,193 @@ +// webtty (ADR 055, ADR 061), not from upstream. +// The search binding (object): wraps one libghostty-vt search over the terminal, made +// while a needle is set. It is fed on content changes, stepped in time slices, and its +// matches are read into a wasm buffer grown as needed. + +import type { EngineContext } from './engine-context'; +import type { ScreenRange, SearchState } from './engine-types'; +import type { GridRefs } from './grid-refs'; +import type { OfficialLayout } from './official-layout'; +import { clearSelectionStruct, selectionRange } from './selection-binding'; + +/** The scratch slots the search binding declares on the shared context. */ +export function searchSlots(L: OfficialLayout) { + return { + searchBuf: [L.search.bufferSize, 8], + searchSel: [L.selection.size, 8], + } as const; +} + +/** The names of the search binding's scratch slots. */ +export type SearchSlot = keyof ReturnType<typeof searchSlots>; + +/** Ghostty's search on one terminal (ADR 055); the view is webtty's, never moved. */ +export class SearchBinding { + private readonly ctx: EngineContext<SearchSlot>; + private readonly refs: GridRefs; + // The handle while a needle is set, the generation it last fed, and the wasm buffer + // matches are read into. + private search = 0; + private fedGen = 0; + private matchBuf = 0; + private matchCap = 0; + + constructor(ctx: EngineContext<SearchSlot>, refs: GridRefs) { + this.ctx = ctx; + this.refs = refs; + } + + /** Sets the needle; an empty one ends the search and frees it. */ + setSearch(needle: string): void { + const { ex, at, L } = this.ctx; + const S = L.search; + if (!needle) { + this.free(); + return; + } + if (!this.search) { + this.search = this.ctx.vt.newHandle('search_new', (slot) => + ex.ghostty_search_new(0, slot, this.ctx.term) + ); + // The view is webtty's own; Ghostty's viewport stays at the bottom + this.ctx.dv().setInt32(at.u32, S.SCROLL_NONE, true); + ex.ghostty_search_set(this.search, S.SELECT_SCROLL, at.u32); + } + const bytes = new TextEncoder().encode(needle); + const buf = ex.ghostty_wasm_alloc(bytes.length); + try { + this.ctx.u8().set(bytes, buf); + const dv = this.ctx.dv(); + dv.setUint32(at.str + L.string.ptr, buf, true); + dv.setUint32(at.str + L.string.len, bytes.length, true); + // An unchanged needle keeps its results + ex.ghostty_search_set(this.search, S.NEEDLE, at.str); + } finally { + ex.ghostty_wasm_free(buf, bytes.length); + } + this.fedGen = 0; + } + + /** Feeds new content and runs the search for up to `budgetMs`; true if it did work. */ + searchStep(budgetMs: number): boolean { + const { ex, at, L } = this.ctx; + const S = L.search; + if (!this.search) return false; + let worked = false; + // Feeding is the only way the search sees output, resizes and resets + if (this.fedGen !== this.ctx.generation) { + ex.ghostty_search_feed(this.search); + this.fedGen = this.ctx.generation; + worked = true; + } + if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) return worked; + let status = this.ctx.dv().getInt32(at.u32, true); + const end = performance.now() + budgetMs; + while (status !== S.COMPLETE && performance.now() < end) { + if (status === S.FEED_REQUIRED) ex.ghostty_search_feed(this.search); + else ex.ghostty_search_tick(this.search, 0); + if (ex.ghostty_search_get(this.search, S.STATUS, at.u32) !== 0) break; + status = this.ctx.dv().getInt32(at.u32, true); + worked = true; + } + return worked; + } + + /** Selects the next (older) or previous match; false without a search. */ + searchSelect(direction: 'next' | 'previous'): boolean { + const S = this.ctx.L.search; + if (!this.search) return false; + const option = direction === 'next' ? S.SELECT_NEXT : S.SELECT_PREV; + return this.ctx.ex.ghostty_search_set(this.search, option, 0) === 0; + } + + /** The match count and the selected match's index. */ + searchState(): SearchState { + const { ex, at, L } = this.ctx; + const S = L.search; + if (!this.search) return { total: 0, selected: null }; + const read = (data: number): number | null => + ex.ghostty_search_get(this.search, data, at.u32) === 0 + ? this.ctx.dv().getUint32(at.u32, true) + : null; + return { total: read(S.TOTAL_MATCHES) ?? 0, selected: read(S.SELECTED_INDEX) }; + } + + /** The selected match in screen cells, or null. */ + searchSelectedMatch(): ScreenRange | null { + const { at, L } = this.ctx; + if (!this.search) return null; + clearSelectionStruct(this.ctx, at.searchSel); + if (this.ctx.ex.ghostty_search_get(this.search, L.search.SELECTED_MATCH, at.searchSel) !== 0) { + return null; + } + return selectionRange(this.refs, L, at.searchSel); + } + + /** Every match as (startCol, startRow, endCol, endRow) screen cells, four per match. */ + searchMatches(): Int32Array { + const { ex, at, L } = this.ctx; + const S = L.search; + const Sel = L.selection; + if (!this.search) return new Int32Array(0); + // Matches are read where they are, before anything changes the terminal; the + // buffer is grown to the count Ghostty reports, then read again. + for (let attempt = 0; attempt < 2; attempt++) { + const dv = this.ctx.dv(); + dv.setUint32(at.searchBuf + S.ptr, this.matchBuf, true); + dv.setUint32(at.searchBuf + S.cap, this.matchCap, true); + dv.setUint32(at.searchBuf + S.len, 0, true); + for (let i = 0; i < this.matchCap; i++) { + dv.setUint32(this.matchBuf + i * Sel.size + Sel.sizeField, Sel.size, true); + } + const rc = ex.ghostty_search_get(this.search, S.MATCHES, at.searchBuf); + const len = this.ctx.dv().getUint32(at.searchBuf + S.len, true); + if (rc === L.result.OUT_OF_SPACE && attempt === 0) { + if (!this.growMatches(len)) return new Int32Array(0); + continue; + } + if (rc !== 0) return new Int32Array(0); + return this.readMatches(len); + } + return new Int32Array(0); + } + + /** Ends the search: frees the handle and the match buffer. Safe to call again. */ + free(): void { + const ex = this.ctx.ex; + if (this.search) ex.ghostty_search_free(this.search); + this.search = 0; + if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * this.ctx.L.selection.size); + this.matchBuf = 0; + this.matchCap = 0; + } + + /** Replaces the match buffer with room for `len` matches and more; false if out of memory. */ + private growMatches(len: number): boolean { + const ex = this.ctx.ex; + const size = this.ctx.L.selection.size; + if (this.matchBuf) ex.ghostty_wasm_free(this.matchBuf, this.matchCap * size); + // Room for the matches output adds before the next read + this.matchCap = len + (len >> 2) + 16; + this.matchBuf = ex.ghostty_wasm_alloc(this.matchCap * size); + if (this.matchBuf) return true; + this.matchCap = 0; + return false; + } + + /** The first `len` matches of the buffer in screen cells. */ + private readMatches(len: number): Int32Array { + const Sel = this.ctx.L.selection; + const out = new Int32Array(len * 4); + for (let i = 0; i < len; i++) { + const sel = this.matchBuf + i * Sel.size; + const start = this.refs.screenCellOf(sel + Sel.start); + const end = this.refs.screenCellOf(sel + Sel.end); + // A match that has no screen point (none expected) is left at row -1 + out[i * 4] = start?.col ?? 0; + out[i * 4 + 1] = start?.row ?? -1; + out[i * 4 + 2] = end?.col ?? 0; + out[i * 4 + 3] = end?.row ?? -1; + } + return out; + } +} diff --git a/src/client/vendor/ghostty-web/engine/selection-binding.ts b/src/client/vendor/ghostty-web/engine/selection-binding.ts new file mode 100644 index 0000000..4ac3552 --- /dev/null +++ b/src/client/vendor/ghostty-web/engine/selection-binding.ts @@ -0,0 +1,143 @@ +// webtty (ADR 050, ADR 057, ADR 061), not from upstream. +// The selection binding (object): Ghostty's word, line and command-output selection +// picked from a screen cell, and the selected text formatted as Ghostty copies it. +// Also the two helpers on a GhosttySelection struct the search binding shares. + +import type { EngineContext } from './engine-context'; +import type { ScreenCell, ScreenRange } from './engine-types'; +import type { GridRefSlot, GridRefs } from './grid-refs'; +import type { OfficialLayout } from './official-layout'; + +/** The scratch slots the selection binding declares on the shared context. */ +export function selectionSlots(L: OfficialLayout) { + return { + sel: [L.selection.size, 8], + selOpts: [ + Math.max(L.selection.word.size, L.selection.line.size, L.selection.format.size), + 8, + ], + outPtr: [4, 4], + outLen: [4, 4], + } as const; +} + +/** The names of the selection binding's scratch slots. */ +export type SelectionSlot = keyof ReturnType<typeof selectionSlots>; + +/** Zeroes the GhosttySelection at `sel` and sets its size field. */ +export function clearSelectionStruct(ctx: EngineContext, sel: number): void { + const S = ctx.L.selection; + ctx.u8().fill(0, sel, sel + S.size); + ctx.dv().setUint32(sel + S.sizeField, S.size, true); +} + +/** The screen range of the GhosttySelection at `sel`, or null if an end has no cell. */ +export function selectionRange(refs: GridRefs, L: OfficialLayout, sel: number): ScreenRange | null { + const start = refs.screenCellOf(sel + L.selection.start); + const end = refs.screenCellOf(sel + L.selection.end); + return start && end ? { start, end } : null; +} + +/** The options struct of a `select_*` call: sized, with one grid reference. */ +interface SelectOptions { + size: number; + sizeField: number; + ref: number; +} + +/** Ghostty's selection rules on one terminal (ADR 050, ADR 057). */ +export class SelectionBinding { + // The grid refs' `gridRef` slot holds the cell `selectOutput` starts from. + private readonly ctx: EngineContext<SelectionSlot | GridRefSlot>; + private readonly refs: GridRefs; + + constructor(ctx: EngineContext<SelectionSlot | GridRefSlot>, refs: GridRefs) { + this.ctx = ctx; + this.refs = refs; + } + + /** The word at `cell`, by Ghostty's word boundaries, soft wraps followed. */ + selectWord(cell: ScreenCell): ScreenRange | null { + return this.selectAt(this.ctx.L.selection.word, cell, (opts, out) => + this.ctx.ex.ghostty_terminal_select_word(this.ctx.term, opts, out) + ); + } + + /** The line at `cell`, soft wraps followed, outer whitespace left out. */ + selectLine(cell: ScreenCell): ScreenRange | null { + return this.selectAt(this.ctx.L.selection.line, cell, (opts, out) => + this.ctx.ex.ghostty_terminal_select_line(this.ctx.term, opts, out) + ); + } + + /** The output of the command at `cell` (OSC 133 marks), or null elsewhere. */ + selectOutput(cell: ScreenCell): ScreenRange | null { + const { at, L } = this.ctx; + this.ctx.info(); + if (!this.refs.screenRef(cell, at.gridRef)) return null; + clearSelectionStruct(this.ctx, at.sel); + if (this.ctx.ex.ghostty_terminal_select_output(this.ctx.term, at.gridRef, at.sel) !== 0) { + return null; + } + return selectionRange(this.refs, L, at.sel); + } + + /** The text between two screen cells, as Ghostty copies it. */ + selectionText(start: ScreenCell, end: ScreenCell): string { + const { ex, at, L } = this.ctx; + const S = L.selection; + const F = S.format; + this.ctx.info(); + // Rows that left history, or a selection made before a resize, are clamped. + const last = this.ctx.sbRows + this.ctx.rows - 1; + const clamp = (c: ScreenCell): ScreenCell => ({ + col: Math.max(0, Math.min(c.col, this.ctx.cols - 1)), + row: Math.max(0, Math.min(c.row, last)), + }); + clearSelectionStruct(this.ctx, at.sel); + if (!this.refs.screenRef(clamp(start), at.sel + S.start)) return ''; + if (!this.refs.screenRef(clamp(end), at.sel + S.end)) return ''; + this.ctx.u8().fill(0, at.selOpts, at.selOpts + F.size); + const dv = this.ctx.dv(); + dv.setUint32(at.selOpts + F.sizeField, F.size, true); + dv.setInt32(at.selOpts + F.emit, S.PLAIN, true); + // Ghostty's copy: soft-wrapped rows joined, trailing whitespace trimmed. + dv.setUint8(at.selOpts + F.unwrap, 1); + dv.setUint8(at.selOpts + F.trim, 1); + dv.setUint32(at.selOpts + F.selection, at.sel, true); + dv.setUint32(at.outPtr, 0, true); + dv.setUint32(at.outLen, 0, true); + const rc = ex.ghostty_terminal_selection_format_alloc( + this.ctx.term, + 0, + at.selOpts, + at.outPtr, + at.outLen + ); + const ptr = this.ctx.dv().getUint32(at.outPtr, true); + const len = this.ctx.dv().getUint32(at.outLen, true); + if (rc !== 0 || !ptr) return ''; + const text = new TextDecoder().decode(this.ctx.u8().slice(ptr, ptr + len)); + ex.ghostty_free(0, ptr, len); + return text; + } + + /** + * Runs a `select_*` call whose options are a sized struct with one grid reference, + * set to `cell`, and returns the selection it derives in screen cells. + */ + private selectAt( + options: SelectOptions, + cell: ScreenCell, + call: (options: number, outSelection: number) => number + ): ScreenRange | null { + const { at, L } = this.ctx; + this.ctx.info(); + this.ctx.u8().fill(0, at.selOpts, at.selOpts + options.size); + this.ctx.dv().setUint32(at.selOpts + options.sizeField, options.size, true); + if (!this.refs.screenRef(cell, at.selOpts + options.ref)) return null; + clearSelectionStruct(this.ctx, at.sel); + if (call(at.selOpts, at.sel) !== 0) return null; + return selectionRange(this.refs, L, at.sel); + } +} From cf07f9652ca0421a5e91c30cec40b6cfe643b08c Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:17:06 -0400 Subject: [PATCH 237/295] test(client): engine bindings tested directly beside the facade tests Each new engine binding gets direct tests on a context of its own (engine-harness bindingContext), and every free() is tested to release what it made once, with a second call a no-op (trackedHost counts the FnTable installs and releases). EngineEffects is checked not to call into the terminal from inside vt_write, and the facade test checks that no two bindings declare the same scratch slot. support/wasm.ts gains wrappedGhostty, which countedGhostty now builds on. --- .../vendor/tests/engine/engine-config.test.ts | 67 ++++++++++++++++++- .../tests/engine/engine-effects.test.ts | 59 +++++++++++++++- .../vendor/tests/engine/grid-refs.test.ts | 46 ++++++++++++- .../engine/mouse-encoder-binding.test.ts | 59 +++++++++++++++- .../tests/engine/official-terminal.test.ts | 45 ++++++++++++- .../tests/engine/paste-focus-binding.test.ts | 41 +++++++++++- .../tests/engine/search-binding.test.ts | 48 ++++++++++++- .../tests/engine/selection-binding.test.ts | 56 +++++++++++++++- .../vendor/tests/support/engine-harness.ts | 48 ++++++++++++- src/client/vendor/tests/support/wasm.ts | 37 ++++++---- 10 files changed, 477 insertions(+), 29 deletions(-) diff --git a/src/client/vendor/tests/engine/engine-config.test.ts b/src/client/vendor/tests/engine/engine-config.test.ts index 9b8eb6a..9392347 100644 --- a/src/client/vendor/tests/engine/engine-config.test.ts +++ b/src/client/vendor/tests/engine/engine-config.test.ts @@ -1,10 +1,21 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { + configSlots, + DEFAULT_SCROLLBACK_LINES, + EngineConfig, + FALLBACK_BACKGROUND, + FALLBACK_FOREGROUND, + SCROLLBACK_CEILING_BYTES, + writeRgb, +} from '../../ghostty-web/engine/engine-config'; import type { Ghostty, GhosttyTerminal, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bindingContext, freeContext } from '../support/engine-harness'; import { bgRgb, FG, RED, rgb, THEME } from '../support/terminal-text'; import { loadGhostty } from '../support/wasm'; // The terminal config the engine applies (ADR 042, ADR 046, ADR 061): theme colours and -// their fallbacks, and the scrollback limit in lines, tested through OfficialTerminal. +// their fallbacks, the scrollback limit in lines, and the cursor blink kept in mode 12, +// through OfficialTerminal and directly. let vt: Ghostty; @@ -140,3 +151,57 @@ describe('official engine: scrollback in lines (ADR 046)', () => { } }); }); + +describe('EngineConfig', () => { + test('the limits and fallbacks are the documented values', () => { + expect(SCROLLBACK_CEILING_BYTES).toBe(32 * 1024 * 1024); + expect(DEFAULT_SCROLLBACK_LINES).toBe(10000); + expect([FALLBACK_FOREGROUND, FALLBACK_BACKGROUND]).toEqual([0xcccccc, 0]); + }); + + test.each([ + [0x000000, [0, 0, 0]], + [0xffffff, [255, 255, 255]], + [0x123456, [0x12, 0x34, 0x56]], + ])('writeRgb(%i) writes r, g and b at their offsets', (value, bytes) => { + const ctx = bindingContext(vt, 4, 2, configSlots(vt.L)); + const C = ctx.L.colors; + writeRgb(ctx, ctx.at.palette, value); + const u8 = ctx.u8(); + const at = ctx.at.palette; + expect([u8[at + C.r], u8[at + C.g], u8[at + C.b]]).toEqual(bytes); + freeContext(ctx); + }); + + test('mode 12 follows the default cursor and DECSCUSR, never mode 12 itself', () => { + const ctx = bindingContext(vt, 10, 3, configSlots(vt.L)); + const config = new EngineConfig(ctx); + config.apply({ cursorBlink: true }); + expect(ctx.getMode(12)).toBe(true); + // Grapheme clustering is on by default + expect(ctx.getMode(2027)).toBe(true); + const write = (text: string) => { + const bytes = new TextEncoder().encode(text); + const ptr = ctx.ex.ghostty_wasm_alloc(bytes.length); + ctx.u8().set(bytes, ptr); + ctx.ex.ghostty_terminal_vt_write(ctx.term, ptr, bytes.length); + ctx.ex.ghostty_wasm_free(ptr, bytes.length); + config.afterWrite(text); + }; + write('\x1b[?12l'); + expect(ctx.getMode(12)).toBe(true); + write('\x1b[2 q'); + expect(ctx.getMode(12)).toBe(false); + // A sequence split across writes is still seen + write('\x1b[1'); + write(' q'); + expect(ctx.getMode(12)).toBe(true); + write('\x1b[2 q\x1b[0 q'); + expect(ctx.getMode(12)).toBe(true); + const gen = ctx.generation; + config.setDefaultCursor('bar', false); + expect(ctx.getMode(12)).toBe(false); + expect(ctx.generation).toBe(gen + 1); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/engine-effects.test.ts b/src/client/vendor/tests/engine/engine-effects.test.ts index bfe68ee..248df4e 100644 --- a/src/client/vendor/tests/engine/engine-effects.test.ts +++ b/src/client/vendor/tests/engine/engine-effects.test.ts @@ -1,11 +1,13 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { EngineEffects, effectsSlots } from '../../ghostty-web/engine/engine-effects'; import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bindingContext, trackedHost, writeVt } from '../support/engine-harness'; import { rowText, THEME } from '../support/terminal-text'; -import { loadGhostty } from '../support/wasm'; +import { loadGhostty, wrappedGhostty } from '../support/wasm'; // What the engine reports back from the wasm callbacks (ADR 042, ADR 052, ADR 056, ADR 061): // the title, the bell, replies through WRITE_PTY, clipboard writes and desktop -// notifications, tested through OfficialTerminal. +// notifications, through OfficialTerminal and directly. let vt: Ghostty; @@ -237,3 +239,56 @@ describe('official engine: desktop notifications (ADR 056)', () => { expect(notified('\x1b]777;notify;t;b\x07', off)).toEqual([]); }); }); + +describe('EngineEffects', () => { + test('installs the five callbacks plus the two the config can turn off', () => { + for (const [on, count] of [ + [false, 5], + [true, 7], + ] as const) { + const { host, installs } = trackedHost(vt); + const ctx = bindingContext(host, 10, 3, effectsSlots(host.L)); + const effects = new EngineEffects(ctx, { clipboardWrite: on, desktopNotifications: on }); + expect(installs.length).toBe(count); + ctx.ex.ghostty_terminal_free(ctx.term); + ctx.term = 0; + effects.free(); + effects.free(); + expect(installs.map((i) => i.releases)).toEqual(new Array(count).fill(1)); + ctx.scratch.free(); + } + }); + + test('the title is read after the write returns, and no callback re-enters the terminal', async () => { + let depth = 0; + const inside: string[] = []; + const names = ['ghostty_terminal_vt_write', 'ghostty_terminal_get']; + const ghostty = await wrappedGhostty(names, (name, fn) => (...args) => { + if (depth > 0) inside.push(name); + if (name !== 'ghostty_terminal_vt_write') return fn(...args); + depth++; + try { + return fn(...args); + } finally { + depth--; + } + }); + const ctx = bindingContext(ghostty, 10, 3, effectsSlots(ghostty.L)); + const effects = new EngineEffects(ctx, { clipboardWrite: true, desktopNotifications: true }); + writeVt(ctx, '\x1b]2;hi\x07\x07\x1b[c\x1b[14t\x1b]52;c;YQ==\x07\x1b]9;n\x07'); + expect(effects.readEffects()).toEqual({ + bell: true, + title: null, + clipboard: 'a', + notifications: [{ title: '', body: 'n' }], + }); + effects.afterWrite(); + expect(effects.readEffects().title).toBe('hi'); + expect(effects.readResponse()).toBe('\x1b[?62;22c\x1b[4;0;0t'); + expect(inside).toEqual([]); + ctx.ex.ghostty_terminal_free(ctx.term); + ctx.term = 0; + effects.free(); + ctx.scratch.free(); + }); +}); diff --git a/src/client/vendor/tests/engine/grid-refs.test.ts b/src/client/vendor/tests/engine/grid-refs.test.ts index fafbe75..a2ae74c 100644 --- a/src/client/vendor/tests/engine/grid-refs.test.ts +++ b/src/client/vendor/tests/engine/grid-refs.test.ts @@ -1,10 +1,12 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { GridRefs, gridRefSlots } from '../../ghostty-web/engine/grid-refs'; import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bindingContext, freeContext, writeVt } from '../support/engine-harness'; import { THEME } from '../support/terminal-text'; import { loadGhostty } from '../support/wasm'; // Cells read through Ghostty grid refs (ADR 042, ADR 061): OSC 8 hyperlink URIs on screen -// and in scrollback, tested through OfficialTerminal. +// and in scrollback, wrap flags, and the cell lookups the selection and search use. let vt: Ghostty; @@ -34,3 +36,45 @@ describe('official engine: hyperlinks (ADR 042)', () => { t.free(); }); }); + +describe('GridRefs', () => { + function refs(cols = 10, rows = 3) { + const ctx = bindingContext(vt, cols, rows, gridRefSlots(vt.L)); + return { ctx, refs: new GridRefs(ctx) }; + } + + test('a screen cell goes to a grid ref and back; cells off the grid have none', () => { + const { ctx, refs: r } = refs(); + writeVt(ctx, 'abc\r\ndef'); + expect(r.screenRef({ col: 2, row: 1 }, ctx.at.gridRef)).toBe(true); + expect(r.screenCellOf(ctx.at.gridRef)).toEqual({ col: 2, row: 1 }); + expect(r.screenRef({ col: -1, row: 0 }, ctx.at.gridRef)).toBe(false); + expect(r.screenRef({ col: 10, row: 0 }, ctx.at.gridRef)).toBe(false); + expect(r.screenRef({ col: 0, row: -1 }, ctx.at.gridRef)).toBe(false); + // A row past the screen is a point Ghostty refuses + expect(r.screenRef({ col: 0, row: 99 }, ctx.at.gridRef)).toBe(false); + freeContext(ctx); + }); + + test('isRowWrapped is true only for a row that continues the one above', () => { + const { ctx, refs: r } = refs(5, 3); + writeVt(ctx, 'abcdefg\r\nxy'); + expect([0, 1, 2].map((row) => r.isRowWrapped(row))).toEqual([false, true, false]); + expect(r.isRowWrapped(-1)).toBe(false); + expect(r.isRowWrapped(3)).toBe(false); + freeContext(ctx); + }); + + test('hyperlinkAt is null off the grid and on a plain cell', () => { + const { ctx, refs: r } = refs(); + writeVt(ctx, '\x1b]8;;https://a.b\x1b\\ln\x1b]8;;\x1b\\ x'); + const P = ctx.L.point; + expect(r.hyperlinkAt(P.ACTIVE, 0, 0)).toBe('https://a.b'); + expect(r.hyperlinkAt(P.ACTIVE, 3, 0)).toBeNull(); + expect(r.hyperlinkAt(P.ACTIVE, -1, 0)).toBeNull(); + expect(r.hyperlinkAt(P.ACTIVE, 10, 0)).toBeNull(); + expect(r.hyperlinkAt(P.ACTIVE, 0, -1)).toBeNull(); + expect(r.hyperlinkAt(P.ACTIVE, 0, 99)).toBeNull(); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts index 8f1cf98..5b678a6 100644 --- a/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts +++ b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts @@ -1,4 +1,9 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { + MOUSE_REPORT_MAX, + MouseEncoderBinding, + mouseEncoderSlots, +} from '../../ghostty-web/engine/mouse-encoder-binding'; import type { Ghostty, GhosttyTerminal, @@ -6,11 +11,12 @@ import type { OfficialTerminal, } from '../../ghostty-web/ghostty'; import { Mods } from '../../ghostty-web/types'; +import { bindingContext, freeContext, writeVt } from '../support/engine-harness'; import { THEME } from '../support/terminal-text'; -import { loadGhostty } from '../support/wasm'; +import { countedGhostty, loadGhostty } from '../support/wasm'; // The wasm mouse encoder (ADR 049, ADR 061): every tracking mode and report format, the -// modifiers, the padding and the last-cell de-dup, tested through OfficialTerminal. +// modifiers, the padding and the last-cell de-dup, through OfficialTerminal and directly. let vt: Ghostty; @@ -184,3 +190,52 @@ describe('official engine: mouse encoder (ADR 049)', () => { t.free(); }); }); + +describe('MouseEncoderBinding', () => { + const press: MouseInput = { + action: 'press', + button: 'left', + mods: 0, + x: 15, + y: 5, + anyButtonPressed: false, + }; + + test('reports on a context of its own, within its report buffer', () => { + const ctx = bindingContext(vt, 10, 3, mouseEncoderSlots(vt.L)); + const mouse = new MouseEncoderBinding(ctx); + expect(ctx.at.mouseOut % 8).toBe(0); + expect(MOUSE_REPORT_MAX).toBeGreaterThanOrEqual(32); + mouse.setMouseGeometry({ + screenWidth: 100, + screenHeight: 30, + cellWidth: 10, + cellHeight: 10, + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, + }); + writeVt(ctx, '\x1b[?1000h\x1b[?1006h'); + expect(new TextDecoder().decode(mouse.encodeMouse(press))).toBe('\x1b[<0;2;1M'); + mouse.free(); + freeContext(ctx); + }); + + test('free() frees the encoder and event once, only if made; a second call is a no-op', async () => { + const { ghostty, calls } = await countedGhostty([ + 'ghostty_mouse_encoder_free', + 'ghostty_mouse_event_free', + ]); + const ctx = bindingContext(ghostty, 10, 3, mouseEncoderSlots(ghostty.L)); + const unused = new MouseEncoderBinding(ctx); + unused.free(); + expect([calls.ghostty_mouse_encoder_free, calls.ghostty_mouse_event_free]).toEqual([0, 0]); + const mouse = new MouseEncoderBinding(ctx); + mouse.encodeMouse(press); + mouse.free(); + mouse.free(); + expect([calls.ghostty_mouse_encoder_free, calls.ghostty_mouse_event_free]).toEqual([1, 1]); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/official-terminal.test.ts b/src/client/vendor/tests/engine/official-terminal.test.ts index 41e1fba..6a5b16e 100644 --- a/src/client/vendor/tests/engine/official-terminal.test.ts +++ b/src/client/vendor/tests/engine/official-terminal.test.ts @@ -1,11 +1,14 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { EngineContext } from '../../ghostty-web/engine/engine-context'; +import { terminalSlotSpecs } from '../../ghostty-web/engine/official-terminal'; import { Ghostty, type OfficialTerminal } from '../../ghostty-web/ghostty'; +import { freeContext } from '../support/engine-harness'; import { markedSession, rgb, rowText, scrollbackText, THEME } from '../support/terminal-text'; -import { instantiateWasm, loadGhostty } from '../support/wasm'; +import { countedGhostty, instantiateWasm, loadGhostty } from '../support/wasm'; // The official libghostty-vt terminal (ADR 042, ADR 061) on the vendored wasm: what the // facade itself does: the cursor and modes, resize, writes, prompt jumps and memory growth. -// Its bindings are tested beside it in engine/, each through this facade. +// Its bindings are tested beside it in engine/, through this facade and directly. let vt: Ghostty; @@ -331,3 +334,41 @@ describe('official engine: prompt jumps (ADR 057)', () => { alt.free(); }); }); + +describe('official engine: the facade over its bindings (ADR 061)', () => { + test('each binding declares its own scratch slots: no name twice, none of the context', () => { + const ctx = new EngineContext(vt, 1, 1, {}); + const names = [...Object.keys(ctx.at)]; + for (const spec of terminalSlotSpecs(vt.L)) names.push(...Object.keys(spec)); + expect(new Set(names).size).toBe(names.length); + freeContext(ctx); + }); + + test('free() frees the terminal and every binding once; a second call is a no-op', async () => { + const names = [ + 'ghostty_terminal_free', + 'ghostty_search_free', + 'ghostty_mouse_encoder_free', + 'ghostty_mouse_event_free', + 'ghostty_render_state_free', + ]; + const { ghostty, calls } = await countedGhostty(names); + const t = ghostty.createTerminal(10, 3, THEME) as OfficialTerminal; + t.write('a\r\n'.repeat(5)); + t.getScrollbackLine(0); + t.setSearch('a'); + t.encodeMouse({ + action: 'press', + button: 'left', + mods: 0, + x: 1, + y: 1, + anyButtonPressed: false, + }); + t.paste('x'); + t.free(); + t.free(); + // The render state and the scrollback page's render state + expect(names.map((n) => calls[n])).toEqual([1, 1, 1, 1, 2]); + }); +}); diff --git a/src/client/vendor/tests/engine/paste-focus-binding.test.ts b/src/client/vendor/tests/engine/paste-focus-binding.test.ts index eb07129..cf22f13 100644 --- a/src/client/vendor/tests/engine/paste-focus-binding.test.ts +++ b/src/client/vendor/tests/engine/paste-focus-binding.test.ts @@ -1,10 +1,17 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { EngineEffects, effectsSlots } from '../../ghostty-web/engine/engine-effects'; +import { + FOCUS_REPORT_MAX, + PasteFocusBinding, + pasteFocusSlots, +} from '../../ghostty-web/engine/paste-focus-binding'; import type { Ghostty, GhosttyTerminal, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bindingContext, freeContext, trackedHost } from '../support/engine-harness'; import { THEME } from '../support/terminal-text'; import { loadGhostty } from '../support/wasm'; // The wasm paste and focus encoders (ADR 049, ADR 053, ADR 061): bracketed and plain -// pastes made safe, and focus reports, tested through OfficialTerminal. +// pastes made safe, and focus reports, through OfficialTerminal and directly. let vt: Ghostty; @@ -77,3 +84,35 @@ describe('official engine: focus reports (ADR 053)', () => { t.free(); }); }); + +describe('PasteFocusBinding', () => { + test('installs its reader on the first paste only, and free() releases it once', () => { + const { host, installs } = trackedHost(vt); + const ctx = bindingContext(host, 10, 3, { ...pasteFocusSlots(), ...effectsSlots(host.L) }); + // The replies come back through WRITE_PTY, which the effects install + const effects = new EngineEffects(ctx, { clipboardWrite: false, desktopNotifications: false }); + const before = installs.length; + const binding = new PasteFocusBinding(ctx); + expect(binding.encodeFocus(true)).toEqual(new TextEncoder().encode('\x1b[I')); + expect(installs.length).toBe(before); + binding.paste('a'); + binding.paste('b'); + expect(effects.readResponse()).toBe('ab'); + expect(installs.length).toBe(before + 1); + ctx.ex.ghostty_terminal_free(ctx.term); + ctx.term = 0; + binding.free(); + binding.free(); + expect(installs[before].releases).toBe(1); + effects.free(); + ctx.scratch.free(); + }); + + test('a focus report fits its buffer', () => { + const ctx = bindingContext(vt, 10, 3, pasteFocusSlots()); + const binding = new PasteFocusBinding(ctx); + expect(binding.encodeFocus(false).length).toBeLessThanOrEqual(FOCUS_REPORT_MAX); + binding.free(); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/search-binding.test.ts b/src/client/vendor/tests/engine/search-binding.test.ts index c704a34..50dab2d 100644 --- a/src/client/vendor/tests/engine/search-binding.test.ts +++ b/src/client/vendor/tests/engine/search-binding.test.ts @@ -1,10 +1,13 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { GridRefs, gridRefSlots } from '../../ghostty-web/engine/grid-refs'; +import { SearchBinding, searchSlots } from '../../ghostty-web/engine/search-binding'; import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bindingContext, freeContext, writeVt } from '../support/engine-harness'; import { THEME } from '../support/terminal-text'; -import { loadGhostty } from '../support/wasm'; +import { countedGhostty, loadGhostty } from '../support/wasm'; // The wasm search (ADR 055, ADR 061): matches found a step at a time, newest first, the -// selected match, and the restart and pruning rules, tested through OfficialTerminal. +// selected match, and the restart and pruning rules, through OfficialTerminal and directly. let vt: Ghostty; @@ -120,3 +123,44 @@ describe('official engine: search (ADR 055)', () => { t.free(); }); }); + +describe('SearchBinding', () => { + test('without a needle every read is empty and nothing is made', async () => { + const { ghostty, calls } = await countedGhostty(['ghostty_search_new']); + const ctx = bindingContext(ghostty, 10, 3, { + ...gridRefSlots(ghostty.L), + ...searchSlots(ghostty.L), + }); + const search = new SearchBinding(ctx, new GridRefs(ctx)); + expect(search.searchStep(10)).toBe(false); + expect(search.searchSelect('next')).toBe(false); + expect(search.searchState()).toEqual({ total: 0, selected: null }); + expect(search.searchSelectedMatch()).toBeNull(); + expect(search.searchMatches()).toEqual(new Int32Array(0)); + search.setSearch(''); + expect(calls.ghostty_search_new).toBe(0); + freeContext(ctx); + }); + + test('free() frees the search and its match buffer once; a second call is a no-op', async () => { + const { ghostty, calls } = await countedGhostty(['ghostty_search_free', 'ghostty_wasm_free']); + const ctx = bindingContext(ghostty, 10, 3, { + ...gridRefSlots(ghostty.L), + ...searchSlots(ghostty.L), + }); + const search = new SearchBinding(ctx, new GridRefs(ctx)); + writeVt(ctx, 'ab ab ab'); + search.setSearch('ab'); + while (search.searchStep(50)) {} + expect(search.searchMatches().length).toBe(12); + const frees = calls.ghostty_wasm_free; + search.free(); + expect(calls.ghostty_search_free).toBe(1); + // The match buffer + expect(calls.ghostty_wasm_free).toBe(frees + 1); + search.free(); + expect([calls.ghostty_search_free, calls.ghostty_wasm_free]).toEqual([1, frees + 1]); + expect(search.searchState()).toEqual({ total: 0, selected: null }); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/engine/selection-binding.test.ts b/src/client/vendor/tests/engine/selection-binding.test.ts index 88c1479..0285c5f 100644 --- a/src/client/vendor/tests/engine/selection-binding.test.ts +++ b/src/client/vendor/tests/engine/selection-binding.test.ts @@ -1,10 +1,18 @@ import { beforeAll, describe, expect, test } from 'bun:test'; +import { GridRefs, gridRefSlots } from '../../ghostty-web/engine/grid-refs'; +import { + clearSelectionStruct, + SelectionBinding, + selectionRange, + selectionSlots, +} from '../../ghostty-web/engine/selection-binding'; import type { Ghostty, OfficialTerminal } from '../../ghostty-web/ghostty'; +import { bindingContext, freeContext, writeVt } from '../support/engine-harness'; import { markedSession, THEME } from '../support/terminal-text'; import { loadGhostty } from '../support/wasm'; // Ghostty's selection rules (ADR 050, ADR 057, ADR 061): words, lines and command output -// picked from a cell, and the selected text, tested through OfficialTerminal. +// picked from a cell, and the selected text, through OfficialTerminal and directly. let vt: Ghostty; @@ -105,3 +113,49 @@ describe('official engine: command output (ADR 057)', () => { plain.free(); }); }); + +describe('SelectionBinding', () => { + function binding(cols = 10, rows = 3) { + const ctx = bindingContext(vt, cols, rows, { ...gridRefSlots(vt.L), ...selectionSlots(vt.L) }); + const refs = new GridRefs(ctx); + return { ctx, refs, selection: new SelectionBinding(ctx, refs) }; + } + + test('selects and copies on a context of its own', () => { + const { ctx, selection } = binding(); + writeVt(ctx, 'one two\r\nthree'); + expect(selection.selectWord({ col: 5, row: 0 })).toEqual({ + start: { col: 4, row: 0 }, + end: { col: 6, row: 0 }, + }); + expect(selection.selectLine({ col: 1, row: 1 })).toEqual({ + start: { col: 0, row: 1 }, + end: { col: 4, row: 1 }, + }); + expect(selection.selectionText({ col: 0, row: 0 }, { col: 4, row: 1 })).toBe('one two\nthree'); + // A cell off the grid selects nothing, and output needs OSC 133 marks + expect(selection.selectWord({ col: -1, row: 0 })).toBeNull(); + expect(selection.selectOutput({ col: 10, row: 0 })).toBeNull(); + expect(selection.selectionText({ col: -5, row: -5 }, { col: 99, row: 99 })).toBe( + 'one two\nthree', + ); + freeContext(ctx); + }); + + test('clearSelectionStruct zeroes a selection and sets its size; selectionRange reads it', () => { + const { ctx, refs } = binding(); + const S = ctx.L.selection; + ctx.u8().fill(0xff, ctx.at.sel, ctx.at.sel + S.size); + clearSelectionStruct(ctx, ctx.at.sel); + expect(ctx.dv().getUint32(ctx.at.sel + S.sizeField, true)).toBe(S.size); + // Zeroed grid refs resolve to no cell + expect(selectionRange(refs, ctx.L, ctx.at.sel)).toBeNull(); + expect(refs.screenRef({ col: 1, row: 0 }, ctx.at.sel + S.start)).toBe(true); + expect(refs.screenRef({ col: 3, row: 1 }, ctx.at.sel + S.end)).toBe(true); + expect(selectionRange(refs, ctx.L, ctx.at.sel)).toEqual({ + start: { col: 1, row: 0 }, + end: { col: 3, row: 1 }, + }); + freeContext(ctx); + }); +}); diff --git a/src/client/vendor/tests/support/engine-harness.ts b/src/client/vendor/tests/support/engine-harness.ts index 0c1b64b..e630bee 100644 --- a/src/client/vendor/tests/support/engine-harness.ts +++ b/src/client/vendor/tests/support/engine-harness.ts @@ -1,9 +1,11 @@ // Engine parts on a real wasm instance without the OfficialTerminal facade (test support, -// ADR 064): a context with the render state's slots, and raw writes that bump its -// generation as the facade's do. +// ADR 064): contexts with one binding's slots, raw writes that bump the generation as the +// facade's do, and a host that records the callbacks a binding installs and releases. -import { EngineContext } from '../../ghostty-web/engine/engine-context'; +import { EngineContext, type SlotSpec } from '../../ghostty-web/engine/engine-context'; +import type { EngineHost } from '../../ghostty-web/engine/engine-types'; import { type RenderStateSlot, renderStateSlots } from '../../ghostty-web/engine/render-state'; +import type { InstalledFn } from '../../ghostty-web/fn-table'; import type { Ghostty } from '../../ghostty-web/ghostty'; /** A context for a `cols` × `rows` terminal with the render state's scratch slots. */ @@ -15,6 +17,46 @@ export function renderContext( return new EngineContext(vt, cols, rows, renderStateSlots(vt.L)); } +/** A context for a `cols` × `rows` terminal with the scratch slots a binding declares. */ +export function bindingContext<K extends string>( + vt: EngineHost, + cols: number, + rows: number, + slots: SlotSpec<K>, +): EngineContext<K> { + return new EngineContext(vt, cols, rows, slots); +} + +/** One callback a binding installed, and how many times it was released. */ +export interface TrackedInstall { + index: number; + releases: number; +} + +/** + * `vt` with a function table that records every install, so a test can check that a + * binding's `free()` releases each callback it installed, once. + */ +export function trackedHost(vt: Ghostty): { host: EngineHost; installs: TrackedInstall[] } { + const installs: TrackedInstall[] = []; + const fnTable = { + install(...args: Parameters<Ghostty['fnTable']['install']>): InstalledFn { + const real = vt.fnTable.install(...args); + const record = { index: real.index, releases: 0 }; + installs.push(record); + return { + index: real.index, + release: () => { + record.releases++; + real.release(); + }, + }; + }, + }; + const host: EngineHost = Object.create(vt, { fnTable: { value: fnTable } }); + return { host, installs }; +} + /** Feeds `text` to the terminal through `vt_write` and bumps the generation. */ export function writeVt(ctx: EngineContext, text: string): void { const bytes = new TextEncoder().encode(text); diff --git a/src/client/vendor/tests/support/wasm.ts b/src/client/vendor/tests/support/wasm.ts index ca862c4..2052ed9 100644 --- a/src/client/vendor/tests/support/wasm.ts +++ b/src/client/vendor/tests/support/wasm.ts @@ -44,6 +44,23 @@ export async function loadGhostty(): Promise<Ghostty> { /** Export call counts by name, filled in by a `countedGhostty` instance. */ export type ExportCalls = Record<string, number>; +/** Wraps one raw export: gets its name and the real function, returns the replacement. */ +export type ExportWrapper = ( + name: string, + fn: (...args: number[]) => number, +) => (...args: number[]) => number; + +/** A Ghostty on a fresh instance whose named exports are replaced by `wrap`. */ +export async function wrappedGhostty( + names: readonly string[], + wrap: ExportWrapper, +): Promise<Ghostty> { + const real = (await instantiateWasm()).exports as Record<string, unknown>; + const spy: Record<string, unknown> = { ...real }; + for (const name of names) spy[name] = wrap(name, real[name] as (...args: number[]) => number); + return new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]); +} + /** * A Ghostty on a fresh instance whose named exports count their calls, so a test can * assert how many times the engine crossed into the wasm for a given read. @@ -51,19 +68,11 @@ export type ExportCalls = Record<string, number>; export async function countedGhostty( names: readonly string[], ): Promise<{ ghostty: Ghostty; calls: ExportCalls }> { - const real = (await instantiateWasm()).exports as Record<string, unknown>; const calls: ExportCalls = {}; - const spy: Record<string, unknown> = { ...real }; - for (const name of names) { - const fn = real[name] as (...args: number[]) => number; - calls[name] = 0; - spy[name] = (...args: number[]) => { - calls[name]++; - return fn(...args); - }; - } - return { - ghostty: new Ghostty(spy as unknown as ConstructorParameters<typeof Ghostty>[0]), - calls, - }; + for (const name of names) calls[name] = 0; + const ghostty = await wrappedGhostty(names, (name, fn) => (...args: number[]) => { + calls[name]++; + return fn(...args); + }); + return { ghostty, calls }; } From 217040eece2fb31b4900597548ce291bee5c645d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:17:27 -0400 Subject: [PATCH 238/295] docs(arch): engine split done; grid-refs and engine-config contents in the tree --- docs/specs/architecture.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index ccbae20..99a7dab 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -198,8 +198,8 @@ ghostty-web/ engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts wasm-loader.ts engine-context.ts official-terminal.ts render-state.ts row-ref.ts scrollback-pages.ts selection-binding.ts search-binding.ts mouse-encoder-binding.ts paste-focus-binding.ts key-encoder-binding.ts - engine-effects.ts (FnTable callbacks, clipboard write, readTitle) grid-refs.ts (grid and screen refs, hyperlinkAt, graphemes) - engine-config.ts (applyConfig, colour reads and writes) cursor-blink.ts text-mime.ts kitty-keyboard-reply.ts + engine-effects.ts (FnTable callbacks, clipboard write, readTitle) grid-refs.ts (grid and screen refs, row wrap, hyperlinkAt) + engine-config.ts (applyConfig, colour writes, the DECSCUSR blink) cursor-blink.ts text-mime.ts kitty-keyboard-reply.ts core/ Terminal's collaborators; import core/host-types.ts, never terminal.ts host-types.ts terminal-constants.ts terminal-geometry.ts options-proxy.ts dom-setup.ts viewport.ts focus-tracker.ts find-controller.ts write-pipeline.ts render-loop.ts @@ -327,7 +327,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a | Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | -| Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | +| Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ✅ | | Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | | Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ⬜ | | Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ⬜ | From deed66d35149067a2084301536d64a0e2b2692d9 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:24:45 -0400 Subject: [PATCH 239/295] refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics The first pure parts of renderer.ts get files of their own under render/ (ADR 061): - render/renderable.ts: IRenderable, IScrollbackProvider, GraphemeLookup and RowContent (the { line, grapheme } shape rowAt returned, written out three times before) - render/default-theme.ts: DEFAULT_THEME - render/device-pixel-ratio.ts: wholeDevicePixelRatio, watchDevicePixelRatio - render/frame-lines.ts: frameLines - render/font-metrics.ts: FontMetrics, fontMetricsFrom (the cell size from a measure of 'M') and measureFont renderer.ts imports them and re-exports what it exported; the barrel points at the new homes. Their tests move out of renderer.test.ts into vendor/tests/render/, table-driven, with the missing-matchMedia and missing-ratio branches covered. Behaviour unchanged. --- src/client/vendor/ghostty-web/PROVENANCE.md | 11 +- src/client/vendor/ghostty-web/index.ts | 6 +- .../ghostty-web/render/default-theme.ts | 38 ++++ .../ghostty-web/render/device-pixel-ratio.ts | 40 ++++ .../vendor/ghostty-web/render/font-metrics.ts | 46 +++++ .../vendor/ghostty-web/render/frame-lines.ts | 25 +++ .../vendor/ghostty-web/render/renderable.ts | 65 ++++++ src/client/vendor/ghostty-web/renderer.ts | 195 +++--------------- .../tests/render/device-pixel-ratio.test.ts | 104 ++++++++++ .../vendor/tests/render/font-metrics.test.ts | 59 ++++++ .../vendor/tests/render/frame-lines.test.ts | 76 +++++++ src/client/vendor/tests/renderer.test.ts | 166 +-------------- 12 files changed, 490 insertions(+), 341 deletions(-) create mode 100644 src/client/vendor/ghostty-web/render/default-theme.ts create mode 100644 src/client/vendor/ghostty-web/render/device-pixel-ratio.ts create mode 100644 src/client/vendor/ghostty-web/render/font-metrics.ts create mode 100644 src/client/vendor/ghostty-web/render/frame-lines.ts create mode 100644 src/client/vendor/ghostty-web/render/renderable.ts create mode 100644 src/client/vendor/tests/render/device-pixel-ratio.test.ts create mode 100644 src/client/vendor/tests/render/font-metrics.test.ts create mode 100644 src/client/vendor/tests/render/frame-lines.test.ts diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 453384b..9bea687 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -14,8 +14,8 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: the mouse cursor is the default arrow instead of the `text` I-beam when not over a link (`mouseCursorFor`); `pointer` over links is unchanged. - `input-handler.ts`: mouse motion with no button held is reported as button 35 instead of 32, the left-drag code, a release outside the terminal no longer leaves a button held, motion is reported once per cell instead of per `mousemove`, and its own `wheel` listener is removed: the terminal's paced `handleWheel` is the only wheel path, where a wheel over the padding (the container itself the target, so the terminal's capture listener did not stop it) was sent a second time, unpaced. -- `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. -- `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. +- `renderer.ts`: the canvas is drawn at the device pixel ratio rounded up to a whole number (`wholeDevicePixelRatio`, now in `render/device-pixel-ratio.ts` with `watchDevicePixelRatio`), backing-store sizes are rounded the same way in `resize()` and `render()`, ratio changes are followed through a re-armed resolution media query (`watchDevicePixelRatio`), and `devicePixelRatio` is a public read-only getter. +- `renderer.ts`: `render()` reads the viewport once per frame through `getViewport` and serves screen rows, including the cursor cell, as slices of that snapshot (`frameLines`, now `render/frame-lines.ts`) instead of calling `getLine` per row, which copied and parsed the whole viewport each time. - `terminal.ts`, `renderer.ts`, `ghostty.ts`, `types.ts`: the cursor shape and blink are Ghostty's (ADR 049). The configured `cursorStyle` / `cursorBlink` are passed in `GhosttyTerminalConfig` and set as `DEFAULT_CURSOR_STYLE` / `DEFAULT_CURSOR_BLINK`, which DECSCUSR Ps 0 and RIS restore; an option change calls the binding's new `setDefaultCursor`. `render()` draws the `style` and `blinking` that `getCursor()` reports (upstream drew only the options, and Ghostty's render-state style was hard-coded to block), and a style or blink change repaints the cursor row on the next frame so the old shape does not stay under the new one. webtty's earlier output scan for DECSCUSR (`scanDecscusr`, `decscusrShape`) is removed. DEC mode 12 is ignored, as in Ghostty with `cursor-style-blink` set: the binding keeps the blink set by the last DECSCUSR, DECSTR or RIS (`scanCursorBlink`, run only on chunks that can hold one) or the default, and puts Ghostty's mode 12 bit back to it after every write and default change (`syncBlink`, `GHOSTTY_TERMINAL_OPT_MODE`). - `terminal.ts`, `interfaces.ts`: when the app tracks the mouse, `handleWheel` sends scroll buttons 64/65 (since ADR 049 encoded by Ghostty's mouse encoder in the app's mouse format) at the cell under the pointer on either screen instead of arrow keys or a viewport scroll, counting pixel deltas one tick per cell height and line/page deltas one tick times the new `mouseScrollSpeed` option (`wheelTicks`, now in `wheel.ts`). - `addons/fit.ts`: `proposeDimensions` no longer subtracts a fixed 15 px scrollbar reserve (the scrollbar is an overlay inside the canvas); the grid math is the pure `fitDimensions`. @@ -55,6 +55,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. +- `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042): renderer helpers webtty added, moved out of `renderer.ts` (ADR 061). - `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043, ADR 061): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout @@ -62,6 +63,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `render/`: the canvas renderer's parts: what it reads (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row reader (`frame-lines.ts`) and the cell size (`font-metrics.ts`). `renderer.ts` re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -100,3 +102,8 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `engine/official-terminal.ts`: `installCallbacks` (now `installReplies`, `installReports`, `installNotifications` and the clipboard install), `clipboardWrite`, `stringAt`, `readTitle`, `hasResponse`, `readResponse`, `readEffects` bodies, the effect fields and the clipboard reply slot (webtty) | `engine/engine-effects.ts` (`EngineEffects`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | | `engine/official-terminal.ts`: `applyConfig` (the colours now `applyColors`), `setDefaultCursor`, `syncBlink`, `writeRgb`, the blink fields and the DECSCUSR scan of `feed` (now `afterWrite`), the scrollback, fallback colour and mode constants, and the palette slot (webtty) | `engine/engine-config.ts` (`EngineConfig`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | | `engine/official-terminal.ts`: `getMode`, the cell pixel size fields, and the mode and string slots several bindings share (webtty) | `engine/engine-context.ts` (`EngineContext`) | refactor(client): engine split, part 4: per-feature bindings; OfficialTerminal a facade | +| `renderer.ts`: `IRenderable`, `IScrollbackProvider`, `GraphemeLookup`, and the row content type of `rowAt` (now `RowContent`) | `render/renderable.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | +| `renderer.ts`: `DEFAULT_THEME` | `render/default-theme.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | +| `renderer.ts`: `wholeDevicePixelRatio`, `watchDevicePixelRatio` (webtty) | `render/device-pixel-ratio.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | +| `renderer.ts`: `frameLines` (webtty) | `render/frame-lines.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | +| `renderer.ts`: `FontMetrics`, the measure of `measureFont` (now `fontMetricsFrom` and `measureFont`) | `render/font-metrics.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index 23e287b..edc4cea 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -34,7 +34,9 @@ export type { KeyEvent, GhosttyCell } from './types'; // Low-level components (for custom integrations) export { CanvasRenderer } from './renderer'; -export type { RendererOptions, FontMetrics, IRenderable } from './renderer'; +export type { RendererOptions } from './renderer'; +export type { FontMetrics } from './render/font-metrics'; +export type { IRenderable } from './render/renderable'; export { InputHandler } from './input-handler'; export { EventEmitter } from './event-emitter'; export { SelectionManager } from './selection-manager'; @@ -53,6 +55,6 @@ export type { ILink, ILinkProvider, IBufferCellPosition } from './types'; // webtty's scrolling motion (ADR 045), DPR tracking (ADR 039) and teardown (ADR 065) export { AT_ONCE, easeOutCubic, FRAME_MS, Glide, linear } from './glide'; export type { Ease, Pace } from './glide'; -export { watchDevicePixelRatio } from './renderer'; +export { watchDevicePixelRatio } from './render/device-pixel-ratio'; export { DisposableStore, listen, toDisposable } from './lifecycle/disposable-store'; export type { Disposable, ListenerTarget } from './lifecycle/disposable-store'; diff --git a/src/client/vendor/ghostty-web/render/default-theme.ts b/src/client/vendor/ghostty-web/render/default-theme.ts new file mode 100644 index 0000000..952d23d --- /dev/null +++ b/src/client/vendor/ghostty-web/render/default-theme.ts @@ -0,0 +1,38 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The renderer's default colours (constants): what an option's theme leaves unset (ADR 061). + +import type { ITheme } from '../interfaces'; + +/** Every theme colour, so the renderer can spread a partial theme over it. */ +export const DEFAULT_THEME: Required<ITheme> = { + foreground: '#d4d4d4', + background: '#1e1e1e', + cursor: '#ffffff', + cursorAccent: '#1e1e1e', + // Selection colors: solid colors that replace cell bg/fg when selected + // Using Ghostty's approach: selection bg = default fg, selection fg = default bg + selectionBackground: '#d4d4d4', + selectionForeground: '#1e1e1e', + // Ghostty's defaults: black on golden yellow, and on soft peach for the selected match + // (webtty, ADR 055) + searchBackground: '#ffe082', + searchForeground: '#000000', + searchSelectedBackground: '#f2a57e', + searchSelectedForeground: '#000000', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff', +}; diff --git a/src/client/vendor/ghostty-web/render/device-pixel-ratio.ts b/src/client/vendor/ghostty-web/render/device-pixel-ratio.ts new file mode 100644 index 0000000..e36ac61 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/device-pixel-ratio.ts @@ -0,0 +1,40 @@ +// webtty (ADR 039, ADR 061), not from upstream. +// The device pixel ratio the canvas is drawn at (util): rounded up to a whole ratio, and +// followed as it changes through a re-armed resolution media query. + +/** + * The ratio the canvas is drawn at: the device pixel ratio rounded up to a whole + * number. A fractional ratio (browser zoom, Windows 125%/150%) would put cell edges + * between device pixels and make the backing-store size fractional; the browser + * scales the whole-ratio canvas down instead (coder/ghostty-web#198). Browsers + * report some whole ratios with float noise (2.0000000298023224), which must not + * round up to the next whole ratio. + */ +export function wholeDevicePixelRatio(ratio: number | undefined): number { + return Math.max(1, Math.ceil((ratio || 1) - 1e-3)); +} + +/** + * Calls onChange with the new window.devicePixelRatio whenever it changes (moving + * to a display with a different scale, browser zoom). A resolution media query + * only matches one ratio, so it is re-armed for each new ratio. Returns a function + * that stops watching. + */ +export function watchDevicePixelRatio( + win: Pick<Window, 'devicePixelRatio' | 'matchMedia'>, + onChange: (ratio: number) => void +): () => void { + let stop = () => {}; + const arm = () => { + if (typeof win.matchMedia !== 'function') return; + const query = win.matchMedia(`(resolution: ${win.devicePixelRatio || 1}dppx)`); + const listener = () => { + onChange(win.devicePixelRatio || 1); + arm(); + }; + query.addEventListener('change', listener, { once: true }); + stop = () => query.removeEventListener('change', listener); + }; + arm(); + return () => stop(); +} diff --git a/src/client/vendor/ghostty-web/render/font-metrics.ts b/src/client/vendor/ghostty-web/render/font-metrics.ts new file mode 100644 index 0000000..159d4e9 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/font-metrics.ts @@ -0,0 +1,46 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The cell size of a font (util): measured from 'M' on a 2D context, with padding for +// glyphs that overflow their box (ADR 061). + +/** A character cell's size and baseline, in CSS pixels. */ +export interface FontMetrics { + width: number; // Character cell width in CSS pixels + height: number; // Character cell height in CSS pixels + baseline: number; // Distance from top to text baseline +} + +/** What `measureText` reports that the cell size is built from. */ +export type TextMeasure = Pick< + TextMetrics, + 'width' | 'actualBoundingBoxAscent' | 'actualBoundingBoxDescent' +>; + +/** + * The cell for a font of `fontSize` pixels from the measure of 'M' (typically the widest + * character). A context that reports no bounding box gets 80% ascent and 20% descent. + */ +export function fontMetricsFrom(measure: TextMeasure, fontSize: number): FontMetrics { + const width = Math.ceil(measure.width); + + // Measure height using ascent + descent with padding for glyph overflow + const ascent = measure.actualBoundingBoxAscent || fontSize * 0.8; + const descent = measure.actualBoundingBoxDescent || fontSize * 0.2; + + // Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p') + // and anti-aliasing pixels + const height = Math.ceil(ascent + descent) + 2; + const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding + + return { width, height, baseline }; +} + +/** Measures the cell of `fontSize`px `fontFamily` on `ctx` (use an offscreen canvas's). */ +export function measureFont( + ctx: Pick<CanvasRenderingContext2D, 'font' | 'measureText'>, + fontSize: number, + fontFamily: string +): FontMetrics { + // Set font (use actual pixel size for accurate measurement) + ctx.font = `${fontSize}px ${fontFamily}`; + return fontMetricsFrom(ctx.measureText('M'), fontSize); +} diff --git a/src/client/vendor/ghostty-web/render/frame-lines.ts b/src/client/vendor/ghostty-web/render/frame-lines.ts new file mode 100644 index 0000000..fcc1cea --- /dev/null +++ b/src/client/vendor/ghostty-web/render/frame-lines.ts @@ -0,0 +1,25 @@ +// webtty (ADR 042, ADR 061), not from upstream. +// One frame's row reader (util): the viewport read once and served as row slices, so a +// frame never copies and parses the whole viewport per row. + +import type { GhosttyCell } from '../engine/cell-types'; +import type { IRenderable } from './renderable'; + +/** + * Returns a row reader for one frame. When the buffer has getViewport, the + * viewport is read once, on the first row asked for, and every row is a slice of + * that snapshot; getLine copies and parses the whole viewport on each call. + */ +export function frameLines( + buffer: Pick<IRenderable, 'getLine' | 'getViewport'>, + cols: number, + rows: number +): (y: number) => GhosttyCell[] | null { + if (!buffer.getViewport) return (y) => buffer.getLine(y); + let viewport: GhosttyCell[] | null = null; + return (y) => { + if (y < 0 || y >= rows) return null; + viewport ??= buffer.getViewport!(); + return viewport.slice(y * cols, (y + 1) * cols); + }; +} diff --git a/src/client/vendor/ghostty-web/render/renderable.ts b/src/client/vendor/ghostty-web/render/renderable.ts new file mode 100644 index 0000000..4636b13 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/renderable.ts @@ -0,0 +1,65 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// What the canvas renderer reads (types): the screen buffer, the scrollback and one row's +// content. The render parts import these instead of the terminal or the engine (ADR 061). + +import type { GhosttyCell } from '../engine/cell-types'; + +/** The screen buffer the renderer draws: the engine's terminal, or a test double. */ +export interface IRenderable { + getLine(y: number): GhosttyCell[] | null; + /** + * All viewport cells, row-major, read in one call. The array may be reused by + * the next call, so rows taken from it are only valid until then. + */ + getViewport?(): GhosttyCell[]; + /** + * The view's `viewportY` (lines scrolled up from the bottom). `render` calls it + * first, so every render path syncs the terminal's view offset in one place. + * Optional for buffers that cannot scroll. (webtty, ADR 042) + */ + setViewportOffset?(linesFromBottom: number): void; + /** + * The cursor. `style` and `blinking`, when given, are the shape and blink the engine + * reports (DECSCUSR over the configured default) and are drawn instead of the + * options. (webtty, ADR 049) + */ + getCursor(): { + x: number; + y: number; + visible: boolean; + style?: 'block' | 'underline' | 'bar'; + blinking?: boolean; + }; + getDimensions(): { cols: number; rows: number }; + isRowDirty(y: number): boolean; + /** Returns true if a full redraw is needed (e.g., screen change) */ + needsFullRedraw?(): boolean; + clearDirty(): void; + /** + * Get the full grapheme string for a cell at (row, col). + * For cells with grapheme_len > 0, this returns all codepoints combined. + * For simple cells, returns the single character. + */ + getGraphemeString?(row: number, col: number): string; +} + +/** The scrollback rows above the screen, counted from the oldest; the terminal provides them. */ +export interface IScrollbackProvider { + getScrollbackLine(offset: number): GhosttyCell[] | null; + getScrollbackLength(): number; + /** Full grapheme string for a scrollback cell, as getGraphemeString for screen rows. */ + getScrollbackGraphemeString?(offset: number, col: number): string; +} + +/** Looks up the full grapheme string for a column of the line being drawn. */ +export type GraphemeLookup = (col: number) => string; + +/** + * What one canvas row shows: its line (null past the buffer's ends, drawn blank) and the + * grapheme lookup on the same source, since a canvas row is not a screen row while + * scrolled. (webtty, ADR 043) + */ +export interface RowContent { + line: GhosttyCell[] | null; + grapheme?: GraphemeLookup; +} diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 4d8c31d..332e73a 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -11,6 +11,16 @@ */ import type { ITheme } from './interfaces'; +import { DEFAULT_THEME } from './render/default-theme'; +import { watchDevicePixelRatio, wholeDevicePixelRatio } from './render/device-pixel-ratio'; +import { type FontMetrics, measureFont } from './render/font-metrics'; +import { frameLines } from './render/frame-lines'; +import type { + GraphemeLookup, + IRenderable, + IScrollbackProvider, + RowContent, +} from './render/renderable'; import { type HighlightSpan, highlightAt, @@ -19,57 +29,15 @@ import { spansKey, } from './search'; import type { SelectionManager } from './selection-manager'; -import type { GhosttyCell, ILink } from './types'; +import type { GhosttyCell } from './types'; import { CellFlags } from './types'; -// Interface for objects that can be rendered -export interface IRenderable { - getLine(y: number): GhosttyCell[] | null; - /** - * All viewport cells, row-major, read in one call. The array may be reused by - * the next call, so rows taken from it are only valid until then. - */ - getViewport?(): GhosttyCell[]; - /** - * The view's `viewportY` (lines scrolled up from the bottom). `render` calls it - * first, so every render path syncs the terminal's view offset in one place. - * Optional for buffers that cannot scroll. (webtty, ADR 042) - */ - setViewportOffset?(linesFromBottom: number): void; - /** - * The cursor. `style` and `blinking`, when given, are the shape and blink the engine - * reports (DECSCUSR over the configured default) and are drawn instead of the - * options. (webtty, ADR 049) - */ - getCursor(): { - x: number; - y: number; - visible: boolean; - style?: 'block' | 'underline' | 'bar'; - blinking?: boolean; - }; - getDimensions(): { cols: number; rows: number }; - isRowDirty(y: number): boolean; - /** Returns true if a full redraw is needed (e.g., screen change) */ - needsFullRedraw?(): boolean; - clearDirty(): void; - /** - * Get the full grapheme string for a cell at (row, col). - * For cells with grapheme_len > 0, this returns all codepoints combined. - * For simple cells, returns the single character. - */ - getGraphemeString?(row: number, col: number): string; -} - -export interface IScrollbackProvider { - getScrollbackLine(offset: number): GhosttyCell[] | null; - getScrollbackLength(): number; - /** Full grapheme string for a scrollback cell, as getGraphemeString for screen rows. */ - getScrollbackGraphemeString?(offset: number, col: number): string; -} - -/** Looks up the full grapheme string for a column of the line being drawn. */ -export type GraphemeLookup = (col: number) => string; +// The facade's former contents, re-exported so deep imports keep working (ADR 061) +export { DEFAULT_THEME } from './render/default-theme'; +export { watchDevicePixelRatio, wholeDevicePixelRatio } from './render/device-pixel-ratio'; +export type { FontMetrics } from './render/font-metrics'; +export { frameLines } from './render/frame-lines'; +export type { GraphemeLookup, IRenderable, IScrollbackProvider } from './render/renderable'; // ============================================================================ // Type Definitions @@ -106,62 +74,6 @@ const ROW_CUT = 2; */ export const REPAINT_CHUNK_ROWS = 8; -/** - * The ratio the canvas is drawn at: the device pixel ratio rounded up to a whole - * number. A fractional ratio (browser zoom, Windows 125%/150%) would put cell edges - * between device pixels and make the backing-store size fractional; the browser - * scales the whole-ratio canvas down instead (coder/ghostty-web#198). Browsers - * report some whole ratios with float noise (2.0000000298023224), which must not - * round up to the next whole ratio. - */ -export function wholeDevicePixelRatio(ratio: number | undefined): number { - return Math.max(1, Math.ceil((ratio || 1) - 1e-3)); -} - -/** - * Calls onChange with the new window.devicePixelRatio whenever it changes (moving - * to a display with a different scale, browser zoom). A resolution media query - * only matches one ratio, so it is re-armed for each new ratio. Returns a function - * that stops watching. - */ -export function watchDevicePixelRatio( - win: Pick<Window, 'devicePixelRatio' | 'matchMedia'>, - onChange: (ratio: number) => void -): () => void { - let stop = () => {}; - const arm = () => { - if (typeof win.matchMedia !== 'function') return; - const query = win.matchMedia(`(resolution: ${win.devicePixelRatio || 1}dppx)`); - const listener = () => { - onChange(win.devicePixelRatio || 1); - arm(); - }; - query.addEventListener('change', listener, { once: true }); - stop = () => query.removeEventListener('change', listener); - }; - arm(); - return () => stop(); -} - -/** - * Returns a row reader for one frame. When the buffer has getViewport, the - * viewport is read once, on the first row asked for, and every row is a slice of - * that snapshot; getLine copies and parses the whole viewport on each call. - */ -export function frameLines( - buffer: Pick<IRenderable, 'getLine' | 'getViewport'>, - cols: number, - rows: number -): (y: number) => GhosttyCell[] | null { - if (!buffer.getViewport) return (y) => buffer.getLine(y); - let viewport: GhosttyCell[] | null = null; - return (y) => { - if (y < 0 || y >= rows) return null; - viewport ??= buffer.getViewport!(); - return viewport.slice(y * cols, (y + 1) * cols); - }; -} - /** A selection: start and end cell, rows inclusive. */ interface CellRange { startCol: number; @@ -198,49 +110,6 @@ function sameRange(a: CellRange | null, b: CellRange | null): boolean { ); } -export interface FontMetrics { - width: number; // Character cell width in CSS pixels - height: number; // Character cell height in CSS pixels - baseline: number; // Distance from top to text baseline -} - -// ============================================================================ -// Default Theme -// ============================================================================ - -export const DEFAULT_THEME: Required<ITheme> = { - foreground: '#d4d4d4', - background: '#1e1e1e', - cursor: '#ffffff', - cursorAccent: '#1e1e1e', - // Selection colors: solid colors that replace cell bg/fg when selected - // Using Ghostty's approach: selection bg = default fg, selection fg = default bg - selectionBackground: '#d4d4d4', - selectionForeground: '#1e1e1e', - // Ghostty's defaults: black on golden yellow, and on soft peach for the selected match - // (webtty, ADR 055) - searchBackground: '#ffe082', - searchForeground: '#000000', - searchSelectedBackground: '#f2a57e', - searchSelectedForeground: '#000000', - black: '#000000', - red: '#cd3131', - green: '#0dbc79', - yellow: '#e5e510', - blue: '#2472c8', - magenta: '#bc3fbc', - cyan: '#11a8cd', - white: '#e5e5e5', - brightBlack: '#666666', - brightRed: '#f14c4c', - brightGreen: '#23d18b', - brightYellow: '#f5f543', - brightBlue: '#3b8eea', - brightMagenta: '#d670d6', - brightCyan: '#29b8db', - brightWhite: '#ffffff', -}; - // ============================================================================ // CanvasRenderer Class // ============================================================================ @@ -401,26 +270,8 @@ export class CanvasRenderer { private measureFont(): FontMetrics { // Use an offscreen canvas for measurement - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - - // Set font (use actual pixel size for accurate measurement) - ctx.font = `${this.fontSize}px ${this.fontFamily}`; - - // Measure width using 'M' (typically widest character) - const widthMetrics = ctx.measureText('M'); - const width = Math.ceil(widthMetrics.width); - - // Measure height using ascent + descent with padding for glyph overflow - const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8; - const descent = widthMetrics.actualBoundingBoxDescent || this.fontSize * 0.2; - - // Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p') - // and anti-aliasing pixels - const height = Math.ceil(ascent + descent) + 2; - const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding - - return { width, height, baseline }; + const ctx = document.createElement('canvas').getContext('2d')!; + return measureFont(ctx, this.fontSize, this.fontFamily); } /** @@ -781,7 +632,7 @@ export class CanvasRenderer { buffer: IRenderable, scrollbackProvider: IScrollbackProvider | undefined, scrollbackLength: number - ): { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } { + ): RowContent { if (y < viewportY) { // This row is from scrollback, counted from its end const scrollbackOffset = scrollbackLength - viewportY + y; @@ -820,7 +671,7 @@ export class CanvasRenderer { last: number, cut: boolean, cols: number, - rowAt: (k: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + rowAt: (k: number) => RowContent ): void { this.drawCount++; const state = this.rowState; @@ -849,7 +700,7 @@ export class CanvasRenderer { last: number, cut: boolean, cols: number, - rowAt: (k: number) => { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + rowAt: (k: number) => RowContent ): void { const end = cut ? last : last + 1; const own = this.ctx; @@ -879,7 +730,7 @@ export class CanvasRenderer { private drawRow( k: number, cols: number, - { line, grapheme }: { line: GhosttyCell[] | null; grapheme?: GraphemeLookup } + { line, grapheme }: RowContent ): void { this.drawCount++; if (line) { diff --git a/src/client/vendor/tests/render/device-pixel-ratio.test.ts b/src/client/vendor/tests/render/device-pixel-ratio.test.ts new file mode 100644 index 0000000..d38fc36 --- /dev/null +++ b/src/client/vendor/tests/render/device-pixel-ratio.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; +import { + watchDevicePixelRatio, + wholeDevicePixelRatio, +} from '../../ghostty-web/render/device-pixel-ratio'; + +// The whole drawing ratio and the re-armed resolution query that follows ratio changes. + +describe('wholeDevicePixelRatio', () => { + test.each([ + ['a whole ratio', 1, 1], + ['a whole ratio', 2, 2], + ['a fractional ratio, rounded up', 1.25, 2], + ['a fractional ratio, rounded up', 1.36, 2], + ['a fractional ratio, rounded up', 2.5, 3], + ['float noise on a whole ratio', 2.0000000298023224, 2], + ['float noise on a whole ratio', 1.0000000149011612, 1], + ['a ratio below 1', 0.5, 1], + ['zero', 0, 1], + ['no ratio', undefined, 1], + ])('%s: %p -> %p', (_name, ratio, whole) => { + expect(wholeDevicePixelRatio(ratio)).toBe(whole); + }); + + test('keeps the backing store a whole number of pixels', () => { + const dpr = wholeDevicePixelRatio(1.36); + expect(Number.isInteger(80 * 9 * dpr)).toBe(true); + }); +}); + +/** A window whose resolution queries can be fired by hand. */ +function fakeWindow(ratio: number) { + const queries: { media: string; listeners: Set<() => void> }[] = []; + const win = { + devicePixelRatio: ratio, + matchMedia(media: string) { + const query = { media, listeners: new Set<() => void>() }; + queries.push(query); + return { + addEventListener: (_: string, listener: () => void) => query.listeners.add(listener), + removeEventListener: (_: string, listener: () => void) => query.listeners.delete(listener), + } as unknown as MediaQueryList; + }, + }; + const change = (next: number) => { + const query = queries[queries.length - 1]; + win.devicePixelRatio = next; + for (const listener of [...query.listeners]) { + query.listeners.delete(listener); + listener(); + } + }; + return { win, queries, change }; +} + +describe('watchDevicePixelRatio', () => { + test('watches the current ratio', () => { + const { win, queries } = fakeWindow(2); + watchDevicePixelRatio(win, () => {}); + expect(queries.map((q) => q.media)).toEqual(['(resolution: 2dppx)']); + }); + + test('reports each change and re-arms for the new ratio', () => { + const { win, queries, change } = fakeWindow(2); + const seen: number[] = []; + watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); + change(1); + change(1.25); + expect(seen).toEqual([1, 1.25]); + expect(queries.map((q) => q.media)).toEqual([ + '(resolution: 2dppx)', + '(resolution: 1dppx)', + '(resolution: 1.25dppx)', + ]); + }); + + test('reads a missing ratio as 1', () => { + const { win, queries, change } = fakeWindow(0); + const seen: number[] = []; + watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); + change(0); + expect(seen).toEqual([1]); + expect(queries.map((q) => q.media)).toEqual(['(resolution: 1dppx)', '(resolution: 1dppx)']); + }); + + test('stops reporting once stopped', () => { + const { win, queries, change } = fakeWindow(2); + const seen: number[] = []; + const stop = watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); + change(1); + stop(); + expect(queries[queries.length - 1].listeners.size).toBe(0); + change(2); + expect(seen).toEqual([1]); + }); + + test('does nothing without matchMedia', () => { + const win = { devicePixelRatio: 2 } as Pick<Window, 'devicePixelRatio' | 'matchMedia'>; + const stop = watchDevicePixelRatio(win, () => { + throw new Error('never called'); + }); + expect(() => stop()).not.toThrow(); + }); +}); diff --git a/src/client/vendor/tests/render/font-metrics.test.ts b/src/client/vendor/tests/render/font-metrics.test.ts new file mode 100644 index 0000000..a0e4ff9 --- /dev/null +++ b/src/client/vendor/tests/render/font-metrics.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; +import { fontMetricsFrom, measureFont } from '../../ghostty-web/render/font-metrics'; + +// The cell size from a measure of 'M': rounded up, with 2px of overflow padding. + +describe('fontMetricsFrom', () => { + test.each([ + [ + 'whole bounds', + { width: 9, ascent: 11, descent: 3 }, + 15, + { width: 9, height: 16, baseline: 12 }, + ], + [ + 'fractional bounds', + { width: 8.4, ascent: 10.2, descent: 2.5 }, + 14, + { width: 9, height: 15, baseline: 12 }, + ], + [ + 'no bounding box: 80% / 20% of the size', + { width: 9, ascent: 0, descent: 0 }, + 15, + { width: 9, height: 17, baseline: 13 }, + ], + [ + 'no descent', + { width: 7, ascent: 10, descent: 0 }, + 10, + { width: 7, height: 14, baseline: 11 }, + ], + ])('%s', (_name, m, fontSize, metrics) => { + const measure = { + width: m.width, + actualBoundingBoxAscent: m.ascent, + actualBoundingBoxDescent: m.descent, + }; + expect(fontMetricsFrom(measure, fontSize)).toEqual(metrics); + }); +}); + +describe('measureFont', () => { + test("sets the font and measures 'M'", () => { + const measured: string[] = []; + const ctx = { + font: '', + measureText(text: string) { + measured.push(`${ctx.font}|${text}`); + return { + width: 9, + actualBoundingBoxAscent: 11, + actualBoundingBoxDescent: 3, + } as TextMetrics; + }, + }; + expect(measureFont(ctx, 15, 'monospace')).toEqual({ width: 9, height: 16, baseline: 12 }); + expect(measured).toEqual(['15px monospace|M']); + }); +}); diff --git a/src/client/vendor/tests/render/frame-lines.test.ts b/src/client/vendor/tests/render/frame-lines.test.ts new file mode 100644 index 0000000..9b3a8a3 --- /dev/null +++ b/src/client/vendor/tests/render/frame-lines.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'bun:test'; +import type { GhosttyCell } from '../../ghostty-web/engine/cell-types'; +import { frameLines } from '../../ghostty-web/render/frame-lines'; +import { loadGhostty } from '../support/wasm'; + +// One frame's row reader: one viewport read, rows served as slices of it. + +/** A cell whose codepoint records its row and column. */ +function cellAt(y: number, x: number): GhosttyCell { + return { + codepoint: y * 100 + x, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; +} + +describe('frameLines', () => { + const cols = 3; + const rows = 2; + const viewport = [0, 1].flatMap((y) => [0, 1, 2].map((x) => cellAt(y, x))); + + test('reads the viewport once per frame and slices rows from it', () => { + let reads = 0; + let lineReads = 0; + const buffer = { + getViewport: () => { + reads++; + return viewport; + }, + getLine: () => { + lineReads++; + return null; + }, + }; + const getLine = frameLines(buffer, cols, rows); + expect(reads).toBe(0); + expect(getLine(1)?.map((c) => c.codepoint)).toEqual([100, 101, 102]); + expect(getLine(0)?.map((c) => c.codepoint)).toEqual([0, 1, 2]); + expect(getLine(1)?.map((c) => c.codepoint)).toEqual([100, 101, 102]); + expect(reads).toBe(1); + expect(lineReads).toBe(0); + frameLines(buffer, cols, rows)(0); + expect(reads).toBe(2); + }); + + test('returns null outside the viewport', () => { + const getLine = frameLines({ getViewport: () => viewport, getLine: () => null }, cols, rows); + expect(getLine(-1)).toBeNull(); + expect(getLine(rows)).toBeNull(); + }); + + test('falls back to getLine without getViewport', () => { + const getLine = frameLines({ getLine: (y) => [cellAt(y, 0)] }, cols, rows); + expect(getLine(1)?.[0].codepoint).toBe(100); + }); + + test('serves the same rows as getLine from a real terminal', async () => { + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(20, 5); + term.write('\x1b[31mred\x1b[0m \x1b[1;44mbold\x1b[0m\r\n中文 ✓\r\n\x1b[7mrev\x1b[0m'); + term.update(); + const getLine = frameLines(term, 20, 5); + for (let y = 0; y < 5; y++) { + expect(getLine(y)).toEqual(term.getLine(y)); + } + term.free(); + }); +}); diff --git a/src/client/vendor/tests/renderer.test.ts b/src/client/vendor/tests/renderer.test.ts index 8b7e890..f4f8259 100644 --- a/src/client/vendor/tests/renderer.test.ts +++ b/src/client/vendor/tests/renderer.test.ts @@ -1,171 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { - CanvasRenderer, - frameLines, - REPAINT_CHUNK_ROWS, - watchDevicePixelRatio, - wholeDevicePixelRatio, -} from '../ghostty-web/renderer'; -import type { GhosttyCell } from '../ghostty-web/types'; +import { CanvasRenderer, REPAINT_CHUNK_ROWS } from '../ghostty-web/renderer'; import { loadGhostty } from './support/wasm'; -describe('wholeDevicePixelRatio', () => { - test('keeps a whole ratio', () => { - expect(wholeDevicePixelRatio(1)).toBe(1); - expect(wholeDevicePixelRatio(2)).toBe(2); - }); - - test('rounds a fractional ratio up', () => { - expect(wholeDevicePixelRatio(1.25)).toBe(2); - expect(wholeDevicePixelRatio(1.36)).toBe(2); - expect(wholeDevicePixelRatio(2.5)).toBe(3); - }); - - test('ignores float noise on a whole ratio', () => { - expect(wholeDevicePixelRatio(2.0000000298023224)).toBe(2); - expect(wholeDevicePixelRatio(1.0000000149011612)).toBe(1); - }); - - test('never goes below 1', () => { - expect(wholeDevicePixelRatio(0.5)).toBe(1); - expect(wholeDevicePixelRatio(0)).toBe(1); - expect(wholeDevicePixelRatio(undefined)).toBe(1); - }); - - test('keeps the backing store a whole number of pixels', () => { - const dpr = wholeDevicePixelRatio(1.36); - expect(Number.isInteger(80 * 9 * dpr)).toBe(true); - }); -}); - -/** A window whose resolution queries can be fired by hand. */ -function fakeWindow(ratio: number) { - const queries: { media: string; listeners: Set<() => void> }[] = []; - const win = { - devicePixelRatio: ratio, - matchMedia(media: string) { - const query = { media, listeners: new Set<() => void>() }; - queries.push(query); - return { - addEventListener: (_: string, listener: () => void) => query.listeners.add(listener), - removeEventListener: (_: string, listener: () => void) => query.listeners.delete(listener), - } as unknown as MediaQueryList; - }, - }; - const change = (next: number) => { - const query = queries[queries.length - 1]; - win.devicePixelRatio = next; - for (const listener of [...query.listeners]) { - query.listeners.delete(listener); - listener(); - } - }; - return { win, queries, change }; -} - -describe('watchDevicePixelRatio', () => { - test('watches the current ratio', () => { - const { win, queries } = fakeWindow(2); - watchDevicePixelRatio(win, () => {}); - expect(queries.map((q) => q.media)).toEqual(['(resolution: 2dppx)']); - }); - - test('reports each change and re-arms for the new ratio', () => { - const { win, queries, change } = fakeWindow(2); - const seen: number[] = []; - watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); - change(1); - change(1.25); - expect(seen).toEqual([1, 1.25]); - expect(queries.map((q) => q.media)).toEqual([ - '(resolution: 2dppx)', - '(resolution: 1dppx)', - '(resolution: 1.25dppx)', - ]); - }); - - test('stops reporting once stopped', () => { - const { win, queries, change } = fakeWindow(2); - const seen: number[] = []; - const stop = watchDevicePixelRatio(win, (ratio) => seen.push(ratio)); - change(1); - stop(); - expect(queries[queries.length - 1].listeners.size).toBe(0); - change(2); - expect(seen).toEqual([1]); - }); -}); - -/** A cell whose codepoint records its row and column. */ -function cellAt(y: number, x: number): GhosttyCell { - return { - codepoint: y * 100 + x, - fg_r: 0, - fg_g: 0, - fg_b: 0, - bg_r: 0, - bg_g: 0, - bg_b: 0, - flags: 0, - width: 1, - hyperlink_id: 0, - grapheme_len: 0, - }; -} - -describe('frameLines', () => { - const cols = 3; - const rows = 2; - const viewport = [0, 1].flatMap((y) => [0, 1, 2].map((x) => cellAt(y, x))); - - test('reads the viewport once per frame and slices rows from it', () => { - let reads = 0; - let lineReads = 0; - const buffer = { - getViewport: () => { - reads++; - return viewport; - }, - getLine: () => { - lineReads++; - return null; - }, - }; - const getLine = frameLines(buffer, cols, rows); - expect(reads).toBe(0); - expect(getLine(1)?.map((c) => c.codepoint)).toEqual([100, 101, 102]); - expect(getLine(0)?.map((c) => c.codepoint)).toEqual([0, 1, 2]); - expect(getLine(1)?.map((c) => c.codepoint)).toEqual([100, 101, 102]); - expect(reads).toBe(1); - expect(lineReads).toBe(0); - frameLines(buffer, cols, rows)(0); - expect(reads).toBe(2); - }); - - test('returns null outside the viewport', () => { - const getLine = frameLines({ getViewport: () => viewport, getLine: () => null }, cols, rows); - expect(getLine(-1)).toBeNull(); - expect(getLine(rows)).toBeNull(); - }); - - test('falls back to getLine without getViewport', () => { - const getLine = frameLines({ getLine: (y) => [cellAt(y, 0)] }, cols, rows); - expect(getLine(1)?.[0].codepoint).toBe(100); - }); - - test('serves the same rows as getLine from a real terminal', async () => { - const ghostty = await loadGhostty(); - const term = ghostty.createTerminal(20, 5); - term.write('\x1b[31mred\x1b[0m \x1b[1;44mbold\x1b[0m\r\n中文 ✓\r\n\x1b[7mrev\x1b[0m'); - term.update(); - const getLine = frameLines(term, 20, 5); - for (let y = 0; y < 5; y++) { - expect(getLine(y)).toEqual(term.getLine(y)); - } - term.free(); - }); -}); - /** A 2D context that records the text drawn on each canvas row. */ function recordingCanvas(cellHeight: number) { const drawn = new Map<number, string[]>(); From 79f84ead72cc249fd69898d5d1c2f2d165a1022a Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:26:31 -0400 Subject: [PATCH 240/295] refactor(client): render split, part 1: one cell range for the renderer, selection and links render/cell-range.ts is the one range shape and inclusive test (ADR 061). The renderer kept two shapes for the same thing (CellRange for the selection, LinkRange with startX/startY for the hovered link), two move helpers, a JSON.stringify comparison for one of them, and two copies of the inclusive test; LinkDetector had a third. - CellRange, cellInRange, moveRangeRows, sameRange - the renderer keeps the hovered link as a CellRange (setHoveredLinkRange keeps its signature and converts at the boundary) and compares it with sameRange - LinkDetector.isPositionInLink calls cellInRange - SelectionCoordinates is an alias of CellRange For the normalised ranges the selection manager and the link providers produce, every test answers as before. cellInRange allocates nothing, so the per-cell paint path stays a direct call. New table-driven tests for the range, and LinkDetector tests (it had none). --- src/client/vendor/ghostty-web/PROVENANCE.md | 4 +- .../vendor/ghostty-web/link-detector.ts | 29 ++--- .../vendor/ghostty-web/render/cell-range.ts | 40 ++++++ src/client/vendor/ghostty-web/renderer.ts | 122 ++++-------------- .../vendor/ghostty-web/selection-manager.ts | 9 +- src/client/vendor/tests/link-detector.test.ts | 98 ++++++++++++++ .../vendor/tests/render/cell-range.test.ts | 73 +++++++++++ 7 files changed, 251 insertions(+), 124 deletions(-) create mode 100644 src/client/vendor/ghostty-web/render/cell-range.ts create mode 100644 src/client/vendor/tests/link-detector.test.ts create mode 100644 src/client/vendor/tests/render/cell-range.test.ts diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 9bea687..fc968b6 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -63,7 +63,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). -- `render/`: the canvas renderer's parts: what it reads (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row reader (`frame-lines.ts`) and the cell size (`font-metrics.ts`). `renderer.ts` re-exports what moved. +- `render/`: the canvas renderer's parts: what it reads (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row reader (`frame-lines.ts`), the cell size (`font-metrics.ts`) and the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`). `renderer.ts` re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -107,3 +107,5 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `renderer.ts`: `wholeDevicePixelRatio`, `watchDevicePixelRatio` (webtty) | `render/device-pixel-ratio.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | | `renderer.ts`: `frameLines` (webtty) | `render/frame-lines.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | | `renderer.ts`: `FontMetrics`, the measure of `measureFont` (now `fontMetricsFrom` and `measureFont`) | `render/font-metrics.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | +| `renderer.ts`: the `CellRange` and `LinkRange` interfaces (now one `CellRange`), `moveRows` and `moveRange` (now `moveRangeRows`), `sameRange`, the `isInSelection` test and the hovered-link test of `renderCellText` (now `cellInRange`) | `render/cell-range.ts` | refactor(client): render split, part 1: one cell range for the renderer, selection and links | +| `link-detector.ts`: the range test of `isPositionInLink` (now `cellInRange`); `selection-manager.ts`: `SelectionCoordinates`, now an alias of `CellRange` | `render/cell-range.ts` | refactor(client): render split, part 1: one cell range for the renderer, selection and links | diff --git a/src/client/vendor/ghostty-web/link-detector.ts b/src/client/vendor/ghostty-web/link-detector.ts index 812f9dd..6ddd6b4 100644 --- a/src/client/vendor/ghostty-web/link-detector.ts +++ b/src/client/vendor/ghostty-web/link-detector.ts @@ -6,7 +6,8 @@ * providers (e.g. OSC8) take precedence over later ones (e.g. regex). */ -import type { IBufferCellPosition, ILink, ILinkProvider } from './types'; +import { cellInRange } from './render/cell-range'; +import type { ILink, ILinkProvider } from './types'; /** * Manages link detection across multiple providers with intelligent caching @@ -115,29 +116,15 @@ export class LinkDetector { } /** - * Check if a position is within a link's range + * Check if a position is within a link's range (the one inclusive test, ADR 061) */ private isPositionInLink(col: number, row: number, link: ILink): boolean { const { start, end } = link.range; - - // Check if row is in range - if (row < start.y || row > end.y) { - return false; - } - - // Single-line link - if (start.y === end.y) { - return col >= start.x && col <= end.x; - } - - // Multi-line link - if (row === start.y) { - return col >= start.x; // First line: from start.x to end of line - } else if (row === end.y) { - return col <= end.x; // Last line: from start of line to end.x - } else { - return true; // Middle line: entire line is part of link - } + return cellInRange( + { startCol: start.x, startRow: start.y, endCol: end.x, endRow: end.y }, + col, + row + ); } /** diff --git a/src/client/vendor/ghostty-web/render/cell-range.ts b/src/client/vendor/ghostty-web/render/cell-range.ts new file mode 100644 index 0000000..7354615 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/cell-range.ts @@ -0,0 +1,40 @@ +// Derived from coder/ghostty-web lib/renderer.ts and lib/link-detector.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The one cell range (object shape + util): a start and end cell, both inclusive, as the +// selection, the hovered link and LinkDetector's hit test use it (ADR 043, ADR 061). + +/** A run of cells from a start cell to an end cell, both inclusive, rows in reading order. */ +export interface CellRange { + startCol: number; + startRow: number; + endCol: number; + endRow: number; +} + +/** + * Whether cell (`col`, `row`) is in `range`: from the start column on the first row, whole + * middle rows, up to the end column on the last row. Called per cell while drawing, so + * it allocates nothing. + */ +export function cellInRange(range: CellRange | null, col: number, row: number): boolean { + if (!range || row < range.startRow || row > range.endRow) return false; + if (range.startRow === range.endRow) return col >= range.startCol && col <= range.endCol; + if (row === range.startRow) return col >= range.startCol; // First line: to its end + if (row === range.endRow) return col <= range.endCol; // Last line: from its start + return true; // Middle line: the whole line +} + +/** `range` moved down by `rows` rows (up when negative); null stays null. */ +export function moveRangeRows(range: CellRange | null, rows: number): CellRange | null { + return range && { ...range, startRow: range.startRow + rows, endRow: range.endRow + rows }; +} + +/** Whether two ranges cover the same cells; two nulls are the same. */ +export function sameRange(a: CellRange | null, b: CellRange | null): boolean { + if (!a || !b) return a === b; + return ( + a.startCol === b.startCol && + a.startRow === b.startRow && + a.endCol === b.endCol && + a.endRow === b.endRow + ); +} diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 332e73a..3324d0f 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -11,6 +11,7 @@ */ import type { ITheme } from './interfaces'; +import { type CellRange, cellInRange, moveRangeRows, sameRange } from './render/cell-range'; import { DEFAULT_THEME } from './render/default-theme'; import { watchDevicePixelRatio, wholeDevicePixelRatio } from './render/device-pixel-ratio'; import { type FontMetrics, measureFont } from './render/font-metrics'; @@ -74,42 +75,6 @@ const ROW_CUT = 2; */ export const REPAINT_CHUNK_ROWS = 8; -/** A selection: start and end cell, rows inclusive. */ -interface CellRange { - startCol: number; - startRow: number; - endCol: number; - endRow: number; -} - -/** A hovered regex link: start and end cell, rows inclusive. */ -interface LinkRange { - startX: number; - startY: number; - endX: number; - endY: number; -} - -/** A selection moved down by `rows` rows (up when negative). */ -function moveRows(range: CellRange | null, rows: number): CellRange | null { - return range && { ...range, startRow: range.startRow + rows, endRow: range.endRow + rows }; -} - -/** A link range moved down by `rows` rows (up when negative). */ -function moveRange(range: LinkRange | null, rows: number): LinkRange | null { - return range && { ...range, startY: range.startY + rows, endY: range.endY + rows }; -} - -function sameRange(a: CellRange | null, b: CellRange | null): boolean { - if (!a || !b) return a === b; - return ( - a.startCol === b.startCol && - a.startRow === b.startRow && - a.endCol === b.endCol && - a.endRow === b.endRow - ); -} - // ============================================================================ // CanvasRenderer Class // ============================================================================ @@ -194,12 +159,11 @@ export class CanvasRenderer { // The hovered link's range (OSC 8 or regex), in viewport rows. The engine's // hyperlink_id is a 1-bit "has a link" flag, not an id, so the range is the only // thing that tells one link from another. (webtty) - private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null = - null; + private hoveredLinkRange: CellRange | null = null; // The regex link range this frame draws and the one the canvas rows were drawn with, // in canvas rows (webtty, ADR 043) - private currentLinkRange: LinkRange | null = null; - private drawnLinkRange: LinkRange | null = null; + private currentLinkRange: CellRange | null = null; + private drawnLinkRange: CellRange | null = null; // Search matches (webtty, ADR 055): the matches, the spans this frame draws per canvas // row, and the spans each canvas row was drawn with (as `spansKey`) @@ -450,8 +414,8 @@ export class CanvasRenderer { this.rowState = state; if (this.cursorRow !== null) this.cursorRow += shift; if (this.scrollbarRow !== null) this.scrollbarRow += shift; - this.drawnSelection = moveRows(this.drawnSelection, shift); - this.drawnLinkRange = moveRange(this.drawnLinkRange, shift); + this.drawnSelection = moveRangeRows(this.drawnSelection, shift); + this.drawnLinkRange = moveRangeRows(this.drawnLinkRange, shift); const drawnSearch = new Array<string>(total).fill(''); for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { drawnSearch[k + shift] = this.drawnSearch[k] ?? ''; @@ -491,7 +455,7 @@ export class CanvasRenderer { this.selectionManager && this.selectionManager.hasSelection() ? this.selectionManager.getSelectionRange() : null; - this.currentSelectionCoords = moveRows(selection, top); + this.currentSelectionCoords = moveRangeRows(selection, top); if (!sameRange(this.currentSelectionCoords, this.drawnSelection)) { for (const range of [this.drawnSelection, this.currentSelectionCoords]) { if (!range) continue; @@ -520,11 +484,11 @@ export class CanvasRenderer { // A link underline is drawn at viewport rows, which are other canvas rows after a // scroll: clear it where it was drawn and draw it where it is. - this.currentLinkRange = moveRange(this.hoveredLinkRange, top); - if (JSON.stringify(this.currentLinkRange) !== JSON.stringify(this.drawnLinkRange)) { + this.currentLinkRange = moveRangeRows(this.hoveredLinkRange, top); + if (!sameRange(this.currentLinkRange, this.drawnLinkRange)) { for (const range of [this.drawnLinkRange, this.currentLinkRange]) { if (!range) continue; - for (let k = range.startY; k <= range.endY; k++) changed(k); + for (let k = range.startRow; k <= range.endRow; k++) changed(k); } } this.drawnLinkRange = this.currentLinkRange; @@ -866,7 +830,7 @@ export class CanvasRenderer { const cellWidth = this.metrics.width * cell.width; // Check if this cell is selected - const isSelected = this.isInSelection(x, y); + const isSelected = cellInRange(this.currentSelectionCoords, x, y); if (isSelected) { // Draw selection background (solid color, not overlay) @@ -926,7 +890,7 @@ export class CanvasRenderer { } // Check if this cell is selected - const isSelected = this.isInSelection(x, y); + const isSelected = cellInRange(this.currentSelectionCoords, x, y); // Set text style let fontStyle = ''; @@ -1009,23 +973,14 @@ export class CanvasRenderer { // Draw the hovered link's underline (OSC 8 or plain text URL), in canvas rows // (webtty, ADR 043) - if (this.currentLinkRange) { - const range = this.currentLinkRange; - // Check if this cell is within the hovered link range - const isInRange = - (y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) || - (y > range.startY && y < range.endY) || - (y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX)); - - if (isInRange) { - const underlineY = cellY + this.metrics.baseline + 2; - this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover - this.ctx.lineWidth = 1; - this.ctx.beginPath(); - this.ctx.moveTo(cellX, underlineY); - this.ctx.lineTo(cellX + cellWidth, underlineY); - this.ctx.stroke(); - } + if (cellInRange(this.currentLinkRange, x, y)) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); } } @@ -1264,36 +1219,6 @@ export class CanvasRenderer { this.selectionManager = manager; } - /** - * Check if a cell at (x, y) is within the current selection. - * Uses cached selection coordinates for performance. - */ - private isInSelection(x: number, y: number): boolean { - const sel = this.currentSelectionCoords; - if (!sel) return false; - - const { startCol, startRow, endCol, endRow } = sel; - - // Single line selection - if (startRow === endRow) { - return y === startRow && x >= startCol && x <= endCol; - } - - // Multi-line selection - if (y === startRow) { - // First line: from startCol to end of line - return x >= startCol; - } else if (y === endRow) { - // Last line: from start of line to endCol - return x <= endCol; - } else if (y > startRow && y < endRow) { - // Middle lines: entire line is selected - return true; - } - - return false; - } - /** * The search matches to highlight, or null for none. Rows whose matches change are * repainted on the next frame. (webtty, ADR 055) @@ -1314,7 +1239,12 @@ export class CanvasRenderer { endY: number; } | null ): void { - this.hoveredLinkRange = range; + this.hoveredLinkRange = range && { + startCol: range.startX, + startRow: range.startY, + endCol: range.endX, + endRow: range.endY, + }; } /** diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index dd88cab..89347fa 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -15,6 +15,7 @@ import { EventEmitter } from './event-emitter'; import type { GhosttyTerminal } from './ghostty'; import type { IEvent } from './interfaces'; import { pressToApp } from './mouse-route'; +import type { CellRange } from './render/cell-range'; import type { CanvasRenderer } from './renderer'; import type { Terminal } from './terminal'; @@ -22,12 +23,8 @@ import type { Terminal } from './terminal'; // Type Definitions // ============================================================================ -export interface SelectionCoordinates { - startCol: number; - startRow: number; - endCol: number; - endRow: number; -} +/** A selection's start and end cell, rows inclusive: the one cell range shape (ADR 061). */ +export type SelectionCoordinates = CellRange; // ============================================================================ // SelectionManager Class diff --git a/src/client/vendor/tests/link-detector.test.ts b/src/client/vendor/tests/link-detector.test.ts new file mode 100644 index 0000000..4117f02 --- /dev/null +++ b/src/client/vendor/tests/link-detector.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { LinkDetector } from '../ghostty-web/link-detector'; +import type { ILink, ILinkProvider } from '../ghostty-web/link-types'; + +// LinkDetector on a fake buffer: the inclusive range hit test, provider precedence, the +// row scan cache and dispose. + +/** A buffer of `rows` rows of `cols` cells. */ +function fakeTerminal(cols: number, rows: number) { + const cell = { getHyperlinkId: () => 0 }; + return { + buffer: { + active: { + getLine: (y: number) => + y >= 0 && y < rows + ? { length: cols, getCell: (x: number) => (x < cols ? cell : undefined) } + : undefined, + }, + }, + }; +} + +function link(text: string, sx: number, sy: number, ex: number, ey: number): ILink { + return { text, range: { start: { x: sx, y: sy }, end: { x: ex, y: ey } }, activate: () => {} }; +} + +/** A provider returning `links` for the rows they start on, counting its calls. */ +function provider(links: ILink[]) { + const calls: number[] = []; + let disposed = 0; + const p: ILinkProvider = { + provideLinks: (y, callback) => { + calls.push(y); + const found = links.filter((l) => l.range.start.y === y); + callback(found.length ? found : undefined); + }, + dispose: () => { + disposed++; + }, + }; + return { p, calls, disposed: () => disposed }; +} + +describe('LinkDetector', () => { + test('finds a link spanning rows by the inclusive range', async () => { + const detector = new LinkDetector(fakeTerminal(10, 4)); + const wrapped = link('https://x', 7, 1, 2, 2); + detector.registerProvider(provider([wrapped]).p); + expect(await detector.getLinkAt(6, 1)).toBeUndefined(); + expect(await detector.getLinkAt(7, 1)).toBe(wrapped); + expect(await detector.getLinkAt(9, 1)).toBe(wrapped); + // Row 2 has not been scanned: the cache from row 1 answers + expect(await detector.getLinkAt(2, 2)).toBe(wrapped); + expect(await detector.getLinkAt(3, 2)).toBeUndefined(); + }); + + test('scans a row once until the cache is invalidated', async () => { + const detector = new LinkDetector(fakeTerminal(10, 4)); + const { p, calls } = provider([]); + detector.registerProvider(p); + await detector.getLinkAt(0, 0); + await detector.getLinkAt(1, 0); + expect(calls).toEqual([0]); + detector.invalidateCache(); + await detector.getLinkAt(1, 0); + expect(calls).toEqual([0, 0]); + }); + + test('an earlier provider wins the same range', async () => { + const detector = new LinkDetector(fakeTerminal(10, 2)); + const first = link('osc8', 0, 0, 3, 0); + detector.registerProvider(provider([first]).p); + detector.registerProvider(provider([link('regex', 0, 0, 3, 0)]).p); + expect(await detector.getLinkAt(2, 0)).toBe(first); + }); + + test('returns nothing outside the buffer', async () => { + const detector = new LinkDetector(fakeTerminal(10, 2)); + const { p, calls } = provider([link('x', 0, 0, 9, 0)]); + detector.registerProvider(p); + expect(await detector.getLinkAt(-1, 0)).toBeUndefined(); + expect(await detector.getLinkAt(10, 0)).toBeUndefined(); + expect(await detector.getLinkAt(0, 5)).toBeUndefined(); + expect(calls).toEqual([]); + }); + + test('dispose disposes the providers and forgets them', async () => { + const detector = new LinkDetector(fakeTerminal(10, 2)); + const a = provider([link('x', 0, 0, 9, 0)]); + detector.registerProvider(a.p); + expect(await detector.getLinkAt(0, 0)).toBeDefined(); + detector.dispose(); + expect(a.disposed()).toBe(1); + expect(await detector.getLinkAt(0, 0)).toBeUndefined(); + detector.dispose(); + expect(a.disposed()).toBe(1); + }); +}); diff --git a/src/client/vendor/tests/render/cell-range.test.ts b/src/client/vendor/tests/render/cell-range.test.ts new file mode 100644 index 0000000..2e3e2f8 --- /dev/null +++ b/src/client/vendor/tests/render/cell-range.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { + type CellRange, + cellInRange, + moveRangeRows, + sameRange, +} from '../../ghostty-web/render/cell-range'; + +// The one inclusive cell range: hit test, move and compare. + +const range = (startCol: number, startRow: number, endCol: number, endRow: number): CellRange => ({ + startCol, + startRow, + endCol, + endRow, +}); + +describe('cellInRange', () => { + const oneRow = range(2, 1, 5, 1); + const rows = range(6, 1, 3, 3); + test.each([ + ['no range', null, 0, 0, false], + ['one row: before the start', oneRow, 1, 1, false], + ['one row: the start', oneRow, 2, 1, true], + ['one row: the end', oneRow, 5, 1, true], + ['one row: past the end', oneRow, 6, 1, false], + ['one row: another row', oneRow, 3, 2, false], + ['rows: above', rows, 7, 0, false], + ['rows: first row before the start', rows, 5, 1, false], + ['rows: first row from the start', rows, 6, 1, true], + ['rows: first row to its end', rows, 79, 1, true], + ['rows: a middle row, first column', rows, 0, 2, true], + ['rows: a middle row, last column', rows, 79, 2, true], + ['rows: last row from its start', rows, 0, 3, true], + ['rows: last row, the end', rows, 3, 3, true], + ['rows: last row past the end', rows, 4, 3, false], + ['rows: below', rows, 0, 4, false], + ] as const)('%s', (_name, r, col, row, inside) => { + expect(cellInRange(r, col, row)).toBe(inside); + }); +}); + +describe('moveRangeRows', () => { + test.each([ + [range(1, 2, 3, 4), 5, range(1, 7, 3, 9)], + [range(1, 2, 3, 4), -2, range(1, 0, 3, 2)], + [range(1, 2, 3, 4), 0, range(1, 2, 3, 4)], + [null, 3, null], + ])('%p moved %p rows', (r, rows, moved) => { + expect(moveRangeRows(r, rows)).toEqual(moved); + }); + + test('returns a new range, leaving the old one', () => { + const r = range(1, 2, 3, 4); + moveRangeRows(r, 1); + expect(r).toEqual(range(1, 2, 3, 4)); + }); +}); + +describe('sameRange', () => { + test.each([ + ['two nulls', null, null, true], + ['a range and null', range(0, 0, 0, 0), null, false], + ['null and a range', null, range(0, 0, 0, 0), false], + ['equal ranges', range(1, 2, 3, 4), range(1, 2, 3, 4), true], + ['another start column', range(1, 2, 3, 4), range(0, 2, 3, 4), false], + ['another start row', range(1, 2, 3, 4), range(1, 1, 3, 4), false], + ['another end column', range(1, 2, 3, 4), range(1, 2, 2, 4), false], + ['another end row', range(1, 2, 3, 4), range(1, 2, 3, 5), false], + ])('%s', (_name, a, b, same) => { + expect(sameRange(a, b)).toBe(same); + }); +}); From 763aa3723c731e325d4958f10af0af27ef815d6c Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:31:16 -0400 Subject: [PATCH 241/295] refactor(client): render split, part 1: band planning and scrollbar geometry The overscan band plan and the canvas row states leave render() for render/row-band.ts (ADR 043, ADR 061): bandFits and nextBand (keep the band while the rows in view are among it, else move it half the overscan ahead), shiftedRowStates and shiftedRowKeys, invalidateRow and changeRow (were per-frame closures), invalidateOutOfView, and wantedRowState with repaintRunEnd (the repaint run plan). DEFAULT_OVERSCAN and the ROW_* states move with them; renderer.ts re-exports DEFAULT_OVERSCAN. render/scrollbar-geometry.ts holds the scrollbar's column, track and thumb maths that renderScrollbar and the terminal's handleMouseDown and processScrollbarDrag each wrote out (8 px wide, 4 px margin and padding, a 20 px minimum thumb). The expressions are unchanged, so the thumb is painted and hit at the same pixels. The render loop keeps its order and makes the same calls; the closures it built per frame are now direct calls. New table-driven tests for both modules, plus a seeded random scroll and output simulation that plans every frame with row-band over a model of the canvas pixels and checks each row in view against a full redraw (it fails if changeRow stops invalidating the joined row above). The raster tests are unchanged and green. The ratchet entries for renderer.ts (1267), terminal.ts (2139) and renderer.test.ts (923) are lowered to their new sizes. --- scripts/architecture/ratchet-allowlist.ts | 6 +- src/client/vendor/ghostty-web/PROVENANCE.md | 8 +- .../vendor/ghostty-web/render/row-band.ts | 111 ++++++++ .../ghostty-web/render/scrollbar-geometry.ts | 52 ++++ src/client/vendor/ghostty-web/renderer.ts | 132 ++++----- src/client/vendor/ghostty-web/terminal.ts | 42 ++- .../vendor/tests/render/row-band.test.ts | 254 ++++++++++++++++++ .../tests/render/scrollbar-geometry.test.ts | 55 ++++ 8 files changed, 552 insertions(+), 108 deletions(-) create mode 100644 src/client/vendor/ghostty-web/render/row-band.ts create mode 100644 src/client/vendor/ghostty-web/render/scrollbar-geometry.ts create mode 100644 src/client/vendor/tests/render/row-band.test.ts create mode 100644 src/client/vendor/tests/render/scrollbar-geometry.test.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 7fa2734..54fc4e6 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -21,7 +21,7 @@ export const RATCHET_ALLOWLIST: Allowlist = { step: 'vendor input split into input/ (ADR 061)', }, 'src/client/vendor/ghostty-web/renderer.ts': { - lines: 1508, + lines: 1267, step: 'vendor renderer split into render/ (ADR 061)', }, 'src/client/vendor/ghostty-web/selection-manager.ts': { @@ -29,10 +29,10 @@ export const RATCHET_ALLOWLIST: Allowlist = { step: 'vendor selection split into selection/ (ADR 061)', }, 'src/client/vendor/ghostty-web/terminal.ts': { - lines: 2147, + lines: 2139, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/renderer.test.ts': { lines: 1087, step: VENDOR_TESTS }, + 'src/client/vendor/tests/renderer.test.ts': { lines: 923, step: VENDOR_TESTS }, 'src/client/vendor/tests/terminal.test.ts': { lines: 1159, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index fc968b6..740c3d3 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -24,7 +24,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `renderer.ts`: the terminal side of the official binding (ADR 042, ADR 047). `open()`, `resize()`, `reset()` and the new `syncCellPixelSize()` pass the device-pixel cell size (`deviceCellPixels`) to the engine; replies are drained after `write`, after a resize or pixel-size change and after each frame's render; bell and title come from `readEffects` instead of upstream's stream scan (`checkForTitleChange` is removed); `parseColorToHex`, which returned 0 for an unset colour and for `#000000` alike, is replaced by `parseColor`, which returns undefined for unset or invalid colours; the `scrollback` option is removed; `render()` calls the buffer's optional `setViewportOffset(viewportY)` first. - `renderer.ts`, `terminal.ts`: a grapheme cell is drawn from the row the line came from. `renderCellText` looked graphemes up with `getGraphemeString(canvasRow, col)`, which reads a screen row, so while scrolled a scrollback grapheme was drawn as whatever sat at that column of the screen row (a stray letter, or `\u0000` as a missing-glyph box on the Coder wasm). `renderLine` now takes a grapheme lookup: scrollback rows use the new `IScrollbackProvider.getScrollbackGraphemeString`, scrolled screen rows use their screen row. - `renderer.ts`, `terminal.ts`, `ghostty.ts`: `render()` repaints only the canvas rows that changed, exactly as a full redraw leaves them (`repaintRows`: a run of rows plus the row below, drawn unclipped on a scratch canvas, then the run's rows copied in, in chunks of at most `REPAINT_CHUNK_ROWS` rows so the scratch canvas stays a few rows high), instead of every row on a viewport move and on every frame while scrolled. A whole-line viewport move with nothing else changed copies the rows still in view with one `drawImage` of the canvas onto itself (`shiftRows`) and draws only the rows that came into view (ADR 043). The cursor row is repainted only when the drawn cursor changes, and the cursor is not drawn again over an unchanged one (a translucent cursor colour would build up), a `contextrestored` canvas is resized and repainted in full, a frame that drops the scrollbar repaints every row, theme, font and ratio changes repaint every row, and resize and font changes render with the current scrollbar opacity. The binding reports its real dirty state while scrolled. -- `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. +- `renderer.ts`, `terminal.ts`, `selection-manager.ts`: overscan for pixel-smooth scroll (ADR 043 step 2). The renderer draws on a canvas of its own, inserted before the terminal's canvas element, absolutely positioned at its static position and ignoring the mouse; the element keeps the layout box and the mouse events and draws nothing. That canvas holds `overscan` rows (`RendererOptions.overscan`, default `DEFAULT_OVERSCAN`) above and below the viewport; `render()` moves it up with `translateY` and clips it with `clip-path` to the element's box, at the offset set by the new `setScrollOffset`. Each canvas row has a state (invalid, joined with the next line, or cut; the states and the band plan are now in `render/row-band.ts`), rows out of view are drawn when they come into view, and a viewport move past the overscan copies the rows with `shiftRows`. The last row in view and the row above the viewport are drawn without what the next line draws into them, as a full redraw of the viewport leaves its last row. Selection and regex link ranges are kept in canvas rows (the new `SelectionManager.getSelectionRange` is not clamped to the viewport), and the scrollbar repaints only the rows it no longer covers. `scrollOffset` and `visibleArea` are new getters; mouse hit testing in `terminal.ts` and `selection-manager.ts` subtracts `scrollOffset`. `resize()` in `terminal.ts` no longer sizes the element's backing store. - `renderer.ts`, `terminal.ts`: a hovered OSC 8 link is underlined over the range its provider found, as a regex link is, and only the rows that range covers are repainted. Upstream underlined every cell whose `hyperlink_id` matched the hovered cell's, but the official engine's `hyperlink_id` is a 1-bit "has a link" flag, so every OSC 8 link on screen was underlined and every row holding one was repainted on each hover change. `setHoveredHyperlinkId` and the renderer's hovered id are removed. - `input-handler.ts`: keys are encoded by the official `KeyEncoder` except Shift+Enter and Shift+Escape, which send CR and ESC as xterm does instead of Ghostty's modifyOtherKeys `CSI 27;2;…~` (`plainKeySequence`). Upstream's fixed table for Enter, Tab, Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, is removed: it ignored application cursor mode (Home and End sent `CSI H/F` instead of `ESC O H/F` under DECCKM) and dropped Shift (Shift+Home sent `CSI H` instead of `CSI 1;2 H`). - `input-handler.ts`: the paste path is removed (the `paste` listeners, `handlePaste`, the `insertFromPaste` case of `handleBeforeInput`, `emitPasteData` and the paste de-duplication). It never ran: webtty's capture listener on the container takes every paste event and stops it (ADR 037), and a cancelled paste fires no `insertFromPaste`. @@ -55,7 +55,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. -- `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042): renderer helpers webtty added, moved out of `renderer.ts` (ADR 061). +- `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts` (ADR 043): renderer helpers webtty added, moved out of `renderer.ts` (ADR 061). - `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043, ADR 061): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout @@ -63,7 +63,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). -- `render/`: the canvas renderer's parts: what it reads (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row reader (`frame-lines.ts`), the cell size (`font-metrics.ts`) and the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`). `renderer.ts` re-exports what moved. +- `render/`: the canvas renderer's parts: what it reads (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row reader (`frame-lines.ts`), the cell size (`font-metrics.ts`) the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -109,3 +109,5 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `renderer.ts`: `FontMetrics`, the measure of `measureFont` (now `fontMetricsFrom` and `measureFont`) | `render/font-metrics.ts` | refactor(client): render split, part 1: contracts, theme, DPR, frame lines, font metrics | | `renderer.ts`: the `CellRange` and `LinkRange` interfaces (now one `CellRange`), `moveRows` and `moveRange` (now `moveRangeRows`), `sameRange`, the `isInSelection` test and the hovered-link test of `renderCellText` (now `cellInRange`) | `render/cell-range.ts` | refactor(client): render split, part 1: one cell range for the renderer, selection and links | | `link-detector.ts`: the range test of `isPositionInLink` (now `cellInRange`); `selection-manager.ts`: `SelectionCoordinates`, now an alias of `CellRange` | `render/cell-range.ts` | refactor(client): render split, part 1: one cell range for the renderer, selection and links | +| `renderer.ts`: `DEFAULT_OVERSCAN`, `ROW_INVALID`, `ROW_JOINED`, `ROW_CUT`; from `render()`: the band fit test and move (now `bandFits`, `nextBand`), the row state and search key shifts (now `shiftedRowStates`, `shiftedRowKeys`), the `invalidate` and `changed` closures (now `invalidateRow`, `changeRow`), the out-of-view invalidation (now `invalidateOutOfView`) and the repaint run plan (now `wantedRowState`, `repaintRunEnd`) (webtty) | `render/row-band.ts` | refactor(client): render split, part 1: band planning and scrollbar geometry | +| `renderer.ts`: the dimensions and thumb maths of `renderScrollbar`; `terminal.ts`: the same, written out again in `handleMouseDown` and `processScrollbarDrag` (now `scrollbarX`, `inScrollbarColumn`, `scrollbarTrackHeight`, `scrollbarThumbHeight`, `scrollbarThumbY` and the `SCROLLBAR_*` constants) | `render/scrollbar-geometry.ts` | refactor(client): render split, part 1: band planning and scrollbar geometry | diff --git a/src/client/vendor/ghostty-web/render/row-band.ts b/src/client/vendor/ghostty-web/render/row-band.ts new file mode 100644 index 0000000..86c56ff --- /dev/null +++ b/src/client/vendor/ghostty-web/render/row-band.ts @@ -0,0 +1,111 @@ +// webtty (ADR 043, ADR 061), not from upstream. +// The overscan band and the canvas row states (util): which lines the canvas rows hold, +// whether each row's pixels are as a full redraw leaves them, and which runs to repaint. +// Called once per frame and per changed row; nothing here allocates except the shifts. + +/** + * Rows drawn above and below the viewport, so pixel-smooth scrolling moves the canvas + * across lines without drawing (webtty, ADR 043). At least 1: the row above the + * viewport shows while it is at a fraction of a line. + */ +export const DEFAULT_OVERSCAN = 16; + +/** A canvas row whose pixels must be drawn before it is shown. */ +export const ROW_INVALID = 0; +/** A row with its own cells plus what the next line draws into it, as a full redraw leaves a row. */ +export const ROW_JOINED = 1; +/** A row with its own cells only, as a full redraw leaves the last row. */ +export const ROW_CUT = 2; + +/** + * Whether canvas rows drawn for viewportY `band` still hold every row in view at + * viewportY `view`, including the row `above` (1 at a fraction of a line, else 0). + * Canvas row k shows viewport row k - overscan at viewportY `band`. + */ +export function bandFits(band: number, view: number, overscan: number, above: number): boolean { + return band - view <= overscan && overscan + band - view - above >= 0; +} + +/** + * The band the next frame draws for. The canvas rows are kept while the rows in view are + * among them. Otherwise they are drawn for a viewportY half the overscan further in the + * direction of the move, so the next moves that way stay within them, clamped to the + * scrollback, or for `view` itself when that does not fit either. + */ +export function nextBand( + band: number, + view: number, + overscan: number, + above: number, + scrollbackLength: number +): number { + if (bandFits(band, view, overscan, above)) return band; + const ahead = Math.sign(view - band) * Math.floor(overscan / 2); + const next = Math.max(0, Math.min(Math.max(scrollbackLength, view), view + ahead)); + return bandFits(next, view, overscan, above) ? next : view; +} + +/** Row states after the canvas content moved down by `shift` rows; rows that came in are invalid. */ +export function shiftedRowStates(state: Uint8Array, shift: number): Uint8Array { + const total = state.length; + const shifted = new Uint8Array(total); + for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { + shifted[k + shift] = state[k]; + } + return shifted; +} + +/** Per-row keys (`total` of them) after the same move; rows that came in get ''. */ +export function shiftedRowKeys(keys: string[], shift: number, total: number): string[] { + const shifted = new Array<string>(total).fill(''); + for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { + shifted[k + shift] = keys[k] ?? ''; + } + return shifted; +} + +/** Marks row `k` invalid: its own pixels changed (an overlay over it). Out of range is ignored. */ +export function invalidateRow(state: Uint8Array, k: number): void { + if (k >= 0 && k < state.length) state[k] = ROW_INVALID; +} + +/** + * Marks row `k` changed: its content or highlight changed, so it is invalid, and so is + * the row above when it holds row k's overflow. + */ +export function changeRow(state: Uint8Array, k: number): void { + invalidateRow(state, k); + if (k > 0 && k <= state.length && state[k - 1] === ROW_JOINED) state[k - 1] = ROW_INVALID; +} + +/** Marks every row outside the `rows` rows from `top` invalid: the lines moved under them. */ +export function invalidateOutOfView(state: Uint8Array, top: number, rows: number): void { + for (let k = 0; k < state.length; k++) { + if (k < top || k >= top + rows) state[k] = ROW_INVALID; + } +} + +/** + * The state a full redraw of the viewport leaves canvas row `k` in, the viewport being + * rows `top` to `last`: its last row and the row above it have nothing drawn into them + * from below. + */ +export function wantedRowState(k: number, top: number, last: number): number { + return k === last || k < top ? ROW_CUT : ROW_JOINED; +} + +/** + * The last row of the run that repaints row `k` (which is not as wanted): a joined row + * is repainted with the rows below it that are not as wanted either, up to `last`. + */ +export function repaintRunEnd(state: Uint8Array, k: number, top: number, last: number): number { + let end = k; + while ( + end < last && + wantedRowState(end, top, last) === ROW_JOINED && + state[end + 1] !== wantedRowState(end + 1, top, last) + ) { + end++; + } + return end; +} diff --git a/src/client/vendor/ghostty-web/render/scrollbar-geometry.ts b/src/client/vendor/ghostty-web/render/scrollbar-geometry.ts new file mode 100644 index 0000000..605d8ab --- /dev/null +++ b/src/client/vendor/ghostty-web/render/scrollbar-geometry.ts @@ -0,0 +1,52 @@ +// Derived from coder/ghostty-web lib/renderer.ts and lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The scrollbar's geometry (util): its column, track and thumb in CSS pixels, so the +// painter and the terminal's mouse hit test agree (ADR 061). + +/** The track and thumb width. */ +export const SCROLLBAR_WIDTH = 8; +/** The gap between the track and the right edge. */ +export const SCROLLBAR_MARGIN = 4; +/** The track's inset from the top and bottom of the rows in view. */ +export const SCROLLBAR_PADDING = 4; +/** The thumb's height when the scrollback is long. */ +export const SCROLLBAR_MIN_THUMB = 20; + +/** The track's left edge on a canvas `width` wide. */ +export function scrollbarX(width: number): number { + return width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN; +} + +/** Whether `x` is in the scrollbar's column on a canvas `width` wide, edges included. */ +export function inScrollbarColumn(x: number, width: number): boolean { + const left = scrollbarX(width); + return x >= left && x <= left + SCROLLBAR_WIDTH; +} + +/** The track's height over rows in view `height` high. */ +export function scrollbarTrackHeight(height: number): number { + return height - SCROLLBAR_PADDING * 2; +} + +/** The thumb's height: the share of all lines in view, at least SCROLLBAR_MIN_THUMB. */ +export function scrollbarThumbHeight( + visibleRows: number, + scrollbackLength: number, + trackHeight: number +): number { + const totalLines = scrollbackLength + visibleRows; + return Math.max(SCROLLBAR_MIN_THUMB, (visibleRows / totalLines) * trackHeight); +} + +/** + * The thumb's top, from the top of the rows in view: at the bottom of the track at + * viewportY 0, at its top at viewportY `scrollbackLength`. + */ +export function scrollbarThumbY( + viewportY: number, + scrollbackLength: number, + trackHeight: number, + thumbHeight: number +): number { + const scrollPosition = viewportY / scrollbackLength; // 0 to 1 + return SCROLLBAR_PADDING + (trackHeight - thumbHeight) * (1 - scrollPosition); +} diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 3324d0f..b5869c9 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -22,6 +22,28 @@ import type { IScrollbackProvider, RowContent, } from './render/renderable'; +import { + changeRow, + DEFAULT_OVERSCAN, + invalidateOutOfView, + invalidateRow, + nextBand, + ROW_CUT, + ROW_INVALID, + ROW_JOINED, + repaintRunEnd, + shiftedRowKeys, + shiftedRowStates, + wantedRowState, +} from './render/row-band'; +import { + SCROLLBAR_PADDING, + SCROLLBAR_WIDTH, + scrollbarThumbHeight, + scrollbarThumbY, + scrollbarTrackHeight, + scrollbarX, +} from './render/scrollbar-geometry'; import { type HighlightSpan, highlightAt, @@ -39,6 +61,7 @@ export { watchDevicePixelRatio, wholeDevicePixelRatio } from './render/device-pi export type { FontMetrics } from './render/font-metrics'; export { frameLines } from './render/frame-lines'; export type { GraphemeLookup, IRenderable, IScrollbackProvider } from './render/renderable'; +export { DEFAULT_OVERSCAN } from './render/row-band'; // ============================================================================ // Type Definitions @@ -54,20 +77,6 @@ export interface RendererOptions { overscan?: number; // Rows drawn above and below the viewport. Default: DEFAULT_OVERSCAN (webtty, ADR 043) } -/** - * Rows drawn above and below the viewport, so pixel-smooth scrolling moves the canvas - * across lines without drawing (webtty, ADR 043). At least 1: the row above the - * viewport shows while it is at a fraction of a line. - */ -export const DEFAULT_OVERSCAN = 16; - -// The state of a canvas row's pixels (webtty, ADR 043) -const ROW_INVALID = 0; -// Its own cells plus what the next line draws into it, as a full redraw leaves a row -const ROW_JOINED = 1; -// Its own cells only, as a full redraw leaves the last row -const ROW_CUT = 2; - /** * Rows a partial repaint draws on the scratch canvas at once, besides the row below: a * longer run is repainted in chunks of this many rows, so the scratch canvas stays a few @@ -392,37 +401,24 @@ export class CanvasRenderer { const offset = view < scrollbackLength ? Math.min(this.requestedScrollOffset, maxOffset) : 0; const above = offset > 0 ? 1 : 0; - // Keep the canvas rows while the rows in view are among them. Otherwise draw them for - // a viewportY half the overscan further in the direction of the move, so the next - // moves that way stay within them, and copy the rows still in view. - const fits = (band: number) => - band - view <= this.overscan && this.overscan + band - view - above >= 0; + // Keep the canvas rows while the rows in view are among them, else move the band and + // copy the rows still in view (see nextBand) let band = this.bandY; if (forceAll) { band = view; - } else if (!fits(band)) { - const ahead = Math.sign(view - band) * Math.floor(this.overscan / 2); - band = Math.max(0, Math.min(Math.max(scrollbackLength, view), view + ahead)); - if (!fits(band)) band = view; + } else { + band = nextBand(this.bandY, view, this.overscan, above, scrollbackLength); const shift = band - this.bandY; - if (Math.abs(shift) < total) { + if (Math.abs(shift) >= total) { + forceAll = true; + } else if (shift !== 0) { this.shiftRows(shift); - const state = new Uint8Array(total); - for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { - state[k + shift] = this.rowState[k]; - } - this.rowState = state; + this.rowState = shiftedRowStates(this.rowState, shift); if (this.cursorRow !== null) this.cursorRow += shift; if (this.scrollbarRow !== null) this.scrollbarRow += shift; this.drawnSelection = moveRangeRows(this.drawnSelection, shift); this.drawnLinkRange = moveRangeRows(this.drawnLinkRange, shift); - const drawnSearch = new Array<string>(total).fill(''); - for (let k = Math.max(0, -shift); k < Math.min(total, total - shift); k++) { - drawnSearch[k + shift] = this.drawnSearch[k] ?? ''; - } - this.drawnSearch = drawnSearch; - } else { - forceAll = true; + this.drawnSearch = shiftedRowKeys(this.drawnSearch, shift, total); } } this.bandY = band; @@ -431,22 +427,9 @@ export class CanvasRenderer { const state = this.rowState; if (forceAll) state.fill(ROW_INVALID); - // A row whose own pixels changed (an overlay over it) - const invalidate = (k: number) => { - if (k >= 0 && k < total) state[k] = ROW_INVALID; - }; - // A row whose content or highlight changed: the row above may hold its overflow - const changed = (k: number) => { - invalidate(k); - if (k > 0 && k <= total && state[k - 1] === ROW_JOINED) state[k - 1] = ROW_INVALID; - }; if (!forceAll) { - for (const y of dirtyRows) changed(top + y); - if (linesMoved) { - for (let k = 0; k < total; k++) { - if (k < top || k >= top + rows) state[k] = ROW_INVALID; - } - } + for (const y of dirtyRows) changeRow(state, top + y); + if (linesMoved) invalidateOutOfView(state, top, rows); } // The selection, in canvas rows. It is anchored to buffer lines like the canvas rows, @@ -460,7 +443,7 @@ export class CanvasRenderer { for (const range of [this.drawnSelection, this.currentSelectionCoords]) { if (!range) continue; const last = Math.min(range.endRow, total - 1); - for (let k = Math.max(0, range.startRow); k <= last; k++) changed(k); + for (let k = Math.max(0, range.startRow); k <= last; k++) changeRow(state, k); } } this.drawnSelection = this.currentSelectionCoords; @@ -476,7 +459,7 @@ export class CanvasRenderer { for (const [row, s] of spans) this.searchSpans[row - toScreen] = s; } for (let k = 0; k < total; k++) { - if (spansKey(this.searchSpans[k]) !== this.drawnSearch[k]) changed(k); + if (spansKey(this.searchSpans[k]) !== this.drawnSearch[k]) changeRow(state, k); } const rowAt = (k: number) => @@ -488,7 +471,7 @@ export class CanvasRenderer { if (!sameRange(this.currentLinkRange, this.drawnLinkRange)) { for (const range of [this.drawnLinkRange, this.currentLinkRange]) { if (!range) continue; - for (let k = range.startRow; k <= range.endRow; k++) changed(k); + for (let k = range.startRow; k <= range.endRow; k++) changeRow(state, k); } } this.drawnLinkRange = this.currentLinkRange; @@ -504,7 +487,7 @@ export class CanvasRenderer { this.cursorRow === cursorRow && cursor.x === this.cursorCol && !this.cursorShapeChanged; - if (!sameCursor) invalidate(this.cursorRow); + if (!sameCursor) invalidateRow(state, this.cursorRow); } this.cursorShapeChanged = false; @@ -514,7 +497,9 @@ export class CanvasRenderer { const scrollbarRow = paintsScrollbar ? top : null; if (this.scrollbarRow !== null && this.scrollbarRow !== scrollbarRow) { for (let k = this.scrollbarRow; k < this.scrollbarRow + rows; k++) { - if (scrollbarRow === null || k < scrollbarRow || k >= scrollbarRow + rows) invalidate(k); + if (scrollbarRow === null || k < scrollbarRow || k >= scrollbarRow + rows) { + invalidateRow(state, k); + } } } @@ -522,13 +507,11 @@ export class CanvasRenderer { // the last row and the row above the viewport have nothing drawn into them from below. const first = top - above; const last = top + rows - 1; - const wanted = (k: number) => (k === last || k < top ? ROW_CUT : ROW_JOINED); const repainted = new Uint8Array(total); for (let k = first; k <= last; k++) { - if (state[k] === wanted(k)) continue; - let end = k; - while (end < last && wanted(end) === ROW_JOINED && state[end + 1] !== wanted(end + 1)) end++; - this.repaintRows(k, end, wanted(end) === ROW_CUT, dims.cols, rowAt); + if (state[k] === wantedRowState(k, top, last)) continue; + const end = repaintRunEnd(state, k, top, last); + this.repaintRows(k, end, wantedRowState(end, top, last) === ROW_CUT, dims.cols, rowAt); repainted.fill(1, k, end + 1); k = end; } @@ -1125,38 +1108,33 @@ export class CanvasRenderer { const canvasHeight = visibleRows * this.metrics.height; const canvasWidth = this.canvas.width / this.devicePixelRatio; - // Scrollbar dimensions - const scrollbarWidth = 8; - const scrollbarX = canvasWidth - scrollbarWidth - 4; - const scrollbarPadding = 4; - const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; + // Scrollbar dimensions (shared with the terminal's hit test) + const x = scrollbarX(canvasWidth); + const trackHeight = scrollbarTrackHeight(canvasHeight); // Always clear the scrollbar area first (fixes ghosting when fading out) - ctx.clearRect(scrollbarX - 2, top, scrollbarWidth + 6, canvasHeight); + ctx.clearRect(x - 2, top, SCROLLBAR_WIDTH + 6, canvasHeight); ctx.fillStyle = this.theme.background; - ctx.fillRect(scrollbarX - 2, top, scrollbarWidth + 6, canvasHeight); + ctx.fillRect(x - 2, top, SCROLLBAR_WIDTH + 6, canvasHeight); // Don't draw scrollbar if fully transparent or no scrollback if (opacity <= 0 || scrollbackLength === 0) return; - // Calculate scrollbar thumb size and position - const totalLines = scrollbackLength + visibleRows; - const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); - - // Position: 0 = at bottom, scrollbackLength = at top - const scrollPosition = viewportY / scrollbackLength; // 0 to 1 - const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); + // Calculate scrollbar thumb size and position (0 = at bottom, scrollbackLength = at top) + const thumbHeight = scrollbarThumbHeight(visibleRows, scrollbackLength, trackHeight); + const thumbY = scrollbarThumbY(viewportY, scrollbackLength, trackHeight, thumbHeight); // Draw scrollbar track (subtle background) with opacity ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; - ctx.fillRect(scrollbarX, top + scrollbarPadding, scrollbarWidth, scrollbarTrackHeight); + ctx.fillRect(x, top + SCROLLBAR_PADDING, SCROLLBAR_WIDTH, trackHeight); // Draw scrollbar thumb with opacity const isScrolled = viewportY > 0; const baseOpacity = isScrolled ? 0.5 : 0.3; ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; - ctx.fillRect(scrollbarX, top + thumbY, scrollbarWidth, thumbHeight); + ctx.fillRect(x, top + thumbY, SCROLLBAR_WIDTH, thumbHeight); } + public getMetrics(): FontMetrics { return { ...this.metrics }; } diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index fa1db63..a3218c3 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -45,6 +45,13 @@ import { MenuSource, rightClickAction } from './mouse-route'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; +import { + inScrollbarColumn, + SCROLLBAR_PADDING, + scrollbarThumbHeight, + scrollbarThumbY, + scrollbarTrackHeight, +} from './render/scrollbar-geometry'; import { type FindAnchor, keptViewportY, viewportYToShow } from './search'; import { SelectionManager } from './selection-manager'; import type { ILink } from './types'; @@ -1891,28 +1898,17 @@ export class Terminal implements ITerminalCore { // The scrollbar is painted over the rows in view (webtty, ADR 043) const mouseY = e.clientY - rect.top - this.renderer.scrollOffset; - // Calculate scrollbar dimensions (match renderer's logic) - // Use rect dimensions which are already in CSS pixels - const canvasWidth = rect.width; - const canvasHeight = rect.height; - const scrollbarWidth = 8; - const scrollbarX = canvasWidth - scrollbarWidth - 4; - const scrollbarPadding = 4; - - // Check if click is in scrollbar area - if (mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth) { + // Check if click is in scrollbar area: the renderer's geometry, in the rect's CSS pixels + if (inScrollbarColumn(mouseX, rect.width)) { // Prevent default and stop propagation to prevent text selection e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event // Calculate scrollbar thumb position and size - const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; - const visibleRows = this.rows; - const totalLines = scrollbackLength + visibleRows; - const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); - const scrollPosition = this.viewportY / scrollbackLength; - const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); + const trackHeight = scrollbarTrackHeight(rect.height); + const thumbHeight = scrollbarThumbHeight(this.rows, scrollbackLength, trackHeight); + const thumbY = scrollbarThumbY(this.viewportY, scrollbackLength, trackHeight, thumbHeight); // Check if click is on thumb if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) { @@ -1928,8 +1924,8 @@ export class Terminal implements ITerminalCore { } } else { // Click on track - jump to position - const relativeY = mouseY - scrollbarPadding; - const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0 + const relativeY = mouseY - SCROLLBAR_PADDING; + const scrollFraction = 1 - relativeY / trackHeight; // Inverted: top = 1, bottom = 0 const targetViewportY = Math.round(scrollFraction * scrollbackLength); this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY))); } @@ -1975,16 +1971,12 @@ export class Terminal implements ITerminalCore { // Convert mouse delta to viewport delta // Use rect height which is already in CSS pixels - const canvasHeight = rect.height; - const scrollbarPadding = 4; - const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; - const visibleRows = this.rows; - const totalLines = scrollbackLength + visibleRows; - const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); + const trackHeight = scrollbarTrackHeight(rect.height); + const thumbHeight = scrollbarThumbHeight(this.rows, scrollbackLength, trackHeight); // Calculate scroll fraction from thumb movement // Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases) - const scrollFraction = -deltaY / (scrollbarTrackHeight - thumbHeight); + const scrollFraction = -deltaY / (trackHeight - thumbHeight); const viewportDelta = Math.round(scrollFraction * scrollbackLength); const newViewportY = this.scrollbarDragStartViewportY + viewportDelta; diff --git a/src/client/vendor/tests/render/row-band.test.ts b/src/client/vendor/tests/render/row-band.test.ts new file mode 100644 index 0000000..38222bf --- /dev/null +++ b/src/client/vendor/tests/render/row-band.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from 'bun:test'; +import { + bandFits, + changeRow, + invalidateOutOfView, + invalidateRow, + nextBand, + ROW_CUT, + ROW_INVALID, + ROW_JOINED, + repaintRunEnd, + shiftedRowKeys, + shiftedRowStates, + wantedRowState, +} from '../../ghostty-web/render/row-band'; + +// The overscan band plan and the canvas row states, table-driven, then checked against a +// full redraw over random scroll and output sequences. + +const I = ROW_INVALID; +const J = ROW_JOINED; +const C = ROW_CUT; + +describe('bandFits', () => { + test.each([ + ['the band at the view', 5, 5, 4, 0, true], + ['the view a whole overscan below the band', 9, 5, 4, 0, true], + ['the view past the overscan below', 10, 5, 4, 0, false], + ['the view a whole overscan above the band', 1, 5, 4, 0, true], + ['the view an overscan above, with the row above shown', 1, 5, 4, 1, false], + ['the view past the overscan above', 0, 5, 4, 0, false], + ])('%s', (_name, band, view, overscan, above, fits) => { + expect(bandFits(band, view, overscan, above)).toBe(fits); + }); +}); + +describe('nextBand', () => { + test.each([ + ['kept while it fits', 10, 12, 4, 0, 100, 10], + ['moved half the overscan ahead, scrolling up', 10, 20, 4, 0, 100, 22], + ['moved half the overscan ahead, scrolling down', 20, 10, 4, 0, 100, 8], + ['clamped to the scrollback', 80, 99, 4, 0, 100, 100], + ['clamped to 0', 20, 1, 4, 0, 100, 0], + ['past the scrollback the view itself is the limit', 0, 30, 4, 0, 10, 30], + ['the view itself when ahead does not fit', 0, 5, 1, 1, 100, 5], + ])('%s', (_name, band, view, overscan, above, scrollback, next) => { + expect(nextBand(band, view, overscan, above, scrollback)).toBe(next); + expect(bandFits(next, view, overscan, above)).toBe(true); + }); +}); + +describe('shifting rows', () => { + test.each([ + [2, [I, I, J, C, J]], + [-2, [J, C, J, I, I]], + [0, [J, C, J, C, J]], + [5, [I, I, I, I, I]], + [-7, [I, I, I, I, I]], + ])('row states moved %p rows', (shift, shifted) => { + expect([...shiftedRowStates(Uint8Array.from([J, C, J, C, J]), shift)]).toEqual(shifted); + }); + + test.each([ + [1, 4, ['', 'a', 'b', 'c']], + [-1, 4, ['b', 'c', '', '']], + [1, 2, ['', 'a']], + ])('keys moved %p rows into %p rows', (shift, total, shifted) => { + expect(shiftedRowKeys(['a', 'b', 'c'], shift, total)).toEqual(shifted); + }); +}); + +describe('marking rows', () => { + test.each([ + ['a row', 2, [J, J, I, J]], + ['before the canvas', -1, [J, J, J, J]], + ['past the canvas', 4, [J, J, J, J]], + ])('invalidateRow: %s', (_name, k, after) => { + const state = Uint8Array.from([J, J, J, J]); + invalidateRow(state, k); + expect([...state]).toEqual(after); + }); + + test.each([ + ['a row and the joined row above', [J, J, J, J], 2, [J, I, I, J]], + ['a row under a cut row', [J, C, J, J], 2, [J, C, I, J]], + ['the first row', [J, J, J, J], 0, [I, J, J, J]], + [ + 'the row just past the canvas: the last row holds its overflow', + [J, J, J, J], + 4, + [J, J, J, I], + ], + ['far past the canvas', [J, J, J, J], 6, [J, J, J, J]], + ])('changeRow: %s', (_name, before, k, after) => { + const state = Uint8Array.from(before); + changeRow(state, k); + expect([...state]).toEqual(after); + }); + + test('invalidateOutOfView keeps only the rows in view', () => { + const state = Uint8Array.from([J, J, J, J, C, J]); + invalidateOutOfView(state, 2, 3); + expect([...state]).toEqual([I, I, J, J, C, I]); + }); +}); + +describe('repaint runs', () => { + test.each([ + ['above the viewport', 1, C], + ['the first row in view', 2, J], + ['a middle row', 3, J], + ['the last row', 5, C], + ])('wantedRowState: %s', (_name, k, wanted) => { + expect(wantedRowState(k, 2, 5)).toBe(wanted); + }); + + // The viewport is rows 2 to 5, the row above it row 1 + test.each([ + ['one row whose next row is as wanted', [I, C, I, J, J, C], 2, 2], + ['a run up to a row as wanted', [I, C, I, I, J, C], 2, 3], + ['a run to the last row', [I, C, I, I, I, I], 2, 5], + ['the row above stops at itself', [I, I, I, I, I, I], 1, 1], + ['the last row', [I, C, J, J, J, I], 5, 5], + ])('repaintRunEnd: %s', (_name, state, k, end) => { + expect(repaintRunEnd(Uint8Array.from(state), k, 2, 5)).toBe(end); + }); +}); + +/** A seeded random number generator, so a failing sequence can be replayed. */ +function random(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 2 ** 32; + }; +} + +/** + * What one canvas row's pixels show: its own line at a version and, when joined, what the + * next line at a version draws into it. null: nothing drawn. + */ +type Pixels = { own: string; next: string | null } | null; + +/** + * Plans frames as the renderer does, over a model of the canvas rows' pixels, and + * checks that every row in view shows exactly what a full redraw of the viewport would. + */ +function simulate(seed: number, frames: number) { + const rng = random(seed); + const rows = 6; + const overscan = 4; + const total = rows + 2 * overscan; + const scrollback = 40; + const version = new Array<number>(scrollback + rows).fill(0); + // The line canvas row k shows at band b: viewport row k - overscan at viewportY b + const lineAt = (k: number, b: number) => scrollback - b + k - overscan; + const text = (line: number) => + line >= 0 && line < version.length ? `${line}@${version[line]}` : 'blank'; + + let state: Uint8Array = new Uint8Array(total); + let pixels: Pixels[] = new Array(total).fill(null); + let band = 0; + let lastView = 0; + let repaints = 0; + let shifts = 0; + + for (let frame = 0; frame < frames; frame++) { + // Mostly small scrolls, sometimes a jump; output only at the bottom + const r = rng(); + let view = lastView; + if (r < 0.5) view += Math.round((rng() - 0.5) * 6); + else if (r < 0.6) view = Math.floor(rng() * (scrollback + 1)); + view = Math.max(0, Math.min(scrollback, view)); + const above = view < scrollback && rng() < 0.3 ? 1 : 0; + const dirty = view === 0 && rng() < 0.3 ? [Math.floor(rng() * rows)] : []; + for (const y of dirty) version[scrollback + y]++; + + let force = frame === 0; + const moved = view !== lastView; + if (dirty.length > 0 && moved) force = true; + lastView = view; + + if (force) { + band = view; + } else { + const next = nextBand(band, view, overscan, above, scrollback); + const shift = next - band; + if (Math.abs(shift) >= total) { + force = true; + } else if (shift !== 0) { + shifts++; + state = shiftedRowStates(state, shift); + const moved: Pixels[] = new Array(total).fill(null); + for (let k = 0; k < total; k++) { + if (k + shift >= 0 && k + shift < total) moved[k + shift] = pixels[k]; + } + pixels = moved; + } + band = next; + } + expect(bandFits(band, view, overscan, above)).toBe(true); + const top = overscan + band - view; + if (force) state.fill(ROW_INVALID); + else { + for (const y of dirty) changeRow(state, top + y); + if (dirty.length > 0) invalidateOutOfView(state, top, rows); + } + + const first = top - above; + const last = top + rows - 1; + for (let k = first; k <= last; k++) { + if (state[k] === wantedRowState(k, top, last)) continue; + const end = repaintRunEnd(state, k, top, last); + const cut = wantedRowState(end, top, last) === ROW_CUT; + for (let j = k; j <= end; j++) { + const joined = j < end || !cut; + pixels[j] = { own: text(lineAt(j, band)), next: joined ? text(lineAt(j + 1, band)) : null }; + state[j] = ROW_JOINED; + repaints++; + } + state[end] = cut ? ROW_CUT : ROW_JOINED; + k = end; + } + + // A full redraw of the viewport: each row its own line, joined with the next line + // except the last row and the row above + for (let k = first; k <= last; k++) { + const joined = wantedRowState(k, top, last) === ROW_JOINED; + const want = { own: text(lineAt(k, band)), next: joined ? text(lineAt(k + 1, band)) : null }; + if (JSON.stringify(pixels[k]) !== JSON.stringify(want)) { + throw new Error( + `seed ${seed} frame ${frame}: row ${k} shows ${JSON.stringify(pixels[k])}, want ${JSON.stringify(want)}`, + ); + } + expect(state[k]).toBe(wantedRowState(k, top, last)); + } + } + return { repaints, shifts }; +} + +describe('random scroll and output sequences', () => { + test('every frame shows what a full redraw shows', () => { + let repaints = 0; + let shifts = 0; + for (let seed = 1; seed <= 40; seed++) { + const result = simulate(seed, 200); + repaints += result.repaints; + shifts += result.shifts; + } + // The band saved work: far fewer rows were drawn than a full redraw per frame would + expect(shifts).toBeGreaterThan(0); + expect(repaints).toBeLessThan(40 * 200 * 6); + }); +}); diff --git a/src/client/vendor/tests/render/scrollbar-geometry.test.ts b/src/client/vendor/tests/render/scrollbar-geometry.test.ts new file mode 100644 index 0000000..3049c2b --- /dev/null +++ b/src/client/vendor/tests/render/scrollbar-geometry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; +import { + inScrollbarColumn, + SCROLLBAR_MIN_THUMB, + scrollbarThumbHeight, + scrollbarThumbY, + scrollbarTrackHeight, + scrollbarX, +} from '../../ghostty-web/render/scrollbar-geometry'; + +// The scrollbar's column, track and thumb, as both the painter and the hit test use them. + +describe('scrollbar column', () => { + test.each([ + [800, 788], + [100, 88], + ])('a %p px canvas puts the track at %p', (width, x) => { + expect(scrollbarX(width)).toBe(x); + }); + + test.each([ + [787, false], + [788, true], + [792, true], + [796, true], + [797, false], + ])('x %p on an 800 px canvas: %p', (x, inside) => { + expect(inScrollbarColumn(x, 800)).toBe(inside); + }); +}); + +describe('scrollbar track and thumb', () => { + test.each([ + [400, 392], + [8, 0], + ])('rows %p px high give a %p px track', (height, track) => { + expect(scrollbarTrackHeight(height)).toBe(track); + }); + + test.each([ + ['all lines in view at once', 24, 24, 392, 196], + ['a quarter of the lines', 24, 72, 392, 98], + ['a long scrollback: the minimum', 24, 10000, 392, SCROLLBAR_MIN_THUMB], + ])('%s', (_name, rows, scrollback, track, thumb) => { + expect(scrollbarThumbHeight(rows, scrollback, track)).toBe(thumb); + }); + + test.each([ + ['at the bottom', 0, 100, 392, 92, 304], + ['at the top', 100, 100, 392, 92, 4], + ['half way', 50, 100, 392, 92, 154], + ])('%s', (_name, viewportY, scrollback, track, thumb, y) => { + expect(scrollbarThumbY(viewportY, scrollback, track, thumb)).toBe(y); + }); +}); From bd2ab21cdb882e0e7ed2eed746e71e46199c87e8 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:56:32 -0400 Subject: [PATCH 242/295] refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) renderer.ts drops from 1267 to 489 lines. CanvasRenderer now plans each frame and hands the drawing to painters in render/ (ADR 061). The painters take the context they draw on as an argument. A partial repaint passes the scratch canvas' context to them, so the renderer no longer swaps this.ctx. Every painter bumps one shared DrawCounter, which visibleArea.version reads. - render/cell-painter.ts: CellPainter (paintRow, paintLine, paintCellBackground, paintCellText) and the colour precedence as pure cellBackground / cellForeground (selection, then search match, then the cell, with INVERSE swapping). Nothing is allocated per cell beyond the colour string, as before. - render/row-repainter.ts: RowRepainter (repaintView, repaint, the chunked scratch repaint, shiftRows) and REPAINT_CHUNK_ROWS. - render/cursor-painter.ts: CursorPainter (shape, drawn row and column, invalidateDrawn), and CursorBlink on injected interval timers (CURSOR_BLINK_INTERVAL_MS = 530; the default is still window.setInterval). - render/scrollbar-painter.ts: ScrollbarPainter (paint, and moveTo for the rows it no longer covers). - render/decoration-tracker.ts: the selection, search spans and link range per canvas row, and what each row was drawn with. - render/scroll-tracker.ts: the dirty-row scan and the move checks. - render/viewport-clip.ts: the drawing canvas' placement, transform, clip and visibleArea. - render/frame-lines.ts gains rowContent (was rowAt) and the grapheme lookups. - render/renderable.ts gains the shared PaintSettings, DrawCounter, FrameDecorations, RowPainter, CellTextPainter, CursorStyle, IntervalTimers, RendererOptions and RendererDeps. CanvasRenderer takes an optional RendererDeps (document, window, timers) with browser defaults. The canvases are still created in the same order, and the contextrestored listener, the ratio watch and the blink go into a DisposableStore; all three were removed in dispose() before too. The listener snapshot is unchanged. render() keeps its call order. Tests: renderer.test.ts is split. Its raster tests move unchanged into vendor/tests/render/row-repainter, cell-painter and cursor-painter tests. They use an injected document instead of swapping globalThis.document. They no longer patch private methods: they count painted rows and copies from the raster context. New direct tests cover each part: the resolveCellColors precedence tables, the 530 ms blink on a ManualClock, setFontSize/setFontFamily/remeasureFont marking every row stale, the ratio watch and dispose. A new vendor/tests/renderer.test.ts covers the facade. The shared helpers live in support/raster-canvas.ts and support/renderer-harness.ts. Every render/ file and renderer.ts is at 100% lines. The ratchet entries for renderer.ts and renderer.test.ts are removed. PROVENANCE.md records the moves. official-replay.test.ts, 3 runs each: before 945, 892, 885 ms; after 966, 908, 906 ms. renderer tests (old file, then the split files): before 7.83, 7.42, 6.87 s; after 5.97, 6.14, 5.69 s. --- scripts/architecture/ratchet-allowlist.ts | 5 - src/client/vendor/ghostty-web/PROVENANCE.md | 14 +- .../vendor/ghostty-web/render/cell-painter.ts | 249 ++++ .../ghostty-web/render/cursor-painter.ts | 173 +++ .../ghostty-web/render/decoration-tracker.ts | 123 ++ .../vendor/ghostty-web/render/frame-lines.ts | 50 +- .../vendor/ghostty-web/render/renderable.ts | 93 +- .../ghostty-web/render/row-repainter.ts | 178 +++ .../ghostty-web/render/scroll-tracker.ts | 48 + .../ghostty-web/render/scrollbar-painter.ts | 91 ++ .../ghostty-web/render/viewport-clip.ts | 91 ++ src/client/vendor/ghostty-web/renderer.ts | 1276 ++++------------- .../vendor/tests/render/cell-painter.test.ts | 352 +++++ .../tests/render/cursor-painter.test.ts | 292 ++++ .../tests/render/decoration-tracker.test.ts | 109 ++ .../vendor/tests/render/frame-lines.test.ts | 54 +- .../vendor/tests/render/row-repainter.test.ts | 399 ++++++ .../tests/render/scroll-tracker.test.ts | 44 + .../tests/render/scrollbar-painter.test.ts | 116 ++ .../vendor/tests/render/viewport-clip.test.ts | 71 + src/client/vendor/tests/renderer.test.ts | 1023 +++---------- .../vendor/tests/support/raster-canvas.ts | 229 +++ .../vendor/tests/support/renderer-harness.ts | 204 +++ 23 files changed, 3385 insertions(+), 1899 deletions(-) create mode 100644 src/client/vendor/ghostty-web/render/cell-painter.ts create mode 100644 src/client/vendor/ghostty-web/render/cursor-painter.ts create mode 100644 src/client/vendor/ghostty-web/render/decoration-tracker.ts create mode 100644 src/client/vendor/ghostty-web/render/row-repainter.ts create mode 100644 src/client/vendor/ghostty-web/render/scroll-tracker.ts create mode 100644 src/client/vendor/ghostty-web/render/scrollbar-painter.ts create mode 100644 src/client/vendor/ghostty-web/render/viewport-clip.ts create mode 100644 src/client/vendor/tests/render/cell-painter.test.ts create mode 100644 src/client/vendor/tests/render/cursor-painter.test.ts create mode 100644 src/client/vendor/tests/render/decoration-tracker.test.ts create mode 100644 src/client/vendor/tests/render/row-repainter.test.ts create mode 100644 src/client/vendor/tests/render/scroll-tracker.test.ts create mode 100644 src/client/vendor/tests/render/scrollbar-painter.test.ts create mode 100644 src/client/vendor/tests/render/viewport-clip.test.ts create mode 100644 src/client/vendor/tests/support/raster-canvas.ts create mode 100644 src/client/vendor/tests/support/renderer-harness.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 54fc4e6..177e93f 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -20,10 +20,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { lines: 856, step: 'vendor input split into input/ (ADR 061)', }, - 'src/client/vendor/ghostty-web/renderer.ts': { - lines: 1267, - step: 'vendor renderer split into render/ (ADR 061)', - }, 'src/client/vendor/ghostty-web/selection-manager.ts': { lines: 736, step: 'vendor selection split into selection/ (ADR 061)', @@ -32,7 +28,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { lines: 2139, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/renderer.test.ts': { lines: 923, step: VENDOR_TESTS }, 'src/client/vendor/tests/terminal.test.ts': { lines: 1159, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 740c3d3..61ff2e8 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -47,6 +47,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `ghostty.ts`, `terminal.ts`, `selection-manager.ts`: OSC 133 prompt jumps and output selection (ADR 057). The binding adds `promptJumpRow` (Ghostty's viewport put on webtty's top row with `ROW`, moved with `GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`, read back with `GHOSTTY_TERMINAL_DATA_SCROLLBAR` and put back at the bottom; `scrollViewport` writes its value as an `isize`) and `selectOutput` (`ghostty_terminal_select_output`). `Terminal` adds `jumpToPrompt`, Ghostty's `jump_to_prompt`, scrolling the view with `scrollToLine`. A triple click with Ctrl or Cmd held selects the command's output instead of the line, as in Ghostty. Upstream had neither. - `fn-table.ts`: not from ghostty-web. Copied unmodified (below its added header) from [gkoreli/ghostty-vt-js](https://github.com/gkoreli/ghostty-vt-js) at `248de81a188b71a1919d3c5c5bce2f99a836b5b1`, `packages/ghostty-vt-js/src/wasm/fn-table.ts`, MIT, Copyright (c) 2026 ghostty-vt-js contributors ([LICENSE.ghostty-vt-js](LICENSE.ghostty-vt-js)); `bun run build` adds the notice to `dist/THIRD_PARTY_LICENSES`. - `engine/wasm-loader.ts` (from `ghostty.ts`): the wasm is read through an injected reader and fetch (`WasmLoaderDeps`; production: `Bun.file` where Bun runs, then the global `fetch`), and upstream's Node `fs/promises` fallback is removed: the browser never reached it and tests run under Bun, where `Bun.file` reads first (ADR 061). The default URL is `../../ghostty-vt/ghostty-vt-small.wasm`, for the file's new depth. +- `renderer.ts`: `CanvasRenderer` takes an optional third argument, `RendererDeps` (the `document` that creates its canvases, the `window` whose ratio it reads and watches, and the blink's interval `timers`), each defaulting to the browser's, so tests inject them instead of replacing globals (ADR 061). The painters take the context they draw on as an argument; the renderer no longer swaps its own context for the scratch canvas' during a partial repaint. - `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `Glide`, `AT_ONCE`, `FRAME_MS`, `linear`, `easeOutCubic`, `Pace` and `Ease` (`glide.ts`), `watchDevicePixelRatio` (`renderer.ts`) and `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`), which the app imported by deep path or did not have. ## webtty-original files @@ -55,7 +56,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. -- `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts` (ADR 043): renderer helpers webtty added, moved out of `renderer.ts` (ADR 061). +- `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts`, `render/row-repainter.ts`, `render/scroll-tracker.ts` (ADR 043), `render/decoration-tracker.ts` (ADR 043, ADR 055), `render/viewport-clip.ts` (ADR 040, ADR 043): renderer parts webtty added, moved out of `renderer.ts` (ADR 061). - `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043, ADR 061): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. ## Layout @@ -63,7 +64,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input (`key-types.ts`). - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). -- `render/`: the canvas renderer's parts: what it reads (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row reader (`frame-lines.ts`), the cell size (`font-metrics.ts`) the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` re-exports what moved. +- `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -111,3 +112,12 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `link-detector.ts`: the range test of `isPositionInLink` (now `cellInRange`); `selection-manager.ts`: `SelectionCoordinates`, now an alias of `CellRange` | `render/cell-range.ts` | refactor(client): render split, part 1: one cell range for the renderer, selection and links | | `renderer.ts`: `DEFAULT_OVERSCAN`, `ROW_INVALID`, `ROW_JOINED`, `ROW_CUT`; from `render()`: the band fit test and move (now `bandFits`, `nextBand`), the row state and search key shifts (now `shiftedRowStates`, `shiftedRowKeys`), the `invalidate` and `changed` closures (now `invalidateRow`, `changeRow`), the out-of-view invalidation (now `invalidateOutOfView`) and the repaint run plan (now `wantedRowState`, `repaintRunEnd`) (webtty) | `render/row-band.ts` | refactor(client): render split, part 1: band planning and scrollbar geometry | | `renderer.ts`: the dimensions and thumb maths of `renderScrollbar`; `terminal.ts`: the same, written out again in `handleMouseDown` and `processScrollbarDrag` (now `scrollbarX`, `inScrollbarColumn`, `scrollbarTrackHeight`, `scrollbarThumbHeight`, `scrollbarThumbY` and the `SCROLLBAR_*` constants) | `render/scrollbar-geometry.ts` | refactor(client): render split, part 1: band planning and scrollbar geometry | +| `renderer.ts`: `renderLine` (now `paintLine`), `renderCellBackground` and `renderCellText` (now `paintCellBackground`, `paintCellText`, taking the context; their colour choice now `cellBackground` and `cellForeground`), `rgbToCSS`, the blank row of `drawRow` (now `paintRow`) | `render/cell-painter.ts` (`CellPainter`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: `renderCursor` (now `paint`), `setCursorStyle`'s change flag, the drawn cursor row and column, and the cursor invalidation of `render()` (now `invalidateDrawn`); `startCursorBlink`, `stopCursorBlink` and the blink fields (now `CursorBlink` on injected timers, `CURSOR_BLINK_INTERVAL_MS`) | `render/cursor-painter.ts` (`CursorPainter`, `CursorBlink`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: `renderScrollbar` (now `paint`), the drawn scrollbar row and the uncovered-row invalidation of `render()` (now `moveTo`) | `render/scrollbar-painter.ts` (`ScrollbarPainter`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: `REPAINT_CHUNK_ROWS`, `repaintRows` (now `repaint`), `repaintChunk`, `scratchContext`, `shiftRows`, the scratch canvas, and the repaint loop of `render()` (now `repaintView`) (webtty) | `render/row-repainter.ts` (`RowRepainter`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: `rowAt` (now `rowContent` over a `RowSource`), `screenGraphemes`, `scrollbackGraphemes` (webtty) | `render/frame-lines.ts` | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: the selection, search and link fields and their marking in `render()` (now `mark`, `shift`, `repainted`), `setSelectionManager`, `setSearchHighlights`, `setHoveredLinkRange` bodies (webtty) | `render/decoration-tracker.ts` (`DecorationTracker`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: the dirty-row scan and the move checks of `render()`, `lastViewportY`, `lastScrollbackLength` (webtty) | `render/scroll-tracker.ts` (`ScrollTracker`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: the drawing canvas set-up of the constructor (now `createDrawingCanvas`), the transform and clip of `render()` (now `show`), the shown fields and the `visibleArea` body (now `area`) (webtty) | `render/viewport-clip.ts` (`ViewportClip`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `renderer.ts`: `RendererOptions`, and the paint settings, draw counter and frame decorations the painters share (new `PaintSettings`, `DrawCounter`, `FrameDecorations`, `RowPainter`, `CellTextPainter`, `IntervalTimers`, `CursorStyle`, `RendererDeps`); `renderer.ts` re-exports `RendererOptions` | `render/renderable.ts` | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | diff --git a/src/client/vendor/ghostty-web/render/cell-painter.ts b/src/client/vendor/ghostty-web/render/cell-painter.ts new file mode 100644 index 0000000..0d9aac6 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/cell-painter.ts @@ -0,0 +1,249 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The cell painter (object): draws a canvas row's backgrounds, then its text and +// decorations, on the context it is given, with the colour precedence in two pure +// functions. The per-cell hot path: no allocation per cell beyond the colour string (ADR 061). + +import { CellFlags, type GhosttyCell } from '../engine/cell-types'; +import type { ITheme } from '../interfaces'; +import { highlightAt } from '../search'; +import { cellInRange } from './cell-range'; +import type { + CellTextPainter, + DrawCounter, + FrameDecorations, + GraphemeLookup, + PaintSettings, + RowContent, + RowPainter, +} from './renderable'; + +/** A colour from its red, green and blue bytes, as the canvas takes it. */ +export function rgbToCSS(r: number, g: number, b: number): string { + return `rgb(${r}, ${g}, ${b})`; +} + +/** + * The colour a cell's background is filled with, or null to leave the row's theme + * background: the selection over a search match (the selected match in its own colour) + * over the cell's own background, which INVERSE swaps with its foreground. Black is the + * engine's default background and is not filled. + */ +export function cellBackground( + cell: GhosttyCell, + selected: boolean, + match: 0 | 1 | 2, + theme: Required<ITheme> +): string | null { + // Selection background replaces cell background + if (selected) return theme.selectionBackground; + // A search match replaces it too, under the selection as in Ghostty (webtty, ADR 055) + if (match) return match === 2 ? theme.searchSelectedBackground : theme.searchBackground; + // When inverted, background becomes foreground + const inverse = cell.flags & CellFlags.INVERSE; + const r = inverse ? cell.fg_r : cell.bg_r; + const g = inverse ? cell.fg_g : cell.bg_g; + const b = inverse ? cell.fg_b : cell.bg_b; + // Only draw cell background if it's different from the default (black) + // This lets the theme background (drawn earlier) show through for default cells + if (r === 0 && g === 0 && b === 0) return null; + return rgbToCSS(r, g, b); +} + +/** + * The colour a cell's text is drawn in: the selection's foreground over a search match's + * (the selected match's in its own colour) over the cell's own, which INVERSE swaps with + * its background. A caller's override (the block cursor's accent) comes before all of them. + */ +export function cellForeground( + cell: GhosttyCell, + selected: boolean, + match: 0 | 1 | 2, + theme: Required<ITheme> +): string { + if (selected) return theme.selectionForeground; + if (match) return match === 2 ? theme.searchSelectedForeground : theme.searchForeground; + // When inverted, foreground becomes background + const inverse = cell.flags & CellFlags.INVERSE; + return inverse + ? rgbToCSS(cell.bg_r, cell.bg_g, cell.bg_b) + : rgbToCSS(cell.fg_r, cell.fg_g, cell.fg_b); +} + +/** The colour of the hovered link's underline. */ +export const LINK_UNDERLINE_COLOR = '#4A90E2'; + +/** + * Paints rows of cells with the shared settings and the frame's decorations. Every method + * takes the context to draw on, so a partial repaint passes the scratch canvas' context + * instead of swapping the renderer's. (webtty, ADR 043, ADR 061) + */ +export class CellPainter implements RowPainter, CellTextPainter { + constructor( + private readonly settings: PaintSettings, + private readonly frame: FrameDecorations, + private readonly counter: DrawCounter + ) {} + + /** Draws canvas row `k`; a row with no line (past the buffer's ends) is left blank. */ + paintRow( + ctx: CanvasRenderingContext2D, + k: number, + cols: number, + { line, grapheme }: RowContent + ): void { + this.counter.draws++; + if (line) { + this.paintLine(ctx, line, k, cols, grapheme ?? blankGrapheme); + return; + } + const { metrics, theme } = this.settings; + const y = k * metrics.height; + const width = cols * metrics.width; + ctx.clearRect(0, y, width, metrics.height); + ctx.fillStyle = theme.background; + ctx.fillRect(0, y, width, metrics.height); + } + + /** + * Render a single line using two-pass approach: + * 1. First pass: Draw all cell backgrounds + * 2. Second pass: Draw all cell text and decorations + * + * This two-pass approach is necessary for proper rendering of complex scripts + * like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the + * base character into the previous cell's visual area. If we draw backgrounds + * and text in a single pass (cell by cell), the background of cell N would + * cover any left-extending portions of graphemes from cell N-1. + */ + paintLine( + ctx: CanvasRenderingContext2D, + line: GhosttyCell[], + y: number, + cols: number, + grapheme: GraphemeLookup + ): void { + this.counter.draws++; + const { metrics, theme } = this.settings; + const lineY = y * metrics.height; + const lineWidth = cols * metrics.width; + + // Clear line background then fill with theme color. + // We clear just the cell area - glyph overflow is handled by also + // redrawing the row below (see RowRepainter). + // clearRect is needed because fillRect composites rather than replaces, + // so transparent/translucent backgrounds wouldn't clear previous content. + ctx.clearRect(0, lineY, lineWidth, metrics.height); + ctx.fillStyle = theme.background; + ctx.fillRect(0, lineY, lineWidth, metrics.height); + + // PASS 1: Draw all cell backgrounds first + // This ensures all backgrounds are painted before any text, allowing text + // to "bleed" across cell boundaries without being covered by adjacent backgrounds + for (let x = 0; x < line.length; x++) { + const cell = line[x]; + if (cell.width === 0) continue; // Skip spacer cells for wide characters + this.paintCellBackground(ctx, cell, x, y); + } + + // PASS 2: Draw all cell text and decorations + // Now text can safely extend beyond cell boundaries (for complex scripts) + for (let x = 0; x < line.length; x++) { + const cell = line[x]; + if (cell.width === 0) continue; // Skip spacer cells for wide characters + this.paintCellText(ctx, cell, x, y, undefined, grapheme); + } + } + + /** + * Render a cell's background only (Pass 1 of two-pass rendering) + * Selection highlighting is integrated here to avoid z-order issues with + * complex glyphs (like Devanagari) that extend outside their cell bounds. + */ + paintCellBackground(ctx: CanvasRenderingContext2D, cell: GhosttyCell, x: number, y: number): void { + const { metrics, theme } = this.settings; + const selected = cellInRange(this.frame.selection, x, y); + const fill = cellBackground(cell, selected, highlightAt(this.frame.searchSpans[y], x), theme); + if (fill === null) return; + ctx.fillStyle = fill; + ctx.fillRect(x * metrics.width, y * metrics.height, metrics.width * cell.width, metrics.height); + } + + /** + * Render a cell's text and decorations (Pass 2 of two-pass rendering) + * Selection foreground color is applied here to match the selection background. + */ + paintCellText( + ctx: CanvasRenderingContext2D, + cell: GhosttyCell, + x: number, + y: number, + colorOverride: string | undefined, + grapheme: GraphemeLookup + ): void { + // Skip rendering if invisible + if (cell.flags & CellFlags.INVISIBLE) return; + const { metrics, theme } = this.settings; + const cellX = x * metrics.width; + const cellY = y * metrics.height; + const cellWidth = metrics.width * cell.width; + + // Set text style + let fontStyle = ''; + if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic '; + if (cell.flags & CellFlags.BOLD) fontStyle += 'bold '; + ctx.font = `${fontStyle}${this.settings.fontSize}px ${this.settings.fontFamily}`; + + // Set text color - use override, selection foreground, search or normal color + ctx.fillStyle = + colorOverride || + cellForeground( + cell, + cellInRange(this.frame.selection, x, y), + highlightAt(this.frame.searchSpans[y], x), + theme + ); + + // Apply faint effect + const faint = cell.flags & CellFlags.FAINT; + if (faint) ctx.globalAlpha = 0.5; + + // A cell with extra codepoints draws its whole grapheme cluster, a simple cell its + // one codepoint (a space if null) + const char = cell.grapheme_len > 0 ? grapheme(x) : String.fromCodePoint(cell.codepoint || 32); + ctx.fillText(char, cellX, cellY + metrics.baseline); + + // Reset alpha + if (faint) ctx.globalAlpha = 1.0; + + if (cell.flags & CellFlags.UNDERLINE) { + drawLine(ctx, ctx.fillStyle, cellX, cellX + cellWidth, cellY + metrics.baseline + 2); + } + if (cell.flags & CellFlags.STRIKETHROUGH) { + drawLine(ctx, ctx.fillStyle, cellX, cellX + cellWidth, cellY + metrics.height / 2); + } + // Draw the hovered link's underline (OSC 8 or plain text URL), in canvas rows + // (webtty, ADR 043) + if (cellInRange(this.frame.link, x, y)) { + drawLine(ctx, LINK_UNDERLINE_COLOR, cellX, cellX + cellWidth, cellY + metrics.baseline + 2); + } + } +} + +/** The lookup for a row that came without one: every cluster a space. */ +const blankGrapheme: GraphemeLookup = () => ' '; + +/** A 1 px horizontal line in `style` from `x0` to `x1` at `y`. */ +function drawLine( + ctx: CanvasRenderingContext2D, + style: CanvasRenderingContext2D['strokeStyle'], + x0: number, + x1: number, + y: number +): void { + ctx.strokeStyle = style; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x0, y); + ctx.lineTo(x1, y); + ctx.stroke(); +} diff --git a/src/client/vendor/ghostty-web/render/cursor-painter.ts b/src/client/vendor/ghostty-web/render/cursor-painter.ts new file mode 100644 index 0000000..6c0f81f --- /dev/null +++ b/src/client/vendor/ghostty-web/render/cursor-painter.ts @@ -0,0 +1,173 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The cursor (objects): CursorBlink toggles its visibility on an injected interval, and +// CursorPainter draws its shape over its cell and tracks where the last frame drew it, so +// a frame repaints the old cell and never blends a translucent cursor over itself (ADR 061). + +import type { Disposable } from '../lifecycle/disposable-store'; +import { type RowSource, screenGraphemes } from './frame-lines'; +import type { + CellTextPainter, + CursorStyle, + DrawCounter, + IntervalTimers, + PaintSettings, +} from './renderable'; +import { invalidateRow } from './row-band'; + +/** The blink half-period, as xterm.js uses. */ +export const CURSOR_BLINK_INTERVAL_MS = 530; + +/** The window's interval timer, read when the blink starts. */ +export const windowIntervals: IntervalTimers = { + setInterval: (run, ms) => window.setInterval(run, ms), + clearInterval: (id) => clearInterval(id), +}; + +/** + * Whether the cursor shows while it blinks: toggled every CURSOR_BLINK_INTERVAL_MS while + * enabled, always shown otherwise. The render loop draws the change on its next frame. + */ +export class CursorBlink implements Disposable { + private shown = true; + private on = false; + private timer?: number; + + constructor(private readonly timers: IntervalTimers = windowIntervals) {} + + /** Whether the blink is on now. */ + get visible(): boolean { + return this.shown; + } + + /** Whether the cursor blinks. */ + get enabled(): boolean { + return this.on; + } + + /** Starts or stops blinking; does nothing when it already is as asked. */ + setEnabled(enabled: boolean): void { + if (enabled === this.on) return; + this.on = enabled; + if (enabled) { + this.timer = this.timers.setInterval(() => { + this.shown = !this.shown; + }, CURSOR_BLINK_INTERVAL_MS); + } else { + this.stop(); + } + } + + /** Stops the timer and shows the cursor. */ + dispose(): void { + this.stop(); + } + + private stop(): void { + if (this.timer !== undefined) { + this.timers.clearInterval(this.timer); + this.timer = undefined; + } + this.shown = true; + } +} + +/** Draws the cursor shape and remembers where it drew it, in canvas rows. */ +export class CursorPainter { + // Where the last frame drew the cursor: its canvas row (null: not drawn) and column (webtty) + private drawnRow: number | null = null; + private drawnCol = 0; + // Cursor style or blink changed since the last frame: repaint the cursor cell so + // the old shape does not stay under the new one (webtty) + private shapeChanged = false; + + constructor( + private style: CursorStyle, + private readonly cells: CellTextPainter, + private readonly settings: PaintSettings, + private readonly counter: DrawCounter + ) {} + + /** Sets the shape; a change repaints the cursor cell on the next frame. */ + setStyle(style: CursorStyle): void { + if (style !== this.style) this.shapeChanged = true; + this.style = style; + } + + /** Repaints the cursor cell on the next frame (the blink was turned on or off). */ + markShapeChanged(): void { + this.shapeChanged = true; + } + + /** Follows the canvas content moved by `rows` rows on a copied scroll. */ + shift(rows: number): void { + if (this.drawnRow !== null) this.drawnRow += rows; + } + + /** + * Invalidates the row the last frame's cursor is in unless the same cursor is drawn + * there again (`draws` at canvas row `row`, column `col`), and returns whether it is. + */ + invalidateDrawn(state: Uint8Array, draws: boolean, row: number, col: number): boolean { + let same = false; + if (this.drawnRow !== null) { + same = draws && this.drawnRow === row && col === this.drawnCol && !this.shapeChanged; + if (!same) invalidateRow(state, this.drawnRow); + } + this.shapeChanged = false; + return same; + } + + /** Records where this frame drew the cursor: canvas row `row` (null: not drawn), column `col`. */ + drawnAt(row: number | null, col: number): void { + this.drawnRow = row; + this.drawnCol = col; + } + + /** + * Render cursor at canvas row `y`, which shows screen row `screenRow`; the block cursor + * draws the cell's character again in the accent colour. (webtty: the screen row) + */ + paint( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + screenRow: number, + rows: RowSource + ): void { + this.counter.draws++; + const { metrics, theme } = this.settings; + const cursorX = x * metrics.width; + const cursorY = y * metrics.height; + ctx.fillStyle = theme.cursor; + switch (this.style) { + case 'block': { + ctx.fillRect(cursorX, cursorY, metrics.width, metrics.height); + const line = rows.getLine(screenRow); + if (line?.[x]) { + ctx.save(); + ctx.beginPath(); + ctx.rect(cursorX, cursorY, metrics.width, metrics.height); + ctx.clip(); + // The grapheme of the screen row, not of the canvas row (webtty, ADR 043) + const grapheme = screenGraphemes(rows.buffer, screenRow); + this.cells.paintCellText(ctx, line[x], x, y, theme.cursorAccent, grapheme); + ctx.restore(); + } + break; + } + case 'underline': { + // Underline at bottom of cell + const underlineHeight = Math.max(2, Math.floor(metrics.height * 0.15)); + const underlineY = cursorY + metrics.height - underlineHeight; + ctx.fillRect(cursorX, underlineY, metrics.width, underlineHeight); + break; + } + case 'bar': { + // Vertical bar at left of cell + const barWidth = Math.max(2, Math.floor(metrics.width * 0.15)); + ctx.fillRect(cursorX, cursorY, barWidth, metrics.height); + break; + } + } + } +} diff --git a/src/client/vendor/ghostty-web/render/decoration-tracker.ts b/src/client/vendor/ghostty-web/render/decoration-tracker.ts new file mode 100644 index 0000000..91fec05 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/decoration-tracker.ts @@ -0,0 +1,123 @@ +// webtty (ADR 043, ADR 055, ADR 061), not from upstream. +// The decoration tracker (object): what a frame draws over the cells' own colours (the +// selection, the search matches, the hovered link), kept in canvas rows next to what the +// rows were drawn with, so a frame repaints only the rows whose decorations changed. + +import type { HighlightSpan, SearchHighlights } from '../search'; +import { highlightRows, spansKey } from '../search'; +import { type CellRange, moveRangeRows, sameRange } from './cell-range'; +import type { FrameDecorations } from './renderable'; +import { changeRow, shiftedRowKeys } from './row-band'; + +/** The selection as the renderer reads it, in viewport rows, not clamped to the viewport. */ +export interface SelectionSource { + hasSelection(): boolean; + getSelectionRange(): CellRange | null; +} + +/** + * Holds the decorations to draw and the ones each canvas row was drawn with. They are + * anchored to buffer lines like the canvas rows, so they follow a copied scroll. + */ +export class DecorationTracker { + /** This frame's decorations, in canvas rows, as the cell painter reads them. */ + readonly frame: FrameDecorations = { selection: null, searchSpans: [], link: null }; + private selectionSource?: SelectionSource; + // The selection the canvas rows were drawn with, in canvas rows (webtty, ADR 043) + private drawnSelection: CellRange | null = null; + // The hovered link's range (OSC 8 or regex), in viewport rows. The engine's + // hyperlink_id is a 1-bit "has a link" flag, not an id, so the range is the only + // thing that tells one link from another. (webtty) + private hoveredLink: CellRange | null = null; + // The link range the canvas rows were drawn with, in canvas rows (webtty, ADR 043) + private drawnLink: CellRange | null = null; + // The search matches (ADR 055), and the spans each canvas row was drawn with (as `spansKey`) + private highlights: SearchHighlights | null = null; + private drawnSearch: string[] = []; + + /** Sets where the selection is read from each frame. */ + setSelectionSource(source: SelectionSource): void { + this.selectionSource = source; + } + + /** Sets the search matches to highlight, or null for none. */ + setSearchHighlights(highlights: SearchHighlights | null): void { + this.highlights = highlights; + } + + /** Sets the hovered link's range in viewport rows, or null for none. */ + setHoveredLink(range: CellRange | null): void { + this.hoveredLink = range; + } + + /** Follows the canvas content moved by `shift` rows on a copied scroll of `total` rows. */ + shift(shift: number, total: number): void { + this.drawnSelection = moveRangeRows(this.drawnSelection, shift); + this.drawnLink = moveRangeRows(this.drawnLink, shift); + this.drawnSearch = shiftedRowKeys(this.drawnSearch, shift, total); + } + + /** + * Sets this frame's decorations for viewport row 0 at canvas row `top`, and marks in + * `state` the rows whose selection, search spans or link underline changed. `view` is + * the viewport's whole line and `scrollbackLength` the lines above the screen. + */ + mark(state: Uint8Array, top: number, view: number, cols: number, scrollbackLength: number): void { + const total = state.length; + const frame = this.frame; + const selection = this.selectionSource?.hasSelection() + ? this.selectionSource.getSelectionRange() + : null; + frame.selection = moveRangeRows(selection, top); + if (!sameRange(frame.selection, this.drawnSelection)) { + for (const range of [this.drawnSelection, frame.selection]) { + if (!range) continue; + const last = Math.min(range.endRow, total - 1); + for (let k = Math.max(0, range.startRow); k <= last; k++) changeRow(state, k); + } + } + this.drawnSelection = frame.selection; + + if (this.drawnSearch.length !== total) this.drawnSearch = new Array<string>(total).fill(''); + frame.searchSpans = this.searchSpans(top, total, view, cols, scrollbackLength); + for (let k = 0; k < total; k++) { + if (spansKey(frame.searchSpans[k]) !== this.drawnSearch[k]) changeRow(state, k); + } + + // A link underline is drawn at viewport rows, which are other canvas rows after a + // scroll: clear it where it was drawn and draw it where it is. + frame.link = moveRangeRows(this.hoveredLink, top); + if (!sameRange(frame.link, this.drawnLink)) { + for (const range of [this.drawnLink, frame.link]) { + if (!range) continue; + for (let k = range.startRow; k <= range.endRow; k++) changeRow(state, k); + } + } + this.drawnLink = frame.link; + } + + /** Records the search spans the rows marked in `repainted` were drawn with. */ + repainted(repainted: Uint8Array): void { + for (let k = 0; k < repainted.length; k++) { + if (repainted[k]) this.drawnSearch[k] = spansKey(this.frame.searchSpans[k]); + } + } + + /** The search spans per canvas row (webtty, ADR 055). */ + private searchSpans( + top: number, + total: number, + view: number, + cols: number, + scrollbackLength: number + ): (HighlightSpan[] | undefined)[] { + const spans = new Array<HighlightSpan[] | undefined>(total); + if (!this.highlights) return spans; + // Canvas row k shows screen row k - top plus the screen row at the viewport's top + const toScreen = scrollbackLength - view - top; + for (const [row, s] of highlightRows(this.highlights, toScreen, toScreen + total - 1, cols)) { + spans[row - toScreen] = s; + } + return spans; + } +} diff --git a/src/client/vendor/ghostty-web/render/frame-lines.ts b/src/client/vendor/ghostty-web/render/frame-lines.ts index fcc1cea..0db935b 100644 --- a/src/client/vendor/ghostty-web/render/frame-lines.ts +++ b/src/client/vendor/ghostty-web/render/frame-lines.ts @@ -1,9 +1,10 @@ // webtty (ADR 042, ADR 061), not from upstream. -// One frame's row reader (util): the viewport read once and served as row slices, so a -// frame never copies and parses the whole viewport per row. +// One frame's row readers (util): the viewport read once and served as row slices, so a +// frame never copies and parses the whole viewport per row, and the line and grapheme +// lookup a canvas row shows, from the scrollback or the screen. import type { GhosttyCell } from '../engine/cell-types'; -import type { IRenderable } from './renderable'; +import type { GraphemeLookup, IRenderable, IScrollbackProvider, RowContent } from './renderable'; /** * Returns a row reader for one frame. When the buffer has getViewport, the @@ -23,3 +24,46 @@ export function frameLines( return viewport.slice(y * cols, (y + 1) * cols); }; } + +/** Grapheme lookup for a screen row. */ +export function screenGraphemes(buffer: IRenderable | null, row: number): GraphemeLookup { + return (col) => buffer?.getGraphemeString?.(row, col) ?? ' '; +} + +/** Grapheme lookup for a scrollback row. */ +export function scrollbackGraphemes( + provider: IScrollbackProvider, + offset: number +): GraphemeLookup { + return (col) => provider.getScrollbackGraphemeString?.(offset, col) ?? ' '; +} + +/** Where a frame's rows come from: the screen and its reader, and the scrollback above it. */ +export interface RowSource { + buffer: IRenderable; + getLine: (y: number) => GhosttyCell[] | null; + rows: number; + scrollback: IScrollbackProvider | undefined; + scrollbackLength: number; +} + +/** + * The line at viewport row `y` for `viewportY`, from scrollback or the screen, with a + * grapheme lookup on the same source: canvas row y is not screen row y while scrolled. + * Null above the scrollback and below the screen. (webtty) + */ +export function rowContent(source: RowSource, y: number, viewportY: number): RowContent { + if (y < viewportY) { + // This row is from scrollback, counted from its end + const scrollbackOffset = source.scrollbackLength - viewportY + y; + if (!source.scrollback || scrollbackOffset < 0) return { line: null }; + return { + line: source.scrollback.getScrollbackLine(scrollbackOffset), + grapheme: scrollbackGraphemes(source.scrollback, scrollbackOffset), + }; + } + // This row is from the screen + const screenRow = y - viewportY; + if (screenRow >= source.rows) return { line: null }; + return { line: source.getLine(screenRow), grapheme: screenGraphemes(source.buffer, screenRow) }; +} diff --git a/src/client/vendor/ghostty-web/render/renderable.ts b/src/client/vendor/ghostty-web/render/renderable.ts index 4636b13..84a121a 100644 --- a/src/client/vendor/ghostty-web/render/renderable.ts +++ b/src/client/vendor/ghostty-web/render/renderable.ts @@ -1,8 +1,17 @@ // Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). -// What the canvas renderer reads (types): the screen buffer, the scrollback and one row's -// content. The render parts import these instead of the terminal or the engine (ADR 061). +// What the canvas renderer reads and what its painters share (types): its options and +// deps, the screen buffer, the scrollback, one row's content, the paint settings, the draw +// counter and the frame's decorations. The render parts import these instead of each +// other or the facade (ADR 061). import type { GhosttyCell } from '../engine/cell-types'; +import type { ITheme } from '../interfaces'; +import type { HighlightSpan } from '../search'; +import type { CellRange } from './cell-range'; +import type { FontMetrics } from './font-metrics'; + +/** The cursor's shapes. */ +export type CursorStyle = 'block' | 'underline' | 'bar'; /** The screen buffer the renderer draws: the engine's terminal, or a test double. */ export interface IRenderable { @@ -63,3 +72,83 @@ export interface RowContent { line: GhosttyCell[] | null; grapheme?: GraphemeLookup; } + +/** + * What every painter draws with, shared by reference so a font, theme or ratio change + * made on the renderer reaches them all at once. (webtty, ADR 061) + */ +export interface PaintSettings { + fontSize: number; + fontFamily: string; + metrics: FontMetrics; + theme: Required<ITheme>; + /** The whole ratio the canvas backing store is drawn at. */ + devicePixelRatio: number; +} + +/** + * Counts the calls that draw on the canvas. Every painter bumps it, so the renderer's + * `visibleArea.version` changes whenever the pixels may have. (webtty, ADR 043, ADR 061) + */ +export interface DrawCounter { + draws: number; +} + +/** + * What one frame draws over the cells' own colours, in canvas rows: the selection, the + * search match spans per canvas row, and the hovered link's range. The renderer sets it + * before it paints a row. (webtty, ADR 043, ADR 055) + */ +export interface FrameDecorations { + selection: CellRange | null; + searchSpans: (HighlightSpan[] | undefined)[]; + link: CellRange | null; +} + +/** Paints one canvas row on the context it is given (the canvas' or the scratch canvas'). */ +export interface RowPainter { + paintRow(ctx: CanvasRenderingContext2D, k: number, cols: number, content: RowContent): void; +} + +/** Paints one cell's text and decorations, in `colorOverride` when given (the block cursor). */ +export interface CellTextPainter { + paintCellText( + ctx: CanvasRenderingContext2D, + cell: GhosttyCell, + x: number, + y: number, + colorOverride: string | undefined, + grapheme: GraphemeLookup + ): void; +} + +/** The interval timer the blink runs on; the window's in the browser, a manual clock in tests. */ +export interface IntervalTimers { + setInterval(run: () => void, ms: number): number; + clearInterval(id: number): void; +} + +/** What a terminal configures the renderer with; the engine's cursor shape wins over the option. */ +export interface RendererOptions { + fontSize?: number; // Default: 15 + fontFamily?: string; // Default: 'monospace' + cursorStyle?: CursorStyle; // Default: 'block' + cursorBlink?: boolean; // Default: false + theme?: ITheme; + devicePixelRatio?: number; // Default: window.devicePixelRatio, rounded up and followed + // Rows drawn above and below the viewport. Default: DEFAULT_OVERSCAN (webtty, ADR 043) + overscan?: number; +} + +/** + * The globals the renderer uses, injectable for tests; each defaults to the browser's. + * (webtty, ADR 061) + */ +export interface RendererDeps { + /** Creates the drawing, scratch and font-measuring canvases, in that order. */ + document?: Pick<Document, 'createElement'>; + /** Read for the ratio, and watched for its changes, when no ratio option is given. */ + window?: Pick<Window, 'devicePixelRatio' | 'matchMedia'>; + /** The cursor blink's interval timer. */ + timers?: IntervalTimers; +} diff --git a/src/client/vendor/ghostty-web/render/row-repainter.ts b/src/client/vendor/ghostty-web/render/row-repainter.ts new file mode 100644 index 0000000..dbcda12 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/row-repainter.ts @@ -0,0 +1,178 @@ +// webtty (ADR 043, ADR 061), not from upstream. +// The row repainter (object): repaints runs of canvas rows exactly as a full redraw leaves +// them, through a small scratch canvas it owns, and moves the canvas content on a copied +// scroll. It draws rows through a RowPainter, on the context it passes in. + +import type { DrawCounter, PaintSettings, RowContent, RowPainter } from './renderable'; +import { ROW_CUT, ROW_INVALID, ROW_JOINED, repaintRunEnd, wantedRowState } from './row-band'; + +/** + * Rows a partial repaint draws on the scratch canvas at once, besides the row below: a + * longer run is repainted in chunks of this many rows, so the scratch canvas stays a few + * rows high instead of growing to the canvas' size. (webtty, ADR 043) + */ +export const REPAINT_CHUNK_ROWS = 8; + +/** Reads what canvas row `k` shows, for the frame being drawn. */ +export type RowAt = (k: number) => RowContent; + +/** + * Repaints canvas rows and copies scrolled ones, on `canvas` through `ctx`; partial + * repaints are drawn on `scratch` first. The scratch canvas is created by the renderer, so + * the order canvases are created in stays the renderer's. + */ +export class RowRepainter { + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly ctx: CanvasRenderingContext2D, + private readonly scratch: HTMLCanvasElement, + private readonly rows: RowPainter, + private readonly settings: PaintSettings, + private readonly counter: DrawCounter + ) { + this.freeScratch(); + } + + /** + * Repaints the rows in view, `first` to `last` with viewport row 0 at canvas row `top`, + * that are not as a full redraw of the viewport leaves them: the last row and the row + * above the viewport (`first` is `top - 1` while it shows) have nothing drawn into them + * from below. Returns which canvas rows were repainted. (webtty, ADR 043) + */ + repaintView( + state: Uint8Array, + first: number, + top: number, + last: number, + cols: number, + rowAt: RowAt + ): Uint8Array { + const repainted = new Uint8Array(state.length); + for (let k = first; k <= last; k++) { + if (state[k] === wantedRowState(k, top, last)) continue; + const end = repaintRunEnd(state, k, top, last); + this.repaint(state, k, end, wantedRowState(end, top, last) === ROW_CUT, cols, rowAt); + repainted.fill(1, k, end + 1); + k = end; + } + return repainted; + } + + /** + * Repaints canvas rows `first` to `last` exactly as a full redraw leaves them, and sets + * their states in `state`. A full redraw draws every row in order, each clearing its own + * cell area first, so a row's pixels are its own cells plus whatever the next line draws + * over it (tall glyphs, combining marks); what the row above overflows into it is + * cleared. So the rows are drawn in order, plus the next line unless the last row is + * `cut`: drawn with its own cells only, as the last row of a full redraw is. + * + * The rows are drawn on the scratch canvas and copied into the canvas, unless they are + * cut and the rows they draw into around them are invalid anyway. A run that is not cut + * ends before a row that is kept. Drawing them clipped to the rows is + * not the same: Chrome skips a glyph whose box is outside the clip, so a combining mark + * of the next line loses the antialiased edge it draws on the last pixel line. + * A long run is drawn on the scratch canvas in chunks of `REPAINT_CHUNK_ROWS` rows, each + * with the next line, which leaves every row as the whole run drawn at once would: a + * row's pixels depend only on its own line and the next. + * (webtty, ADR 043) + */ + repaint( + state: Uint8Array, + first: number, + last: number, + cut: boolean, + cols: number, + rowAt: RowAt + ): void { + this.counter.draws++; + const total = state.length; + const free = (k: number) => k < 0 || k >= total || state[k] === ROW_INVALID; + // Drawn on the canvas, the row above gets the first row's overflow and the row below + // the last one's + if (cut && free(first - 1) && free(last + 1)) { + for (let k = first; k <= last; k++) this.rows.paintRow(this.ctx, k, cols, rowAt(k)); + } else { + for (let from = first; from <= last; from += REPAINT_CHUNK_ROWS) { + const to = Math.min(from + REPAINT_CHUNK_ROWS - 1, last); + this.repaintChunk(from, to, cut && to === last, cols, rowAt); + } + } + for (let k = first; k <= last; k++) state[k] = ROW_JOINED; + state[last] = cut ? ROW_CUT : ROW_JOINED; + } + + /** + * Moves the canvas content down by `rows` rows (up when negative) with one drawImage of + * the canvas onto itself, in device pixels. The 'copy' operator replaces pixels instead + * of blending onto them (a translucent background would darken) and clears the rest of + * the canvas: the rows that came in, which are invalid. (webtty, ADR 043) + */ + shiftRows(rows: number): void { + this.counter.draws++; + const { width, height } = this.canvas; + const dy = rows * this.settings.metrics.height * this.settings.devicePixelRatio; + const kept = height - Math.abs(dy); + const ctx = this.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'copy'; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(this.canvas, 0, Math.max(0, -dy), width, kept, 0, Math.max(0, dy), width, kept); + ctx.restore(); + } + + /** Frees the scratch canvas' backing store; the next partial repaint sizes it. */ + freeScratch(): void { + this.scratch.width = 0; + this.scratch.height = 0; + } + + /** + * Draws canvas rows `first` to `last`, plus the next line unless `cut`, on the scratch + * canvas and copies rows `first` to `last` into the canvas. (webtty, ADR 043) + */ + private repaintChunk(first: number, last: number, cut: boolean, cols: number, rowAt: RowAt): void { + const end = cut ? last : last + 1; + const scratch = this.scratchContext(first, end - first + 1); + for (let k = first; k <= end; k++) this.rows.paintRow(scratch, k, cols, rowAt(k)); + // Replace the rows' pixels in device pixels: cleared first, the copy is not blended + // over them, so a translucent background does not darken + const own = this.ctx; + const rowHeight = this.settings.metrics.height * this.settings.devicePixelRatio; + const width = this.canvas.width; + const top = first * rowHeight; + const height = (last - first + 1) * rowHeight; + own.save(); + own.setTransform(1, 0, 0, 1, 0, 0); + own.globalAlpha = 1; + own.globalCompositeOperation = 'source-over'; + own.imageSmoothingEnabled = false; + own.clearRect(0, top, width, height); + own.drawImage(this.scratch, 0, 0, width, height, 0, top, width, height); + own.restore(); + } + + /** + * The scratch canvas' context, as wide as the canvas and at least `count` rows high, + * scaled like the canvas and moved up so canvas row `first` is drawn at its top. + * (webtty, ADR 043) + */ + private scratchContext(first: number, count: number): CanvasRenderingContext2D { + const dpr = this.settings.devicePixelRatio; + const rowHeight = this.settings.metrics.height * dpr; + const width = this.canvas.width; + const height = count * rowHeight; + if (this.scratch.width !== width) this.scratch.width = width; + if (this.scratch.height < height) this.scratch.height = height; + const ctx = this.scratch.getContext('2d', { alpha: true }); + if (!ctx) { + throw new Error('Failed to get 2D rendering context'); + } + // Set every time: resizing, or a lost context, resets the context state + ctx.setTransform(dpr, 0, 0, dpr, 0, -first * rowHeight); + ctx.textBaseline = 'alphabetic'; + ctx.textAlign = 'left'; + return ctx; + } +} diff --git a/src/client/vendor/ghostty-web/render/scroll-tracker.ts b/src/client/vendor/ghostty-web/render/scroll-tracker.ts new file mode 100644 index 0000000..0a6ec12 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/scroll-tracker.ts @@ -0,0 +1,48 @@ +// webtty (ADR 043, ADR 061), not from upstream. +// The scroll tracker (object): compares a frame with the last one to tell which screen +// rows the engine changed, whether the lines moved under the canvas rows, and whether +// that forces the frame to repaint every row. + +import type { RowSource } from './frame-lines'; + +/** What changed since the last frame. */ +export interface FrameMoves { + /** The rows the engine changed, in screen rows; empty when every row is repainted anyway. */ + dirtyRows: number[]; + /** New scrollback or a changed row: every line may have moved (the screen scrolled). */ + linesMoved: boolean; + /** Every row must be repainted. */ + repaintAll: boolean; +} + +/** Remembers the last frame's viewportY and scrollback length. */ +export class ScrollTracker { + private lastViewportY = 0; + private lastScrollbackLength = 0; + + /** + * Compares this frame, at `viewportY` (whole line `view`), with the last one. A + * fractional viewportY (ghostty-web's own eased scroll) repaints every row, and so do + * lines that moved while scrolled or on a move. At the bottom, the screen's rows are + * repainted as dirty and the rows out of view when they come into view. `forceAll` + * skips the dirty-row scan. + */ + track(source: RowSource, viewportY: number, view: number, forceAll: boolean): FrameMoves { + const dirtyRows: number[] = []; + if (!forceAll) { + for (let y = 0; y < source.rows; y++) { + if (source.buffer.isRowDirty(y)) dirtyRows.push(y); + } + } + const moved = viewportY !== this.lastViewportY; + if (moved && !(Number.isInteger(viewportY) && Number.isInteger(this.lastViewportY))) { + forceAll = true; + } + this.lastViewportY = viewportY; + const linesMoved = + source.scrollbackLength !== this.lastScrollbackLength || dirtyRows.length > 0; + if (linesMoved && (view > 0 || moved)) forceAll = true; + this.lastScrollbackLength = source.scrollbackLength; + return { dirtyRows, linesMoved, repaintAll: forceAll }; + } +} diff --git a/src/client/vendor/ghostty-web/render/scrollbar-painter.ts b/src/client/vendor/ghostty-web/render/scrollbar-painter.ts new file mode 100644 index 0000000..bebf820 --- /dev/null +++ b/src/client/vendor/ghostty-web/render/scrollbar-painter.ts @@ -0,0 +1,91 @@ +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The scrollbar painter (object): paints the track and thumb over the right edge of the +// rows in view, with the geometry the terminal's hit test shares, and invalidates the rows +// it no longer covers so none keeps an old thumb (ADR 043, ADR 061). + +import type { DrawCounter, PaintSettings } from './renderable'; +import { invalidateRow } from './row-band'; +import { + SCROLLBAR_PADDING, + SCROLLBAR_WIDTH, + scrollbarThumbHeight, + scrollbarThumbY, + scrollbarTrackHeight, + scrollbarX, +} from './scrollbar-geometry'; + +/** Paints the scrollbar on the canvas and remembers which canvas rows it is over. */ +export class ScrollbarPainter { + // The first canvas row the last frame painted the scrollbar over (null: none) + private drawnRow: number | null = null; + + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly settings: PaintSettings, + private readonly counter: DrawCounter + ) {} + + /** Follows the canvas content moved by `rows` rows on a copied scroll. */ + shift(rows: number): void { + if (this.drawnRow !== null) this.drawnRow += rows; + } + + /** + * Invalidates the `rows` rows the last frame's scrollbar covered that this frame's, + * painted from canvas row `row` (null: not painted), does not, and records `row`. + */ + moveTo(state: Uint8Array, row: number | null, rows: number): void { + if (this.drawnRow !== null && this.drawnRow !== row) { + for (let k = this.drawnRow; k < this.drawnRow + rows; k++) { + if (row === null || k < row || k >= row + rows) invalidateRow(state, k); + } + } + this.drawnRow = row; + } + + /** + * Render scrollbar + * Shows scroll position and allows click/drag interaction + * @param opacity Opacity level (0-1) for fade in/out effect + * @param row The canvas row of viewport row 0: it is painted over the rows in view (webtty) + */ + paint( + ctx: CanvasRenderingContext2D, + viewportY: number, + scrollbackLength: number, + visibleRows: number, + opacity: number, + row: number + ): void { + this.counter.draws++; + const { metrics, theme, devicePixelRatio } = this.settings; + const top = row * metrics.height; + const canvasHeight = visibleRows * metrics.height; + const canvasWidth = this.canvas.width / devicePixelRatio; + + // Scrollbar dimensions (shared with the terminal's hit test) + const x = scrollbarX(canvasWidth); + const trackHeight = scrollbarTrackHeight(canvasHeight); + + // Always clear the scrollbar area first (fixes ghosting when fading out) + ctx.clearRect(x - 2, top, SCROLLBAR_WIDTH + 6, canvasHeight); + ctx.fillStyle = theme.background; + ctx.fillRect(x - 2, top, SCROLLBAR_WIDTH + 6, canvasHeight); + + // Don't draw scrollbar if fully transparent or no scrollback + if (opacity <= 0 || scrollbackLength === 0) return; + + // Calculate scrollbar thumb size and position (0 = at bottom, scrollbackLength = at top) + const thumbHeight = scrollbarThumbHeight(visibleRows, scrollbackLength, trackHeight); + const thumbY = scrollbarThumbY(viewportY, scrollbackLength, trackHeight, thumbHeight); + + // Draw scrollbar track (subtle background) with opacity + ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; + ctx.fillRect(x, top + SCROLLBAR_PADDING, SCROLLBAR_WIDTH, trackHeight); + + // Draw scrollbar thumb with opacity + const baseOpacity = viewportY > 0 ? 0.5 : 0.3; + ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; + ctx.fillRect(x, top + thumbY, SCROLLBAR_WIDTH, thumbHeight); + } +} diff --git a/src/client/vendor/ghostty-web/render/viewport-clip.ts b/src/client/vendor/ghostty-web/render/viewport-clip.ts new file mode 100644 index 0000000..075613a --- /dev/null +++ b/src/client/vendor/ghostty-web/render/viewport-clip.ts @@ -0,0 +1,91 @@ +// webtty (ADR 040, ADR 043, ADR 061), not from upstream. +// The viewport clip (object): places the drawing canvas over the terminal's canvas +// element, moves it up and clips it to the element's box so the viewport's rows show at a +// pixel offset, and reports the part in view. + +/** The drawing canvas and the part of it in view, in canvas pixels; the rest is overscan. */ +export interface VisibleArea { + canvas: HTMLCanvasElement; + y: number; + height: number; + /** The bottom pixel line of the viewport's last row, below the part in view at a fraction. */ + lastLine: number; + /** Changes whenever the canvas is drawn on, so a copy of its pixels can tell it is stale. */ + version: number; +} + +/** + * Creates the canvas the rows are drawn on, inserted before `element`. Positioned with no + * offsets, it sits where the element's box starts (its static position), wherever padding + * moves it. Positioned, it paints over the element, and lets the mouse through to it. + * (webtty, ADR 043) + */ +export function createDrawingCanvas( + doc: Pick<Document, 'createElement'>, + element: HTMLCanvasElement +): HTMLCanvasElement { + const canvas = doc.createElement('canvas'); + canvas.style.position = 'absolute'; + canvas.style.pointerEvents = 'none'; + canvas.style.display = 'block'; + // Excluded from the lookups of the terminal canvas, like the edge strips + canvas.dataset.edge = 'true'; + element.parentNode?.insertBefore(canvas, element); + return canvas; +} + +/** + * Shows `rows` rows of the canvas from a canvas row, moved down by an offset. Only style + * values that changed are written. + */ +export class ViewportClip { + // The offset the rows are shown at, in CSS pixels + private shownOffset = 0; + // CSS pixels from the canvas top to the top of what is shown + private shownTop = 0; + private shownRows = 0; + private shownTransform = ''; + private shownClip = ''; + + constructor(private readonly canvas: HTMLCanvasElement) {} + + /** The offset the viewport is shown at, in CSS pixels. */ + get offset(): number { + return this.shownOffset; + } + + /** + * Shows the `rows` rows from canvas row `top` of a canvas `total` rows of + * `cellHeight` high, moved down by `offset`: moves the canvas up by what is above them + * and clips it to the element's box. A transform places it to the device pixel; + * object-position would round to CSS pixels. + */ + show(top: number, offset: number, rows: number, total: number, cellHeight: number): void { + this.shownOffset = offset; + this.shownTop = top * cellHeight - offset; + this.shownRows = rows; + const below = (total - rows) * cellHeight - this.shownTop; + const transform = `translateY(${-this.shownTop}px)`; + const clip = `inset(${this.shownTop}px 0px ${below}px 0px)`; + if (transform !== this.shownTransform) { + this.canvas.style.transform = transform; + this.shownTransform = transform; + } + if (clip !== this.shownClip) { + this.canvas.style.clipPath = clip; + this.shownClip = clip; + } + } + + /** The part in view at ratio `dpr` and rows `cellHeight` high, tagged with `version`. */ + area(dpr: number, cellHeight: number, version: number): VisibleArea { + const bottom = this.shownTop + this.shownOffset + this.shownRows * cellHeight; + return { + canvas: this.canvas, + y: Math.round(this.shownTop * dpr), + height: this.shownRows * cellHeight * dpr, + lastLine: Math.round(bottom * dpr) - 1, + version, + }; + } +} diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index b5869c9..34b9b45 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -1,197 +1,101 @@ -/** - * Canvas Renderer for Terminal Display - * - * High-performance canvas-based renderer that draws the terminal using - * Ghostty's WASM terminal emulator. Features: - * - Font metrics measurement with DPI scaling - * - Full color support (256-color palette + RGB) - * - All text styles (bold, italic, underline, strikethrough, etc.) - * - Multiple cursor styles (block, underline, bar) - * - Dirty line optimization for 60 FPS - */ - -import type { ITheme } from './interfaces'; -import { type CellRange, cellInRange, moveRangeRows, sameRange } from './render/cell-range'; +// Derived from coder/ghostty-web lib/renderer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The canvas renderer (facade): `CanvasRenderer` plans each frame, which canvas rows of the +// overscan band to keep, copy or repaint, and hands the drawing to the painters in render/ +// on the context it chooses (ADR 040, ADR 043, ADR 061). + +import { DisposableStore, listen, toDisposable } from './lifecycle/disposable-store'; +import { CellPainter } from './render/cell-painter'; +import { DecorationTracker, type SelectionSource } from './render/decoration-tracker'; +import { CursorBlink, CursorPainter } from './render/cursor-painter'; import { DEFAULT_THEME } from './render/default-theme'; import { watchDevicePixelRatio, wholeDevicePixelRatio } from './render/device-pixel-ratio'; import { type FontMetrics, measureFont } from './render/font-metrics'; -import { frameLines } from './render/frame-lines'; +import { frameLines, type RowSource, rowContent } from './render/frame-lines'; import type { - GraphemeLookup, + CursorStyle, + DrawCounter, IRenderable, IScrollbackProvider, - RowContent, + PaintSettings, + RendererDeps, + RendererOptions, } from './render/renderable'; import { changeRow, DEFAULT_OVERSCAN, invalidateOutOfView, - invalidateRow, nextBand, - ROW_CUT, ROW_INVALID, - ROW_JOINED, - repaintRunEnd, - shiftedRowKeys, shiftedRowStates, - wantedRowState, } from './render/row-band'; -import { - SCROLLBAR_PADDING, - SCROLLBAR_WIDTH, - scrollbarThumbHeight, - scrollbarThumbY, - scrollbarTrackHeight, - scrollbarX, -} from './render/scrollbar-geometry'; -import { - type HighlightSpan, - highlightAt, - highlightRows, - type SearchHighlights, - spansKey, -} from './search'; -import type { SelectionManager } from './selection-manager'; -import type { GhosttyCell } from './types'; -import { CellFlags } from './types'; +import { RowRepainter } from './render/row-repainter'; +import { type FrameMoves, ScrollTracker } from './render/scroll-tracker'; +import { ScrollbarPainter } from './render/scrollbar-painter'; +import { createDrawingCanvas, ViewportClip, type VisibleArea } from './render/viewport-clip'; +import type { SearchHighlights } from './search'; // The facade's former contents, re-exported so deep imports keep working (ADR 061) export { DEFAULT_THEME } from './render/default-theme'; export { watchDevicePixelRatio, wholeDevicePixelRatio } from './render/device-pixel-ratio'; export type { FontMetrics } from './render/font-metrics'; export { frameLines } from './render/frame-lines'; -export type { GraphemeLookup, IRenderable, IScrollbackProvider } from './render/renderable'; +export type { + GraphemeLookup, + IRenderable, + IScrollbackProvider, + RendererDeps, + RendererOptions, +} from './render/renderable'; export { DEFAULT_OVERSCAN } from './render/row-band'; - -// ============================================================================ -// Type Definitions -// ============================================================================ - -export interface RendererOptions { - fontSize?: number; // Default: 15 - fontFamily?: string; // Default: 'monospace' - cursorStyle?: 'block' | 'underline' | 'bar'; // Default: 'block' - cursorBlink?: boolean; // Default: false - theme?: ITheme; - devicePixelRatio?: number; // Default: window.devicePixelRatio, rounded up and followed - overscan?: number; // Rows drawn above and below the viewport. Default: DEFAULT_OVERSCAN (webtty, ADR 043) -} +export { REPAINT_CHUNK_ROWS } from './render/row-repainter'; /** - * Rows a partial repaint draws on the scratch canvas at once, besides the row below: a - * longer run is repainted in chunks of this many rows, so the scratch canvas stays a few - * rows high instead of growing to the canvas' size. (webtty, ADR 043) + * Draws the terminal on a canvas of its own that holds `overscan` rows above and below the + * viewport, moved and clipped to show the viewport's part of it (webtty, ADR 043). */ -export const REPAINT_CHUNK_ROWS = 8; - -// ============================================================================ -// CanvasRenderer Class -// ============================================================================ - export class CanvasRenderer { // The terminal's canvas element: it takes the viewport's place in the layout and gets // the mouse events, and draws nothing (webtty, ADR 043) - private element: HTMLCanvasElement; + private readonly element: HTMLCanvasElement; // The canvas the rows are drawn on. It also holds the overscan rows, sits over the // element, moved up by a transform, and is clipped to the element's box (webtty, ADR 043) - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private fontSize: number; - private fontFamily: string; - private cursorStyle: 'block' | 'underline' | 'bar'; - private cursorBlink: boolean; - private theme: Required<ITheme>; - private _devicePixelRatio: number; - private stopWatchingDevicePixelRatio?: () => void; - private metrics: FontMetrics; - - // Cursor blinking state - private cursorVisible: boolean = true; - private cursorBlinkInterval?: number; - // Where the last frame drew the cursor: its canvas row (null: not drawn) and column (webtty) - private cursorRow: number | null = null; - private cursorCol: number = 0; - // Cursor style or blink changed since the last frame: repaint the cursor cell so - // the old shape does not stay under the new one (webtty) - private cursorShapeChanged: boolean = false; - - // Viewport tracking (for scrolling) - private lastViewportY: number = 0; - private lastScrollbackLength: number = 0; - - // The canvas holds `overscan` rows above and below the viewport and is moved and - // clipped to show the viewport's part of it. Canvas row k shows viewport row k - overscan at - // viewportY `bandY`, so a scroll within the band only moves the canvas - // (webtty, ADR 043). + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private readonly document: Pick<Document, 'createElement'>; + private readonly settings: PaintSettings; + private readonly counter: DrawCounter = { draws: 0 }; + private readonly decorations = new DecorationTracker(); + private readonly cells: CellPainter; + private readonly repainter: RowRepainter; + private readonly cursor: CursorPainter; + private readonly blink: CursorBlink; + private readonly scrollbar: ScrollbarPainter; + private readonly disposables = new DisposableStore(); + + private readonly moves = new ScrollTracker(); + + // Canvas row k shows viewport row k - overscan at viewportY `bandY`, so a scroll within + // the band only moves the canvas (webtty, ADR 043). private readonly overscan: number; - private bandY: number = 0; + private bandY = 0; // ROW_INVALID, ROW_JOINED or ROW_CUT per canvas row. Rows out of view stay invalid // until they come into view. private rowState: Uint8Array = new Uint8Array(0); // The pixel offset below its whole line the viewport shows at, in CSS pixels (ADR 040) - private requestedScrollOffset: number = 0; - private shownScrollOffset: number = 0; - // CSS pixels from the canvas top to the top of what is shown - private shownTop: number = 0; - private shownRows: number = 0; - private shownTransform: string = ''; - private shownClip: string = ''; - // The first canvas row the last frame painted the scrollbar over (null: none) - private scrollbarRow: number | null = null; + private requestedScrollOffset = 0; + private readonly clip: ViewportClip; // The canvas no longer matches its rows (theme, font or ratio change); the next frame // repaints every row (webtty, ADR 043) - private canvasStale: boolean = false; + private canvasStale = false; // The browser restored a lost context: the backing store is blank and the context // state reset, so the next frame resizes and repaints every row (webtty, ADR 043) - private contextRestored: boolean = false; - private readonly onContextRestored = () => { - this.contextRestored = true; - }; - // A partial repaint draws its rows here, unclipped, then copies them into the canvas - // (webtty, ADR 043; see repaintRows) - private readonly scratch: HTMLCanvasElement; - - // Current buffer being rendered (for grapheme lookups) - private currentBuffer: IRenderable | null = null; - // Row reader for the current render pass (one viewport read per frame) - private currentLine: (y: number) => GhosttyCell[] | null = () => null; - - // Selection manager (for rendering selection) - private selectionManager?: SelectionManager; - // Cached selection coordinates for current render pass, in canvas rows and not - // clamped to the viewport (webtty, ADR 043) - private currentSelectionCoords: CellRange | null = null; - // The selection the canvas rows were drawn with, in canvas rows (webtty, ADR 043) - private drawnSelection: CellRange | null = null; - - // The hovered link's range (OSC 8 or regex), in viewport rows. The engine's - // hyperlink_id is a 1-bit "has a link" flag, not an id, so the range is the only - // thing that tells one link from another. (webtty) - private hoveredLinkRange: CellRange | null = null; - // The regex link range this frame draws and the one the canvas rows were drawn with, - // in canvas rows (webtty, ADR 043) - private currentLinkRange: CellRange | null = null; - private drawnLinkRange: CellRange | null = null; - - // Search matches (webtty, ADR 055): the matches, the spans this frame draws per canvas - // row, and the spans each canvas row was drawn with (as `spansKey`) - private searchHighlights: SearchHighlights | null = null; - private searchSpans: (HighlightSpan[] | undefined)[] = []; - private drawnSearch: string[] = []; + private contextRestored = false; - constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}, deps: RendererDeps = {}) { this.element = canvas; - // Positioned with no offsets, the drawing canvas sits where the element's box starts - // (its static position), wherever padding moves it. Positioned, it paints over the - // element, and lets the mouse through to it. (webtty, ADR 043) - this.canvas = document.createElement('canvas'); - this.canvas.style.position = 'absolute'; - this.canvas.style.pointerEvents = 'none'; - this.canvas.style.display = 'block'; - // Excluded from the lookups of the terminal canvas, like the edge strips - this.canvas.dataset.edge = 'true'; - canvas.parentNode?.insertBefore(this.canvas, canvas); + this.document = deps.document ?? document; + this.canvas = createDrawingCanvas(this.document, canvas); const ctx = this.canvas.getContext('2d', { alpha: true }); if (!ctx) { throw new Error('Failed to get 2D rendering context'); @@ -199,81 +103,66 @@ export class CanvasRenderer { this.ctx = ctx; // Frames only draw what changed, so a canvas the browser blanked would stay blank // (webtty, ADR 043) - this.canvas.addEventListener('contextrestored', this.onContextRestored); - this.scratch = document.createElement('canvas'); - this.scratch.width = 0; - this.scratch.height = 0; + this.disposables.add( + listen(this.canvas, 'contextrestored', () => { + this.contextRestored = true; + }) + ); + const scratch = this.document.createElement('canvas'); - // Apply options - this.fontSize = options.fontSize ?? 15; - this.fontFamily = options.fontFamily ?? 'monospace'; - this.cursorStyle = options.cursorStyle ?? 'block'; - this.cursorBlink = options.cursorBlink ?? false; this.overscan = Math.max(1, Math.floor(options.overscan ?? DEFAULT_OVERSCAN)); - this.theme = { ...DEFAULT_THEME, ...options.theme }; - this._devicePixelRatio = wholeDevicePixelRatio( - options.devicePixelRatio ?? window.devicePixelRatio - ); + const win = () => deps.window ?? window; + this.settings = { + fontSize: options.fontSize ?? 15, + fontFamily: options.fontFamily ?? 'monospace', + theme: { ...DEFAULT_THEME, ...options.theme }, + devicePixelRatio: wholeDevicePixelRatio(options.devicePixelRatio ?? win().devicePixelRatio), + metrics: { width: 0, height: 0, baseline: 0 }, + }; // Follow ratio changes; the next render() sees the backing-store mismatch and // resizes and repaints the canvas. if (options.devicePixelRatio === undefined) { - this.stopWatchingDevicePixelRatio = watchDevicePixelRatio(window, (ratio) => { - this._devicePixelRatio = wholeDevicePixelRatio(ratio); + const stop = watchDevicePixelRatio(win(), (ratio) => { + this.settings.devicePixelRatio = wholeDevicePixelRatio(ratio); this.canvasStale = true; }); + this.disposables.add(toDisposable(stop)); } + this.settings.metrics = this.measureFont(); - // Measure font metrics - this.metrics = this.measureFont(); - - // Setup cursor blinking if enabled - if (this.cursorBlink) { - this.startCursorBlink(); - } + this.cells = new CellPainter(this.settings, this.decorations.frame, this.counter); + const { settings, counter } = this; + this.repainter = new RowRepainter(this.canvas, ctx, scratch, this.cells, settings, counter); + this.cursor = new CursorPainter(options.cursorStyle ?? 'block', this.cells, settings, counter); + this.scrollbar = new ScrollbarPainter(this.canvas, this.settings, this.counter); + this.clip = new ViewportClip(this.canvas); + this.blink = this.disposables.add(new CursorBlink(deps.timers)); + this.blink.setEnabled(options.cursorBlink ?? false); } /** The whole ratio the canvas backing store is drawn at. */ public get devicePixelRatio(): number { - return this._devicePixelRatio; + return this.settings.devicePixelRatio; } - // ========================================================================== - // Font Metrics Measurement - // ========================================================================== - private measureFont(): FontMetrics { // Use an offscreen canvas for measurement - const ctx = document.createElement('canvas').getContext('2d')!; - return measureFont(ctx, this.fontSize, this.fontFamily); + const ctx = this.document.createElement('canvas').getContext('2d')!; + return measureFont(ctx, this.settings.fontSize, this.settings.fontFamily); } - /** - * Remeasure font metrics (call after font loads or changes) - */ + /** Remeasures the font (after it loads or changes); the next frame repaints every row. */ public remeasureFont(): void { - this.metrics = this.measureFont(); + this.settings.metrics = this.measureFont(); this.canvasStale = true; } - // ========================================================================== - // Color Conversion - // ========================================================================== - - private rgbToCSS(r: number, g: number, b: number): string { - return `rgb(${r}, ${g}, ${b})`; - } - - // ========================================================================== - // Canvas Sizing - // ========================================================================== - - /** - * Resize canvas to fit terminal dimensions - */ + /** Sizes the element and the drawing canvas (with its overscan rows) for `cols` × `rows`. */ public resize(cols: number, rows: number): void { - this.drawCount++; - const cssWidth = cols * this.metrics.width; - const cssHeight = rows * this.metrics.height; + this.counter.draws++; + const { metrics, devicePixelRatio: dpr } = this.settings; + const cssWidth = cols * metrics.width; + const cssHeight = rows * metrics.height; // Set CSS size (what user sees). The element draws nothing. (webtty, ADR 043) this.element.style.width = `${cssWidth}px`; @@ -283,42 +172,34 @@ export class CanvasRenderer { // Set actual canvas size (scaled for DPI). The canvas also holds the overscan rows; // render() moves and clips it to show the viewport's rows. (webtty, ADR 043) - const canvasHeight = (rows + 2 * this.overscan) * this.metrics.height; + const canvasHeight = (rows + 2 * this.overscan) * metrics.height; this.canvas.style.width = `${cssWidth}px`; this.canvas.style.height = `${canvasHeight}px`; - this.canvas.width = Math.round(cssWidth * this.devicePixelRatio); - this.canvas.height = Math.round(canvasHeight * this.devicePixelRatio); + this.canvas.width = Math.round(cssWidth * dpr); + this.canvas.height = Math.round(canvasHeight * dpr); // Scale context to match DPI (setting canvas.width/height resets the context) - this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); + this.ctx.scale(dpr, dpr); // Set text rendering properties for crisp text this.ctx.textBaseline = 'alphabetic'; this.ctx.textAlign = 'left'; // Fill background after resize - this.ctx.fillStyle = this.theme.background; + this.ctx.fillStyle = this.settings.theme.background; this.ctx.fillRect(0, 0, cssWidth, canvasHeight); - // Free the scratch canvas' backing store; the next partial repaint sizes it - this.scratch.width = 0; - this.scratch.height = 0; + this.repainter.freeScratch(); this.canvasStale = true; } - // ========================================================================== - // Main Rendering - // ========================================================================== - /** * Render the terminal buffer to canvas * - * The canvas holds `overscan` rows above and below the viewport, and is moved and - * clipped to show the viewport's part of it. A scroll within those rows only moves the - * canvas; one past them moves the canvas content with one - * drawImage of the canvas onto itself. Only rows that come into view or changed are - * drawn, and rows out of view are drawn when they come into view. What the element - * shows is exactly a full redraw of the viewport, plus at a fraction of a line the row - * above it drawn on its own. (webtty, ADR 043; see repaintRows) + * A scroll within the overscan rows only moves the canvas; one past them moves the + * canvas content with one drawImage of the canvas onto itself. Only rows that come into + * view or changed are drawn, and rows out of view are drawn when they come into view. + * What the element shows is exactly a full redraw of the viewport, plus at a fraction of + * a line the row above it drawn on its own. (webtty, ADR 043; see RowRepainter) */ public render( buffer: IRenderable, @@ -327,821 +208,196 @@ export class CanvasRenderer { scrollbackProvider?: IScrollbackProvider, scrollbarOpacity: number = 1 ): void { - // Record the view's offset before any read (webtty, ADR 042) - buffer.setViewportOffset?.(viewportY); - - // Store buffer reference for grapheme lookups in renderCell - this.currentBuffer = buffer; - - // getCursor() calls update() internally to ensure fresh state. - // Multiple update() calls are safe - dirty state persists until clearDirty(). - const cursor = buffer.getCursor(); - // The engine's shape and blink; a change repaints the cursor row (webtty, ADR 049) - if (cursor.style !== undefined) this.setCursorStyle(cursor.style); - if (cursor.blinking !== undefined) this.setCursorBlink(cursor.blinking); - const dims = buffer.getDimensions(); - const getLine = frameLines(buffer, dims.cols, dims.rows); - this.currentLine = getLine; - const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; - const rows = dims.rows; + const { cursor, cols, source } = this.readFrame(buffer, viewportY, scrollbackProvider); + const rows = source.rows; const total = rows + 2 * this.overscan; const view = Math.max(0, Math.floor(viewportY)); - - // Check if buffer needs full redraw (e.g., screen change between normal/alternate) - if (buffer.needsFullRedraw?.()) { - forceAll = true; - } - - // Resize canvas if dimensions changed - const needsResize = - this.contextRestored || - this.canvas.width !== Math.round(dims.cols * this.metrics.width * this.devicePixelRatio) || - this.canvas.height !== Math.round(total * this.metrics.height * this.devicePixelRatio); - - if (needsResize) { - this.resize(dims.cols, dims.rows); - this.contextRestored = false; - forceAll = true; // Force full render after resize - } - if (this.rowState.length !== total) { - this.rowState = new Uint8Array(total); - forceAll = true; - } - - // A theme, font or ratio change since the last frame changes every row (webtty, ADR 043) - if (this.canvasStale) { - forceAll = true; - this.canvasStale = false; - } - - // Rows the engine changed, in screen rows - const dirtyRows: number[] = []; - if (!forceAll) { - for (let y = 0; y < rows; y++) { - if (buffer.isRowDirty(y)) dirtyRows.push(y); - } - } - - // A fractional viewportY (ghostty-web's own eased scroll) repaints every row - const moved = viewportY !== this.lastViewportY; - if (moved && !(Number.isInteger(viewportY) && Number.isInteger(this.lastViewportY))) { - forceAll = true; - } - this.lastViewportY = viewportY; - // New scrollback or a changed row may have moved every line: the screen scrolled. - // While scrolled, or on a move, that repaints every row. At the bottom, the screen's - // rows are repainted as dirty and the rows out of view when they come into view. - const linesMoved = scrollbackLength !== this.lastScrollbackLength || dirtyRows.length > 0; - if (linesMoved && (view > 0 || moved)) forceAll = true; - this.lastScrollbackLength = scrollbackLength; + forceAll = this.prepareCanvas(buffer, cols, rows, total, forceAll); + const moves = this.moves.track(source, viewportY, view, forceAll); + forceAll = moves.repaintAll; // The row above the viewport shows while it is at a fraction of a line (ADR 040) - // An offset set for a taller cell (before a font change) shows at most a line. - const maxOffset = this.metrics.height - 1 / this.devicePixelRatio; - const offset = view < scrollbackLength ? Math.min(this.requestedScrollOffset, maxOffset) : 0; + const offset = view < source.scrollbackLength ? this.clampedScrollOffset() : 0; const above = offset > 0 ? 1 : 0; - // Keep the canvas rows while the rows in view are among them, else move the band and - // copy the rows still in view (see nextBand) - let band = this.bandY; - if (forceAll) { - band = view; - } else { - band = nextBand(this.bandY, view, this.overscan, above, scrollbackLength); - const shift = band - this.bandY; - if (Math.abs(shift) >= total) { - forceAll = true; - } else if (shift !== 0) { - this.shiftRows(shift); - this.rowState = shiftedRowStates(this.rowState, shift); - if (this.cursorRow !== null) this.cursorRow += shift; - if (this.scrollbarRow !== null) this.scrollbarRow += shift; - this.drawnSelection = moveRangeRows(this.drawnSelection, shift); - this.drawnLinkRange = moveRangeRows(this.drawnLinkRange, shift); - this.drawnSearch = shiftedRowKeys(this.drawnSearch, shift, total); - } - } - this.bandY = band; + forceAll = this.moveBand(view, above, source.scrollbackLength, total, forceAll); // The canvas row of viewport row 0 - const top = this.overscan + band - view; - + const top = this.overscan + this.bandY - view; const state = this.rowState; if (forceAll) state.fill(ROW_INVALID); - if (!forceAll) { - for (const y of dirtyRows) changeRow(state, top + y); - if (linesMoved) invalidateOutOfView(state, top, rows); - } - - // The selection, in canvas rows. It is anchored to buffer lines like the canvas rows, - // so it only repaints rows when it changed. (webtty, ADR 043) - const selection = - this.selectionManager && this.selectionManager.hasSelection() - ? this.selectionManager.getSelectionRange() - : null; - this.currentSelectionCoords = moveRangeRows(selection, top); - if (!sameRange(this.currentSelectionCoords, this.drawnSelection)) { - for (const range of [this.drawnSelection, this.currentSelectionCoords]) { - if (!range) continue; - const last = Math.min(range.endRow, total - 1); - for (let k = Math.max(0, range.startRow); k <= last; k++) changeRow(state, k); - } - } - this.drawnSelection = this.currentSelectionCoords; - - // Search matches, in canvas rows; a row whose matches changed is repainted - // (webtty, ADR 055) - if (this.drawnSearch.length !== total) this.drawnSearch = new Array<string>(total).fill(''); - this.searchSpans = new Array(total); - if (this.searchHighlights) { - // Canvas row k shows screen row k - top plus the screen row at the viewport's top - const toScreen = scrollbackLength - view - top; - const spans = highlightRows(this.searchHighlights, toScreen, toScreen + total - 1, dims.cols); - for (const [row, s] of spans) this.searchSpans[row - toScreen] = s; - } - for (let k = 0; k < total; k++) { - if (spansKey(this.searchSpans[k]) !== this.drawnSearch[k]) changeRow(state, k); - } - - const rowAt = (k: number) => - this.rowAt(k - this.overscan, band, rows, getLine, buffer, scrollbackProvider, scrollbackLength); - - // A link underline is drawn at viewport rows, which are other canvas rows after a - // scroll: clear it where it was drawn and draw it where it is. - this.currentLinkRange = moveRangeRows(this.hoveredLinkRange, top); - if (!sameRange(this.currentLinkRange, this.drawnLinkRange)) { - for (const range of [this.drawnLinkRange, this.currentLinkRange]) { - if (!range) continue; - for (let k = range.startRow; k <= range.endRow; k++) changeRow(state, k); - } - } - this.drawnLinkRange = this.currentLinkRange; + else this.invalidateMoved(moves, top, rows); + this.decorations.mark(state, top, view, cols, source.scrollbackLength); // The cursor is drawn over its cell after the rows. Repaint the cell the last frame's // cursor is in now unless the same cursor is drawn there again. - const drawsCursor = view === 0 && cursor.visible && this.cursorVisible; + const drawsCursor = view === 0 && cursor.visible && this.blink.visible; const cursorRow = top + cursor.y; - let sameCursor = false; - if (this.cursorRow !== null) { - sameCursor = - drawsCursor && - this.cursorRow === cursorRow && - cursor.x === this.cursorCol && - !this.cursorShapeChanged; - if (!sameCursor) invalidateRow(state, this.cursorRow); - } - this.cursorShapeChanged = false; - + const sameCursor = this.cursor.invalidateDrawn(state, drawsCursor, cursorRow, cursor.x); // The scrollbar is painted over the right edge of the rows in view. Rows it no longer // covers are repainted, so none keeps the old thumb. const paintsScrollbar = !!scrollbackProvider && scrollbarOpacity > 0; - const scrollbarRow = paintsScrollbar ? top : null; - if (this.scrollbarRow !== null && this.scrollbarRow !== scrollbarRow) { - for (let k = this.scrollbarRow; k < this.scrollbarRow + rows; k++) { - if (scrollbarRow === null || k < scrollbarRow || k >= scrollbarRow + rows) { - invalidateRow(state, k); - } - } - } + this.scrollbar.moveTo(state, paintsScrollbar ? top : null, rows); - // Repaint the rows in view that are not as a full redraw of the viewport leaves them: - // the last row and the row above the viewport have nothing drawn into them from below. - const first = top - above; + const band = this.bandY; + const rowAt = (k: number) => rowContent(source, k - this.overscan, band); const last = top + rows - 1; - const repainted = new Uint8Array(total); - for (let k = first; k <= last; k++) { - if (state[k] === wantedRowState(k, top, last)) continue; - const end = repaintRunEnd(state, k, top, last); - this.repaintRows(k, end, wantedRowState(end, top, last) === ROW_CUT, dims.cols, rowAt); - repainted.fill(1, k, end + 1); - k = end; - } - for (let k = 0; k < total; k++) { - if (repainted[k]) this.drawnSearch[k] = spansKey(this.searchSpans[k]); - } - - // Selection highlighting is now integrated into renderCellBackground/renderCellText - // No separate overlay pass needed - this fixes z-order issues with complex glyphs - - // Link underlines are drawn during cell rendering (see renderCell) + const repainted = this.repainter.repaintView(state, top - above, top, last, cols, rowAt); + this.decorations.repainted(repainted); // Render cursor (only if we're at the bottom, not scrolled). The same cursor over a // row that was not repainted is still on the canvas: drawing it again would blend a // translucent cursor colour over itself until it is opaque. if (drawsCursor && !(sameCursor && !repainted[cursorRow])) { - this.renderCursor(cursor.x, cursorRow, cursor.y); + this.cursor.paint(this.ctx, cursor.x, cursorRow, cursor.y, source); } - this.cursorRow = drawsCursor ? cursorRow : null; - this.cursorCol = cursor.x; - + this.cursor.drawnAt(drawsCursor ? cursorRow : null, cursor.x); // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) if (paintsScrollbar) { - this.renderScrollbar(viewportY, scrollbackLength, rows, scrollbarOpacity, top); - } - this.scrollbarRow = scrollbarRow; - - // Show the viewport's rows, moved down by the offset: move the canvas up by what is - // above them and clip it to the element's box. A transform places it to the device - // pixel; object-position would round to CSS pixels. - this.shownScrollOffset = offset; - this.shownTop = top * this.metrics.height - offset; - this.shownRows = rows; - const below = (total - rows) * this.metrics.height - this.shownTop; - const transform = `translateY(${-this.shownTop}px)`; - const clip = `inset(${this.shownTop}px 0px ${below}px 0px)`; - if (transform !== this.shownTransform) { - this.canvas.style.transform = transform; - this.shownTransform = transform; - } - if (clip !== this.shownClip) { - this.canvas.style.clipPath = clip; - this.shownClip = clip; + const { scrollbackLength } = source; + this.scrollbar.paint(this.ctx, viewportY, scrollbackLength, rows, scrollbarOpacity, top); } + this.clip.show(top, offset, rows, total, this.settings.metrics.height); // Without scrollback rows a scrolled frame left its top rows unpainted if (viewportY > 0 && !scrollbackProvider) this.canvasStale = true; - - // ALWAYS clear dirty flags after rendering, regardless of forceAll. - // This is critical - if we don't clear after a full redraw, the dirty - // state persists and the next frame might not detect new changes properly. + // ALWAYS clear dirty flags after rendering, regardless of forceAll: if they persisted + // after a full redraw, the next frame might not detect new changes properly. buffer.clearDirty(); } /** - * The line at viewport row `y` for `viewportY`, from scrollback or the screen, with a - * grapheme lookup on the same source: canvas row y is not screen row y while scrolled. - * Null above the scrollback and below the screen. (webtty) + * Reads what a frame starts from: records the view's offset before any read + * (webtty, ADR 042), then the cursor, whose engine shape and blink are drawn (a change + * repaints the cursor row; webtty, ADR 049), the size, and where the rows come from. */ - private rowAt( - y: number, - viewportY: number, - rows: number, - getLine: (y: number) => GhosttyCell[] | null, + private readFrame( buffer: IRenderable, - scrollbackProvider: IScrollbackProvider | undefined, - scrollbackLength: number - ): RowContent { - if (y < viewportY) { - // This row is from scrollback, counted from its end - const scrollbackOffset = scrollbackLength - viewportY + y; - if (!scrollbackProvider || scrollbackOffset < 0) return { line: null }; - return { - line: scrollbackProvider.getScrollbackLine(scrollbackOffset), - grapheme: this.scrollbackGraphemes(scrollbackProvider, scrollbackOffset), - }; - } - // This row is from the screen - const screenRow = y - viewportY; - if (screenRow >= rows) return { line: null }; - return { line: getLine(screenRow), grapheme: this.screenGraphemes(buffer, screenRow) }; - } - - /** - * Repaints canvas rows `first` to `last` exactly as a full redraw leaves them. A full - * redraw draws every row in order, each clearing its own cell area first, so a row's - * pixels are its own cells plus whatever the next line draws over it (tall glyphs, - * combining marks); what the row above overflows into it is cleared. So the rows are - * drawn in order, plus the next line unless the last row is `cut`: drawn with its own - * cells only, as the last row of a full redraw is. - * - * The rows are drawn on the scratch canvas and copied into the canvas, unless they are - * cut and the rows they draw into around them are invalid anyway. A run that is not cut - * ends before a row that is kept. Drawing them clipped to the rows is - * not the same: Chrome skips a glyph whose box is outside the clip, so a combining mark - * of the next line loses the antialiased edge it draws on the last pixel line. - * A long run is drawn on the scratch canvas in chunks of `REPAINT_CHUNK_ROWS` rows, each - * with the next line, which leaves every row as the whole run drawn at once would: a - * row's pixels depend only on its own line and the next. - * (webtty, ADR 043) - */ - private repaintRows( - first: number, - last: number, - cut: boolean, - cols: number, - rowAt: (k: number) => RowContent - ): void { - this.drawCount++; - const state = this.rowState; - const total = state.length; - const free = (k: number) => k < 0 || k >= total || state[k] === ROW_INVALID; - // Drawn on the canvas, the row above gets the first row's overflow and the row below - // the last one's - if (cut && free(first - 1) && free(last + 1)) { - for (let k = first; k <= last; k++) this.drawRow(k, cols, rowAt(k)); - } else { - for (let from = first; from <= last; from += REPAINT_CHUNK_ROWS) { - const to = Math.min(from + REPAINT_CHUNK_ROWS - 1, last); - this.repaintChunk(from, to, cut && to === last, cols, rowAt); - } - } - for (let k = first; k <= last; k++) state[k] = ROW_JOINED; - state[last] = cut ? ROW_CUT : ROW_JOINED; - } - - /** - * Draws canvas rows `first` to `last`, plus the next line unless `cut`, on the scratch - * canvas and copies rows `first` to `last` into the canvas. (webtty, ADR 043) - */ - private repaintChunk( - first: number, - last: number, - cut: boolean, - cols: number, - rowAt: (k: number) => RowContent - ): void { - const end = cut ? last : last + 1; - const own = this.ctx; - const rowHeight = this.metrics.height * this.devicePixelRatio; - this.ctx = this.scratchContext(first, end - first + 1); - try { - for (let k = first; k <= end; k++) this.drawRow(k, cols, rowAt(k)); - } finally { - this.ctx = own; - } - // Replace the rows' pixels in device pixels: cleared first, the copy is not blended - // over them, so a translucent background does not darken - const width = this.canvas.width; - const top = first * rowHeight; - const height = (last - first + 1) * rowHeight; - own.save(); - own.setTransform(1, 0, 0, 1, 0, 0); - own.globalAlpha = 1; - own.globalCompositeOperation = 'source-over'; - own.imageSmoothingEnabled = false; - own.clearRect(0, top, width, height); - own.drawImage(this.scratch, 0, 0, width, height, 0, top, width, height); - own.restore(); - } - - /** Draws canvas row `k`; a row with no line (past the buffer's ends) is left blank. */ - private drawRow( - k: number, - cols: number, - { line, grapheme }: RowContent - ): void { - this.drawCount++; - if (line) { - this.renderLine(line, k, cols, grapheme); - return; - } - const y = k * this.metrics.height; - const width = cols * this.metrics.width; - this.ctx.clearRect(0, y, width, this.metrics.height); - this.ctx.fillStyle = this.theme.background; - this.ctx.fillRect(0, y, width, this.metrics.height); + viewportY: number, + scrollback: IScrollbackProvider | undefined + ) { + buffer.setViewportOffset?.(viewportY); + // getCursor() calls update() internally to ensure fresh state. + // Multiple update() calls are safe - dirty state persists until clearDirty(). + const cursor = buffer.getCursor(); + if (cursor.style !== undefined) this.setCursorStyle(cursor.style); + if (cursor.blinking !== undefined) this.setCursorBlink(cursor.blinking); + const { cols, rows } = buffer.getDimensions(); + const source: RowSource = { + buffer, + getLine: frameLines(buffer, cols, rows), + rows, + scrollback, + scrollbackLength: scrollback ? scrollback.getScrollbackLength() : 0, + }; + return { cursor, cols, source }; } - /** - * The scratch canvas' context, as wide as the canvas and at least `count` rows high, - * scaled like the canvas and moved up so canvas row `first` is drawn at its top. - * (webtty, ADR 043) - */ - private scratchContext(first: number, count: number): CanvasRenderingContext2D { - const rowHeight = this.metrics.height * this.devicePixelRatio; - const width = this.canvas.width; - const height = count * rowHeight; - if (this.scratch.width !== width) this.scratch.width = width; - if (this.scratch.height < height) this.scratch.height = height; - const ctx = this.scratch.getContext('2d', { alpha: true }); - if (!ctx) { - throw new Error('Failed to get 2D rendering context'); - } - // Set every time: resizing, or a lost context, resets the context state - ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, -first * rowHeight); - ctx.textBaseline = 'alphabetic'; - ctx.textAlign = 'left'; - return ctx; + /** The requested offset, at most a line: one set for a taller cell (before a font change). */ + private clampedScrollOffset(): number { + const maxOffset = this.settings.metrics.height - 1 / this.devicePixelRatio; + return Math.min(this.requestedScrollOffset, maxOffset); } - /** - * Moves the canvas content down by `rows` rows (up when negative) with one drawImage of - * the canvas onto itself, in device pixels. The 'copy' operator replaces pixels instead - * of blending onto them (a translucent background would darken) and clears the rest of - * the canvas: the rows that came in, which are invalid. (webtty, ADR 043) - */ - private shiftRows(rows: number): void { - this.drawCount++; - const { width, height } = this.canvas; - const dy = rows * this.metrics.height * this.devicePixelRatio; - const kept = height - Math.abs(dy); - const ctx = this.ctx; - ctx.save(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = 'copy'; - ctx.imageSmoothingEnabled = false; - ctx.drawImage( - this.canvas, - 0, - Math.max(0, -dy), - width, - kept, - 0, - Math.max(0, dy), - width, - kept - ); - ctx.restore(); + /** Invalidates the rows the engine changed and, when the lines moved, the rows out of view. */ + private invalidateMoved(moves: FrameMoves, top: number, rows: number): void { + for (const y of moves.dirtyRows) changeRow(this.rowState, top + y); + if (moves.linesMoved) invalidateOutOfView(this.rowState, top, rows); } /** - * Render a single line using two-pass approach: - * 1. First pass: Draw all cell backgrounds - * 2. Second pass: Draw all cell text and decorations - * - * This two-pass approach is necessary for proper rendering of complex scripts - * like Devanagari where diacritics (like vowel sign ि) can extend LEFT of the - * base character into the previous cell's visual area. If we draw backgrounds - * and text in a single pass (cell by cell), the background of cell N would - * cover any left-extending portions of graphemes from cell N-1. + * Resizes the canvas when its size, ratio or context no longer match, and sizes the row + * states; returns whether every row must be repainted. */ - private renderLine( - line: GhosttyCell[], - y: number, + private prepareCanvas( + buffer: IRenderable, cols: number, - grapheme: GraphemeLookup = this.screenGraphemes(this.currentBuffer, y) - ): void { - this.drawCount++; - const lineY = y * this.metrics.height; - const lineWidth = cols * this.metrics.width; - - // Clear line background then fill with theme color. - // We clear just the cell area - glyph overflow is handled by also - // redrawing adjacent rows (see render() method). - // clearRect is needed because fillRect composites rather than replaces, - // so transparent/translucent backgrounds wouldn't clear previous content. - this.ctx.clearRect(0, lineY, lineWidth, this.metrics.height); - this.ctx.fillStyle = this.theme.background; - this.ctx.fillRect(0, lineY, lineWidth, this.metrics.height); - - // PASS 1: Draw all cell backgrounds first - // This ensures all backgrounds are painted before any text, allowing text - // to "bleed" across cell boundaries without being covered by adjacent backgrounds - for (let x = 0; x < line.length; x++) { - const cell = line[x]; - if (cell.width === 0) continue; // Skip spacer cells for wide characters - this.renderCellBackground(cell, x, y); - } - - // PASS 2: Draw all cell text and decorations - // Now text can safely extend beyond cell boundaries (for complex scripts) - for (let x = 0; x < line.length; x++) { - const cell = line[x]; - if (cell.width === 0) continue; // Skip spacer cells for wide characters - this.renderCellText(cell, x, y, undefined, grapheme); - } - } - - /** Grapheme lookup for a screen row. */ - private screenGraphemes(buffer: IRenderable | null, row: number): GraphemeLookup { - return (col) => buffer?.getGraphemeString?.(row, col) ?? ' '; - } - - /** Grapheme lookup for a scrollback row. */ - private scrollbackGraphemes(provider: IScrollbackProvider, offset: number): GraphemeLookup { - return (col) => provider.getScrollbackGraphemeString?.(offset, col) ?? ' '; - } - - /** - * Render a cell's background only (Pass 1 of two-pass rendering) - * Selection highlighting is integrated here to avoid z-order issues with - * complex glyphs (like Devanagari) that extend outside their cell bounds. - */ - private renderCellBackground(cell: GhosttyCell, x: number, y: number): void { - const cellX = x * this.metrics.width; - const cellY = y * this.metrics.height; - const cellWidth = this.metrics.width * cell.width; - - // Check if this cell is selected - const isSelected = cellInRange(this.currentSelectionCoords, x, y); - - if (isSelected) { - // Draw selection background (solid color, not overlay) - this.ctx.fillStyle = this.theme.selectionBackground; - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); - return; // Selection background replaces cell background - } - - // A search match replaces it too, under the selection as in Ghostty (webtty, ADR 055) - const match = highlightAt(this.searchSpans[y], x); - if (match) { - this.ctx.fillStyle = - match === 2 ? this.theme.searchSelectedBackground : this.theme.searchBackground; - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); - return; - } - - // Extract background color and handle inverse - let bg_r = cell.bg_r, - bg_g = cell.bg_g, - bg_b = cell.bg_b; - - if (cell.flags & CellFlags.INVERSE) { - // When inverted, background becomes foreground - bg_r = cell.fg_r; - bg_g = cell.fg_g; - bg_b = cell.fg_b; - } - - // Only draw cell background if it's different from the default (black) - // This lets the theme background (drawn earlier) show through for default cells - const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; - if (!isDefaultBg) { - this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); - } - } - - /** - * Render a cell's text and decorations (Pass 2 of two-pass rendering) - * Selection foreground color is applied here to match the selection background. - */ - private renderCellText( - cell: GhosttyCell, - x: number, - y: number, - colorOverride?: string, - grapheme: GraphemeLookup = this.screenGraphemes(this.currentBuffer, y) - ): void { - const cellX = x * this.metrics.width; - const cellY = y * this.metrics.height; - const cellWidth = this.metrics.width * cell.width; - - // Skip rendering if invisible - if (cell.flags & CellFlags.INVISIBLE) { - return; - } - - // Check if this cell is selected - const isSelected = cellInRange(this.currentSelectionCoords, x, y); - - // Set text style - let fontStyle = ''; - if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic '; - if (cell.flags & CellFlags.BOLD) fontStyle += 'bold '; - this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`; - - // Set text color - use override, selection foreground, or normal color - if (colorOverride) { - this.ctx.fillStyle = colorOverride; - } else if (isSelected) { - this.ctx.fillStyle = this.theme.selectionForeground; - } else if (highlightAt(this.searchSpans[y], x)) { - this.ctx.fillStyle = - highlightAt(this.searchSpans[y], x) === 2 - ? this.theme.searchSelectedForeground - : this.theme.searchForeground; - } else { - // Extract colors and handle inverse - let fg_r = cell.fg_r, - fg_g = cell.fg_g, - fg_b = cell.fg_b; - - if (cell.flags & CellFlags.INVERSE) { - // When inverted, foreground becomes background - fg_r = cell.bg_r; - fg_g = cell.bg_g; - fg_b = cell.bg_b; - } - - this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b); - } - - // Apply faint effect - if (cell.flags & CellFlags.FAINT) { - this.ctx.globalAlpha = 0.5; - } - - // Draw text - const textX = cellX; - const textY = cellY + this.metrics.baseline; - - // Get the character to render - use grapheme lookup for complex scripts - let char: string; - if (cell.grapheme_len > 0) { - // Cell has additional codepoints - get full grapheme cluster - char = grapheme(x); - } else { - // Simple cell - single codepoint - char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null - } - this.ctx.fillText(char, textX, textY); - - // Reset alpha - if (cell.flags & CellFlags.FAINT) { - this.ctx.globalAlpha = 1.0; - } - - // Draw underline - if (cell.flags & CellFlags.UNDERLINE) { - const underlineY = cellY + this.metrics.baseline + 2; - this.ctx.strokeStyle = this.ctx.fillStyle; - this.ctx.lineWidth = 1; - this.ctx.beginPath(); - this.ctx.moveTo(cellX, underlineY); - this.ctx.lineTo(cellX + cellWidth, underlineY); - this.ctx.stroke(); + rows: number, + total: number, + forceAll: boolean + ): boolean { + // Check if buffer needs full redraw (e.g., screen change between normal/alternate) + if (buffer.needsFullRedraw?.()) forceAll = true; + const { metrics, devicePixelRatio: dpr } = this.settings; + const needsResize = + this.contextRestored || + this.canvas.width !== Math.round(cols * metrics.width * dpr) || + this.canvas.height !== Math.round(total * metrics.height * dpr); + if (needsResize) { + this.resize(cols, rows); + this.contextRestored = false; + forceAll = true; } - - // Draw strikethrough - if (cell.flags & CellFlags.STRIKETHROUGH) { - const strikeY = cellY + this.metrics.height / 2; - this.ctx.strokeStyle = this.ctx.fillStyle; - this.ctx.lineWidth = 1; - this.ctx.beginPath(); - this.ctx.moveTo(cellX, strikeY); - this.ctx.lineTo(cellX + cellWidth, strikeY); - this.ctx.stroke(); + if (this.rowState.length !== total) { + this.rowState = new Uint8Array(total); + forceAll = true; } - - // Draw the hovered link's underline (OSC 8 or plain text URL), in canvas rows - // (webtty, ADR 043) - if (cellInRange(this.currentLinkRange, x, y)) { - const underlineY = cellY + this.metrics.baseline + 2; - this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover - this.ctx.lineWidth = 1; - this.ctx.beginPath(); - this.ctx.moveTo(cellX, underlineY); - this.ctx.lineTo(cellX + cellWidth, underlineY); - this.ctx.stroke(); + // A theme, font or ratio change since the last frame changes every row (webtty, ADR 043) + if (this.canvasStale) { + forceAll = true; + this.canvasStale = false; } + return forceAll; } /** - * Render cursor at canvas row `y`, which shows screen row `screenRow` (webtty) + * Keeps the canvas rows while the rows in view are among them, else moves the band and + * copies the rows still in view (see nextBand); returns whether every row must be + * repainted. */ - private renderCursor(x: number, y: number, screenRow: number): void { - this.drawCount++; - const cursorX = x * this.metrics.width; - const cursorY = y * this.metrics.height; - - this.ctx.fillStyle = this.theme.cursor; - - switch (this.cursorStyle) { - case 'block': - // Full cell block - this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); - // Re-draw character under cursor with cursorAccent color - { - const line = this.currentLine(screenRow); - if (line?.[x]) { - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.rect(cursorX, cursorY, this.metrics.width, this.metrics.height); - this.ctx.clip(); - // The grapheme of the screen row, not of the canvas row (webtty, ADR 043) - this.renderCellText( - line[x], - x, - y, - this.theme.cursorAccent, - this.screenGraphemes(this.currentBuffer, screenRow) - ); - this.ctx.restore(); - } - } - break; - - case 'underline': - // Underline at bottom of cell - const underlineHeight = Math.max(2, Math.floor(this.metrics.height * 0.15)); - this.ctx.fillRect( - cursorX, - cursorY + this.metrics.height - underlineHeight, - this.metrics.width, - underlineHeight - ); - break; - - case 'bar': - // Vertical bar at left of cell - const barWidth = Math.max(2, Math.floor(this.metrics.width * 0.15)); - this.ctx.fillRect(cursorX, cursorY, barWidth, this.metrics.height); - break; + private moveBand( + view: number, + above: number, + scrollbackLength: number, + total: number, + forceAll: boolean + ): boolean { + if (forceAll) { + this.bandY = view; + return true; } - } - - // ========================================================================== - // Cursor Blinking - // ========================================================================== - - private startCursorBlink(): void { - // xterm.js uses ~530ms blink interval - this.cursorBlinkInterval = window.setInterval(() => { - this.cursorVisible = !this.cursorVisible; - // Note: Render loop should redraw cursor line automatically - }, 530); - } - - private stopCursorBlink(): void { - if (this.cursorBlinkInterval !== undefined) { - clearInterval(this.cursorBlinkInterval); - this.cursorBlinkInterval = undefined; + const band = nextBand(this.bandY, view, this.overscan, above, scrollbackLength); + const shift = band - this.bandY; + this.bandY = band; + if (Math.abs(shift) >= total) return true; + if (shift !== 0) { + this.repainter.shiftRows(shift); + this.rowState = shiftedRowStates(this.rowState, shift); + this.cursor.shift(shift); + this.scrollbar.shift(shift); + this.decorations.shift(shift, total); } - this.cursorVisible = true; + return false; } - // ========================================================================== - // Public API - // ========================================================================== - - /** - * Update font size - */ + /** Sets the font size; the next frame repaints every row. */ public setFontSize(size: number): void { - this.fontSize = size; - this.metrics = this.measureFont(); - this.canvasStale = true; + this.settings.fontSize = size; + this.remeasureFont(); } - /** - * Update font family - */ + /** Sets the font family; the next frame repaints every row. */ public setFontFamily(family: string): void { - this.fontFamily = family; - this.metrics = this.measureFont(); - this.canvasStale = true; + this.settings.fontFamily = family; + this.remeasureFont(); } - /** - * Update cursor style - */ - public setCursorStyle(style: 'block' | 'underline' | 'bar'): void { - if (style !== this.cursorStyle) this.cursorShapeChanged = true; - this.cursorStyle = style; + /** Sets the cursor shape; a change repaints the cursor cell on the next frame. */ + public setCursorStyle(style: CursorStyle): void { + this.cursor.setStyle(style); } - /** - * Enable/disable cursor blinking - */ + /** Turns the cursor blink on or off; a change repaints the cursor cell on the next frame. */ public setCursorBlink(enabled: boolean): void { - if (enabled !== this.cursorBlink) this.cursorShapeChanged = true; - if (enabled && !this.cursorBlink) { - this.cursorBlink = true; - this.startCursorBlink(); - } else if (!enabled && this.cursorBlink) { - this.cursorBlink = false; - this.stopCursorBlink(); - } - } - - /** - * Get current font metrics - */ - - /** - * Render scrollbar (Phase 2) - * Shows scroll position and allows click/drag interaction - * @param opacity Opacity level (0-1) for fade in/out effect - * @param row The canvas row of viewport row 0: it is painted over the rows in view (webtty) - */ - private renderScrollbar( - viewportY: number, - scrollbackLength: number, - visibleRows: number, - opacity: number = 1, - row: number = 0 - ): void { - this.drawCount++; - const ctx = this.ctx; - const top = row * this.metrics.height; - const canvasHeight = visibleRows * this.metrics.height; - const canvasWidth = this.canvas.width / this.devicePixelRatio; - - // Scrollbar dimensions (shared with the terminal's hit test) - const x = scrollbarX(canvasWidth); - const trackHeight = scrollbarTrackHeight(canvasHeight); - - // Always clear the scrollbar area first (fixes ghosting when fading out) - ctx.clearRect(x - 2, top, SCROLLBAR_WIDTH + 6, canvasHeight); - ctx.fillStyle = this.theme.background; - ctx.fillRect(x - 2, top, SCROLLBAR_WIDTH + 6, canvasHeight); - - // Don't draw scrollbar if fully transparent or no scrollback - if (opacity <= 0 || scrollbackLength === 0) return; - - // Calculate scrollbar thumb size and position (0 = at bottom, scrollbackLength = at top) - const thumbHeight = scrollbarThumbHeight(visibleRows, scrollbackLength, trackHeight); - const thumbY = scrollbarThumbY(viewportY, scrollbackLength, trackHeight, thumbHeight); - - // Draw scrollbar track (subtle background) with opacity - ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; - ctx.fillRect(x, top + SCROLLBAR_PADDING, SCROLLBAR_WIDTH, trackHeight); - - // Draw scrollbar thumb with opacity - const isScrolled = viewportY > 0; - const baseOpacity = isScrolled ? 0.5 : 0.3; - ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; - ctx.fillRect(x, top + thumbY, SCROLLBAR_WIDTH, thumbHeight); + if (enabled !== this.blink.enabled) this.cursor.markShapeChanged(); + this.blink.setEnabled(enabled); } + /** The cell size and baseline, a copy. */ public getMetrics(): FontMetrics { - return { ...this.metrics }; + return { ...this.settings.metrics }; } - /** - * Get canvas element (needed by SelectionManager) - */ + /** The terminal's canvas element (needed by SelectionManager). */ public getCanvas(): HTMLCanvasElement { return this.element; } @@ -1159,11 +415,8 @@ export class CanvasRenderer { * The offset the viewport is shown at, in CSS pixels: viewport row 0 starts * this far below the element's top. Hit testing adds it. (webtty, ADR 043) */ - /** Counts the calls that draw on the canvas: `visibleArea.version`. (webtty) */ - private drawCount = 0; - public get scrollOffset(): number { - return this.shownScrollOffset; + return this.clip.offset; } /** @@ -1172,29 +425,13 @@ export class CanvasRenderer { * which at a fraction of a line is below the part in view. `version` changes whenever * the canvas is drawn on, so a copy of its pixels can tell it is stale. (webtty, ADR 043) */ - public get visibleArea(): { - canvas: HTMLCanvasElement; - y: number; - height: number; - lastLine: number; - version: number; - } { - const dpr = this.devicePixelRatio; - const bottom = this.shownTop + this.shownScrollOffset + this.shownRows * this.metrics.height; - return { - canvas: this.canvas, - y: Math.round(this.shownTop * dpr), - height: this.shownRows * this.metrics.height * dpr, - lastLine: Math.round(bottom * dpr) - 1, - version: this.drawCount, - }; + public get visibleArea(): VisibleArea { + return this.clip.area(this.devicePixelRatio, this.settings.metrics.height, this.counter.draws); } - /** - * Set selection manager (for rendering selection) - */ - public setSelectionManager(manager: SelectionManager): void { - this.selectionManager = manager; + /** Sets the selection to draw; rows whose selection changed are repainted next frame. */ + public setSelectionManager(manager: SelectionSource): void { + this.decorations.setSelectionSource(manager); } /** @@ -1202,7 +439,7 @@ export class CanvasRenderer { * repainted on the next frame. (webtty, ADR 055) */ public setSearchHighlights(highlights: SearchHighlights | null): void { - this.searchHighlights = highlights; + this.decorations.setSearchHighlights(highlights); } /** @@ -1210,58 +447,43 @@ export class CanvasRenderer { * Pass null to clear the hover state */ public setHoveredLinkRange( - range: { - startX: number; - startY: number; - endX: number; - endY: number; - } | null + range: { startX: number; startY: number; endX: number; endY: number } | null ): void { - this.hoveredLinkRange = range && { - startCol: range.startX, - startRow: range.startY, - endCol: range.endX, - endRow: range.endY, - }; + this.decorations.setHoveredLink( + range && { + startCol: range.startX, + startRow: range.startY, + endCol: range.endX, + endRow: range.endY, + } + ); } - /** - * Get character cell width (for coordinate conversion) - */ + /** Character cell width (for coordinate conversion). */ public get charWidth(): number { - return this.metrics.width; + return this.settings.metrics.width; } - /** - * Get character cell height (for coordinate conversion) - */ + /** Character cell height (for coordinate conversion). */ public get charHeight(): number { - return this.metrics.height; + return this.settings.metrics.height; } - /** - * Clear entire canvas - */ + /** Clears the entire canvas; the next frame repaints every row. */ public clear(): void { - this.drawCount++; + this.counter.draws++; // clearRect first because fillRect composites rather than replaces, // so transparent/translucent backgrounds wouldn't clear previous content. this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.ctx.fillStyle = this.theme.background; + this.ctx.fillStyle = this.settings.theme.background; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this.canvasStale = true; } - /** - * Cleanup resources - */ + /** Stops the blink and the ratio watch, removes the drawing canvas and frees the scratch. */ public dispose(): void { - this.stopCursorBlink(); - this.stopWatchingDevicePixelRatio?.(); - this.canvas.removeEventListener('contextrestored', this.onContextRestored); + this.disposables.dispose(); this.canvas.remove?.(); - // Free the scratch canvas' backing store - this.scratch.width = 0; - this.scratch.height = 0; + this.repainter.freeScratch(); } } diff --git a/src/client/vendor/tests/render/cell-painter.test.ts b/src/client/vendor/tests/render/cell-painter.test.ts new file mode 100644 index 0000000..1c8fb10 --- /dev/null +++ b/src/client/vendor/tests/render/cell-painter.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from 'bun:test'; +import { CellFlags, type GhosttyCell } from '../../ghostty-web/engine/cell-types'; +import { + CellPainter, + cellBackground, + cellForeground, + LINK_UNDERLINE_COLOR, + rgbToCSS, +} from '../../ghostty-web/render/cell-painter'; +import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; +import type { FrameDecorations, PaintSettings } from '../../ghostty-web/render/renderable'; +import { CanvasRenderer } from '../../ghostty-web/renderer'; +import { recordingCanvas } from '../support/raster-canvas'; +import { + COLS, + ROWS, + rasterRenderer, + selectionOf, + writtenTerminal, +} from '../support/renderer-harness'; +import { loadGhostty } from '../support/wasm'; + +const theme = DEFAULT_THEME; + +/** A cell with a red foreground and a blue background, plus `flags`. */ +function cell(flags = 0, overrides: Partial<GhosttyCell> = {}): GhosttyCell { + return { + codepoint: 0x41, + fg_r: 200, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 90, + flags, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + ...overrides, + }; +} + +describe('cell colours', () => { + test('rgbToCSS writes the canvas colour', () => { + expect(rgbToCSS(1, 2, 3)).toBe('rgb(1, 2, 3)'); + }); + + // The selection over a search match (the selected one in its own colour) over the cell + test.each([ + ['selected', true, 0, cell(), theme.selectionBackground], + ['selected over a match', true, 2, cell(), theme.selectionBackground], + ['a match', false, 1, cell(), theme.searchBackground], + ['the selected match', false, 2, cell(), theme.searchSelectedBackground], + ['its own', false, 0, cell(), 'rgb(0, 0, 90)'], + ['inverse: its foreground', false, 0, cell(CellFlags.INVERSE), 'rgb(200, 0, 0)'], + ['default black: none', false, 0, cell(0, { bg_b: 0 }), null], + ['inverse of a black foreground: none', false, 0, cell(CellFlags.INVERSE, { fg_r: 0 }), null], + ] as const)('background: %s', (_name, selected, match, c, expected) => { + expect(cellBackground(c, selected, match, theme)).toBe(expected); + }); + + test.each([ + ['selected', true, 0, cell(), theme.selectionForeground], + ['selected over a match', true, 1, cell(), theme.selectionForeground], + ['a match', false, 1, cell(), theme.searchForeground], + ['the selected match', false, 2, cell(), theme.searchSelectedForeground], + ['its own', false, 0, cell(), 'rgb(200, 0, 0)'], + ['inverse: its background', false, 0, cell(CellFlags.INVERSE), 'rgb(0, 0, 90)'], + ] as const)('foreground: %s', (_name, selected, match, c, expected) => { + expect(cellForeground(c, selected, match, theme)).toBe(expected); + }); +}); + +/** A context that records calls and writes as strings. */ +function recordingContext() { + const calls: string[] = []; + const state: Record<string, unknown> = {}; + const ctx = new Proxy(state, { + get: (t, key) => + typeof key === 'string' && key in t + ? t[key] + : (...args: unknown[]) => calls.push(`${String(key)}(${args.join(',')})`), + set: (t, key, value) => { + t[key as string] = value; + calls.push(`${String(key)}=${value}`); + return true; + }, + }); + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +describe('CellPainter', () => { + const settings: PaintSettings = { + fontSize: 15, + fontFamily: 'mono', + metrics: { width: 8, height: 15, baseline: 11 }, + theme, + devicePixelRatio: 1, + }; + + function painter(frame: Partial<FrameDecorations> = {}) { + const counter = { draws: 0 }; + const decorations = { selection: null, searchSpans: [], link: null, ...frame }; + const { ctx, calls } = recordingContext(); + return { p: new CellPainter(settings, decorations, counter), ctx, calls, counter }; + } + + test('a row without a line is cleared to the background', () => { + const t = painter(); + t.p.paintRow(t.ctx, 2, 3, { line: null }); + expect(t.calls).toEqual([ + 'clearRect(0,30,24,15)', + `fillStyle=${theme.background}`, + 'fillRect(0,30,24,15)', + ]); + expect(t.counter.draws).toBe(1); + }); + + test('a line draws every background before any text, skipping spacer cells', () => { + const t = painter(); + const wide = cell(0, { codepoint: 0x4e2d, width: 2 }); + const spacer = cell(0, { codepoint: 0, width: 0 }); + t.p.paintRow(t.ctx, 0, 3, { line: [wide, spacer, cell()], grapheme: () => '?' }); + const kinds = t.calls.filter((c) => /^(fillRect|fillText)/.test(c)); + expect(kinds).toEqual([ + 'fillRect(0,0,24,15)', + 'fillRect(0,0,16,15)', + 'fillRect(16,0,8,15)', + 'fillText(中,0,11)', + 'fillText(A,16,11)', + ]); + // The row and the line: two draws + expect(t.counter.draws).toBe(2); + }); + + test('a row with no grapheme lookup draws its clusters as spaces', () => { + const t = painter(); + t.p.paintRow(t.ctx, 0, 1, { line: [cell(0, { grapheme_len: 1 })] }); + expect(t.calls).toContain('fillText( ,0,11)'); + }); + + test('text styles: bold italic font, faint alpha, underline, strike, invisible', () => { + const t = painter(); + const flags = CellFlags.BOLD | CellFlags.ITALIC | CellFlags.FAINT; + t.p.paintCellText(t.ctx, cell(flags), 0, 0, undefined, () => ''); + expect(t.calls).toEqual([ + 'font=italic bold 15px mono', + 'fillStyle=rgb(200, 0, 0)', + 'globalAlpha=0.5', + 'fillText(A,0,11)', + 'globalAlpha=1', + ]); + t.calls.length = 0; + t.p.paintCellText( + t.ctx, + cell(CellFlags.UNDERLINE | CellFlags.STRIKETHROUGH), + 1, + 0, + '#abc', + () => '', + ); + expect(t.calls.filter((c) => c.startsWith('strokeStyle') || c.startsWith('moveTo'))).toEqual([ + 'strokeStyle=#abc', + 'moveTo(8,13)', + 'strokeStyle=#abc', + 'moveTo(8,7.5)', + ]); + t.calls.length = 0; + t.p.paintCellText(t.ctx, cell(CellFlags.INVISIBLE), 0, 0, undefined, () => ''); + expect(t.calls).toEqual([]); + }); + + test('the hovered link is underlined in the link colour', () => { + const link = { startCol: 0, startRow: 0, endCol: 1, endRow: 0 }; + const t = painter({ link }); + t.p.paintCellText(t.ctx, cell(), 1, 0, undefined, () => ''); + t.p.paintCellText(t.ctx, cell(), 2, 0, undefined, () => ''); + expect(t.calls.filter((c) => c.startsWith('strokeStyle'))).toEqual([ + `strokeStyle=${LINK_UNDERLINE_COLOR}`, + ]); + }); +}); + +describe('cells drawn through the renderer', () => { + test(`draws scrollback graphemes from their own row`, async () => { + const { canvas, drawn } = recordingCanvas(); + const document = { createElement: () => canvas } as unknown as Document; + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(30, 6, { scrollbackLines: 10000 }); + // A grapheme at a different column on every line, so a lookup on the wrong + // row finds something else. + for (let i = 0; i < 20; i++) term.write(`${'x'.repeat(i % 7)}é ${i}\r\n`); + term.write('$ abcdefghijklmnop'); + const sb = term.getScrollbackLength(); + const provider = { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => sb, + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; + const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1, overscan: 1 }, { document }); + renderer.resize(30, 6); + renderer.render(term, true, 3, provider); + // Below the overscan row, canvas rows 1-3 show the last three scrollback lines + // (line i is the i-th written); rows 4-6 the top of the screen. + for (let row = 0; row < 3; row++) { + expect(drawn.get(row + 1)).toContain('é'); + expect(drawn.get(row + 1)?.join('')).toContain(` ${sb - 3 + row} `); + } + expect(drawn.get(4)).toContain('é'); + term.free(); + }); + + test(`a hovered OSC 8 link underlines and repaints only its own range`, async () => { + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(COLS, ROWS); + // Two links, on rows 1 and 4: the engine gives every link cell the same hyperlink_id + term.write('a\r\n\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\nb\r\nc\r\n'); + term.write('\x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); + expect(term.getLine(1)?.[0].hyperlink_id).toBe(term.getLine(4)?.[0].hyperlink_id); + const live = rasterRenderer(1, { overscan: 2 }); + live.r.render(term, true, 0, undefined, 0); + live.drawn.length = 0; + live.r.setHoveredLinkRange({ startX: 0, startY: 4, endX: 5, endY: 4 }); + live.r.render(term, false, 0, undefined, 0); + // The hovered row, the row above it, which may hold its overflow, and the row below, + // which overflows into it (overscan rows first) + expect([...new Set(live.drawn)].sort()).toEqual([3, 4, 5].map((y) => y + 2)); + const width = COLS * 8; + const underlined = (y: number) => + live + .shown() + .slice(y * 15 * width, (y + 1) * 15 * width) + .some((p) => p.startsWith(`line ${LINK_UNDERLINE_COLOR}`)); + expect([0, 1, 2, 3, 4, 5].map(underlined)).toEqual([false, false, false, false, true, false]); + term.free(); + }); + + test(`selection, link underline and cursor follow a scroll`, async () => { + const { term, provider } = await writtenTerminal(); + const sb = provider.getScrollbackLength(); + let view = 0; + // A selection anchored to buffer lines, as SelectionManager keeps it, reaching past + // the viewport. + const first = { startCol: 3, startLine: sb - 4, endCol: 12, endLine: sb + 2 }; + const second = { startCol: 0, startLine: sb - 12, endCol: 4, endLine: sb - 9 }; + let selection = first; + const manager = selectionOf( + term, + () => selection, + () => view, + ); + const range = { startX: 2, startY: 6, endX: 9, endY: 6 }; + for (const overscan of [1, 4]) { + const options = { overscan, theme: { cursor: 'rgba(255, 0, 0, 0.3)' } }; + const live = rasterRenderer(2, options); + live.r.setSelectionManager(manager); + live.r.setHoveredLinkRange(range); + live.r.render(term, true, 0, provider, 1); + selection = first; + for (const [y, offset, changed] of [ + [1, 0], + [3, 0], + [3, 7.5], + [2, 0.5], + [0, 4], + [0, 0], + [2, 3], + [7, 0], + [9, 1], + [6, 0], + [6, 0, second], + [6, 2], + [11, 0], + // The selection changes on a frame that copies rows, with the old one still in view + [13, 0, first], + [12, 0, second], + [5, 3, first], + [0, 0], + ] as const) { + if (changed) selection = changed; + view = y; + live.r.setScrollOffset(offset); + live.r.render(term, false, view, provider, 1); + const full = rasterRenderer(2, options); + full.r.setSelectionManager(manager); + full.r.setHoveredLinkRange(range); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, provider, 1); + expect(live.shown()).toEqual(full.shown()); + } + } + term.free(); + }); + + test(`search highlights follow scrolls, output and selection changes (ADR 055)`, async () => { + const { term, provider } = await writtenTerminal(); + const live = { + provider: { ...provider, getScrollbackLength: () => term.getScrollbackLength() }, + }; + term.setSearch('g'); + while (term.searchStep(50)) {} + const read = () => ({ matches: term.searchMatches(), selected: term.searchSelectedMatch() }); + let highlights = read(); + expect(highlights.matches.length).toBeGreaterThan(40); + for (const overscan of [1, 4]) { + const r = rasterRenderer(2, { overscan }); + r.r.setSearchHighlights(highlights); + r.r.render(term, true, 0, live.provider, 0); + const steps: [number, number, (() => void)?][] = [ + [0, 0], + [3, 0, () => term.searchSelect('next')], + [3, 7.5], + [9, 0, () => term.searchSelect('next')], + [12, 0], + [6, 2, () => term.searchSelect('previous')], + [0, 0, () => term.write('one more g\r\n')], + [2, 0], + [2, 0, () => term.setSearch('plain')], + [0, 0, () => term.setSearch('')], + ]; + for (const [view, offset, change] of steps) { + if (change) { + change(); + while (term.searchStep(50)) {} + highlights = read(); + r.r.setSearchHighlights(highlights.matches.length ? highlights : null); + } + r.r.setScrollOffset(offset); + r.r.render(term, false, view, live.provider, 0); + const full = rasterRenderer(2, { overscan }); + full.r.setSearchHighlights(highlights.matches.length ? highlights : null); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, live.provider, 0); + expect(r.shown()).toEqual(full.shown()); + } + term.setSearch('g'); + while (term.searchStep(50)) {} + highlights = read(); + } + // The matches' text in the search colours, the selected one's in its own + const r = rasterRenderer(1, { + theme: { searchForeground: '#111111', searchSelectedForeground: '#222222' }, + }); + term.searchSelect('next'); + r.r.setSearchHighlights(read()); + r.r.render(term, true, 0, live.provider, 0); + const glyphs = (color: string) => r.shown().filter((v) => v.startsWith(`${color} `)); + expect(glyphs('#111111').length).toBeGreaterThan(0); + expect(glyphs('#222222').length).toBeGreaterThan(0); + term.free(); + }); +}); diff --git a/src/client/vendor/tests/render/cursor-painter.test.ts b/src/client/vendor/tests/render/cursor-painter.test.ts new file mode 100644 index 0000000..da2de27 --- /dev/null +++ b/src/client/vendor/tests/render/cursor-painter.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import type { GhosttyCell } from '../../ghostty-web/engine/cell-types'; +import { + CURSOR_BLINK_INTERVAL_MS, + CursorBlink, + CursorPainter, +} from '../../ghostty-web/render/cursor-painter'; +import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; +import type { RowSource } from '../../ghostty-web/render/frame-lines'; +import type { CellTextPainter, PaintSettings } from '../../ghostty-web/render/renderable'; +import { ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; +import { COLS, ROWS, rasterRenderer, writtenTerminal } from '../support/renderer-harness'; +import { loadGhostty } from '../support/wasm'; + +describe('CursorBlink', () => { + test('toggles every 530 ms while enabled and shows the cursor when stopped', () => { + const clock = new ManualClock(); + const blink = new CursorBlink(clock); + expect(CURSOR_BLINK_INTERVAL_MS).toBe(530); + expect([blink.enabled, blink.visible]).toEqual([false, true]); + blink.setEnabled(true); + expect(blink.enabled).toBe(true); + clock.advance(529); + expect(blink.visible).toBe(true); + clock.advance(1); + expect(blink.visible).toBe(false); + clock.advance(530); + expect(blink.visible).toBe(true); + clock.advance(530); + blink.setEnabled(false); + expect([blink.enabled, blink.visible]).toEqual([false, true]); + expect(clock.pending.timers).toBe(0); + }); + + test('enabling twice starts one timer', () => { + const clock = new ManualClock(); + const blink = new CursorBlink(clock); + blink.setEnabled(true); + blink.setEnabled(true); + expect(clock.pending.timers).toBe(1); + blink.setEnabled(false); + blink.setEnabled(false); + expect(clock.pending.timers).toBe(0); + }); + + test('dispose stops the timer, shows the cursor, and a second dispose does nothing', () => { + const clock = new ManualClock(); + const blink = new CursorBlink(clock); + blink.setEnabled(true); + clock.advance(530); + blink.dispose(); + expect(clock.pending.timers).toBe(0); + expect(blink.visible).toBe(true); + blink.dispose(); + expect(clock.pending.timers).toBe(0); + }); +}); + +/** A context that records calls as strings. */ +function recordingContext() { + const calls: string[] = []; + const ctx = new Proxy( + {}, + { + get: + (_t, key) => + (...args: unknown[]) => + calls.push(`${String(key)}(${args.join(',')})`), + set: (_t, key, value) => { + calls.push(`${String(key)}=${value}`); + return true; + }, + }, + ); + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +describe('CursorPainter', () => { + const settings: PaintSettings = { + fontSize: 15, + fontFamily: 'mono', + metrics: { width: 8, height: 20, baseline: 14 }, + theme: { ...DEFAULT_THEME, cursor: '#c0ffee', cursorAccent: '#000001' }, + devicePixelRatio: 1, + }; + const cell = { codepoint: 0x61, width: 1 } as GhosttyCell; + + function painter(style: 'block' | 'underline' | 'bar' = 'block') { + const texts: string[] = []; + const cells: CellTextPainter = { + paintCellText: (_ctx, c, x, y, color, grapheme) => + texts.push(`${c.codepoint} ${x},${y} ${color} ${grapheme(x)}`), + }; + const counter = { draws: 0 }; + return { p: new CursorPainter(style, cells, settings, counter), texts, counter }; + } + + const source = (line: GhosttyCell[] | null): RowSource => ({ + buffer: { + getGraphemeString: (row: number, col: number) => `g${row}:${col}`, + } as unknown as RowSource['buffer'], + getLine: () => line, + rows: 4, + scrollback: undefined, + scrollbackLength: 0, + }); + + test.each([ + ['underline', ['fillStyle=#c0ffee', 'fillRect(16,57,8,3)']], + ['bar', ['fillStyle=#c0ffee', 'fillRect(16,40,2,20)']], + ] as const)('the %s cursor fills its shape', (style, calls) => { + const t = painter(style); + const { ctx, calls: drawn } = recordingContext(); + t.p.paint(ctx, 2, 2, 0, source([cell, cell, cell])); + expect(drawn).toEqual([...calls]); + expect(t.texts).toEqual([]); + expect(t.counter.draws).toBe(1); + }); + + test('the block cursor draws its cell again in the accent colour, with the screen row grapheme', () => { + const t = painter('block'); + const { ctx, calls } = recordingContext(); + t.p.paint(ctx, 1, 5, 2, source([cell, cell])); + expect(calls).toEqual([ + 'fillStyle=#c0ffee', + 'fillRect(8,100,8,20)', + 'save()', + 'beginPath()', + 'rect(8,100,8,20)', + 'clip()', + 'restore()', + ]); + // Canvas row 5, grapheme of screen row 2 + expect(t.texts).toEqual(['97 1,5 #000001 g2:1']); + // No cell under it: only the block + t.texts.length = 0; + t.p.paint(recordingContext().ctx, 1, 5, 2, source(null)); + expect(t.texts).toEqual([]); + }); + + test('invalidateDrawn keeps the same cursor and invalidates a moved, hidden or reshaped one', () => { + const t = painter(); + const state = () => new Uint8Array(6).fill(ROW_JOINED); + // Nothing drawn yet: nothing to invalidate + let s = state(); + expect(t.p.invalidateDrawn(s, true, 2, 3)).toBe(false); + expect([...s]).toEqual([...state()]); + t.p.drawnAt(2, 3); + // The same cursor + s = state(); + expect(t.p.invalidateDrawn(s, true, 2, 3)).toBe(true); + expect(s[2]).toBe(ROW_JOINED); + // Moved a column, hidden, moved a row + for (const [draws, row, col] of [ + [true, 2, 4], + [false, 2, 3], + [true, 3, 3], + ] as const) { + s = state(); + expect(t.p.invalidateDrawn(s, draws, row, col)).toBe(false); + expect(s[2]).toBe(ROW_INVALID); + } + // A shape change once, then the same again + t.p.setStyle('bar'); + s = state(); + expect(t.p.invalidateDrawn(s, true, 2, 3)).toBe(false); + expect(t.p.invalidateDrawn(state(), true, 2, 3)).toBe(true); + t.p.setStyle('bar'); + expect(t.p.invalidateDrawn(state(), true, 2, 3)).toBe(true); + t.p.markShapeChanged(); + expect(t.p.invalidateDrawn(state(), true, 2, 3)).toBe(false); + // A copied scroll moves the drawn row + t.p.shift(2); + s = state(); + expect(t.p.invalidateDrawn(s, true, 4, 3)).toBe(true); + t.p.drawnAt(null, 0); + t.p.shift(1); + expect(t.p.invalidateDrawn(state(), true, 4, 3)).toBe(false); + }); +}); + +describe('the cursor through the renderer', () => { + test(`a translucent cursor drawn again is not blended over itself`, async () => { + const { term, provider } = await writtenTerminal(); + const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; + // The shape is the engine's (ADR 049) + for (const [cursorStyle, ps] of [ + ['block', 2], + ['bar', 6], + ['underline', 4], + ] as const) { + term.write(`\x1b[${ps} q`); + const live = rasterRenderer(2, { theme, cursorStyle }); + live.r.render(term, true, 0, provider, 1); + // Idle frames, a scroll away and back, and a frame that repaints the cursor row + for (const [y, force] of [ + [0, false], + [0, false], + [2, false], + [0, false], + [0, false], + [0, true], + [0, false], + ] as const) { + live.r.render(term, force, y, provider, 1); + const full = rasterRenderer(2, { theme, cursorStyle }); + full.r.render(term, true, y, provider, 1); + expect(live.shown()).toEqual(full.shown()); + } + live.r.dispose(); + } + term.free(); + }); + + test(`draws the cursor shape the engine reports, not the configured one (ADR 049)`, async () => { + const { term, provider } = await writtenTerminal(); + // The same terminal with the shape left to the renderer's option + const optionShape = new Proxy(term, { + get: (t, k) => { + if (k === 'getCursor') { + return () => { + const { style: _s, blinking: _b, ...cursor } = t.getCursor(); + return cursor; + }; + } + const v = t[k as keyof typeof t]; + return typeof v === 'function' ? v.bind(t) : v; + }, + }); + term.write('\x1b[4 q'); + const live = rasterRenderer(2, { cursorStyle: 'block' }); + live.r.render(term, true, 0, provider, 1); + const underline = rasterRenderer(2, { cursorStyle: 'underline' }); + underline.r.render(optionShape, true, 0, provider, 1); + expect(live.shown()).toEqual(underline.shown()); + // A later DECSCUSR repaints the cursor cell on the next frame + term.write('\x1b[6 q'); + live.r.render(term, false, 0, provider, 1); + const bar = rasterRenderer(2, { cursorStyle: 'bar' }); + bar.r.render(optionShape, true, 0, provider, 1); + expect(live.shown()).toEqual(bar.shown()); + expect(live.shown()).not.toEqual(underline.shown()); + term.free(); + }); + + test(`the block cursor redraws the grapheme of its own screen row`, async () => { + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(COLS, ROWS, { scrollbackLines: 10000 }); + // Every row starts with a cluster of its own, the cursor on a combining one + term.write(Array.from({ length: ROWS }, (_, i) => `${'abcdefgh'[i]}́̂ ${i}`).join('\r\n')); + term.write('\x1b[3;1H'); + const accent = '#00ff01'; + const live = rasterRenderer(2, { cursorStyle: 'block', theme: { cursorAccent: accent } }); + live.r.render(term, true, 0, undefined, 1); + // The cursor draws its character in the accent colour + const accented = new Set( + live + .shown() + .filter((p) => p.startsWith(`${accent} `)) + .map((p) => p.slice(p.lastIndexOf(' ') + 1)), + ); + expect([...accented]).toEqual([term.getGraphemeString(2, 0)]); + expect([...accented][0]).toBe('ć̂'); + live.r.dispose(); + term.free(); + }); + + test('the blink hides and shows the cursor on the frames after each 530 ms tick', async () => { + const { term, provider } = await writtenTerminal(); + const clock = new ManualClock(); + const live = rasterRenderer(2, {}, ROWS, clock); + // The engine reports a blinking cursor (DECSCUSR 1) + term.write('\x1b[1 q'); + live.r.render(term, true, 0, provider, 1); + const shown = live.shown(); + const version = live.r.visibleArea.version; + clock.advance(CURSOR_BLINK_INTERVAL_MS); + live.r.render(term, false, 0, provider, 1); + expect(live.r.visibleArea.version).toBeGreaterThan(version); + expect(live.shown()).not.toEqual(shown); + clock.advance(CURSOR_BLINK_INTERVAL_MS); + live.r.render(term, false, 0, provider, 1); + expect(live.shown()).toEqual(shown); + // A steady cursor stops the timer + term.write('\x1b[2 q'); + live.r.render(term, false, 0, provider, 1); + expect(clock.pending.timers).toBe(0); + live.r.dispose(); + term.free(); + }); +}); diff --git a/src/client/vendor/tests/render/decoration-tracker.test.ts b/src/client/vendor/tests/render/decoration-tracker.test.ts new file mode 100644 index 0000000..5b9f19a --- /dev/null +++ b/src/client/vendor/tests/render/decoration-tracker.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test'; +import type { CellRange } from '../../ghostty-web/render/cell-range'; +import { DecorationTracker } from '../../ghostty-web/render/decoration-tracker'; +import { ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; +import type { SearchHighlights } from '../../ghostty-web/search'; + +const TOTAL = 12; +const state = () => new Uint8Array(TOTAL).fill(ROW_JOINED); +const invalid = (s: Uint8Array) => [...s.keys()].filter((k) => s[k] === ROW_INVALID); +/** All rows drawn, so the next mark only invalidates what changed. */ +const settle = (t: DecorationTracker, s: Uint8Array, top: number, view = 0, sb = 0) => { + t.mark(s, top, view, 10, sb); + t.repainted(new Uint8Array(TOTAL).fill(1)); +}; + +const range = (startRow: number, endRow: number): CellRange => ({ + startCol: 0, + startRow, + endCol: 3, + endRow, +}); + +describe('DecorationTracker', () => { + test('nothing to draw: no row changes, and the frame is empty', () => { + const t = new DecorationTracker(); + const s = state(); + settle(t, s, 2); + expect(invalid(s)).toEqual([]); + expect(t.frame).toEqual({ selection: null, searchSpans: Array(TOTAL), link: null }); + }); + + test('a selection is moved to canvas rows; old and new rows are changed', () => { + const t = new DecorationTracker(); + let selection: CellRange | null = range(1, 2); + t.setSelectionSource({ + hasSelection: () => selection !== null, + getSelectionRange: () => selection, + }); + let s = state(); + t.mark(s, 2, 0, 10, 0); + expect(t.frame.selection).toEqual(range(3, 4)); + // changeRow also invalidates the joined row above + expect(invalid(s)).toEqual([2, 3, 4]); + s = state(); + t.mark(s, 2, 0, 10, 0); + expect(invalid(s)).toEqual([]); + selection = null; + s = state(); + t.mark(s, 2, 0, 10, 0); + expect(invalid(s)).toEqual([2, 3, 4]); + }); + + test('a selection past the canvas is clamped to its rows', () => { + const t = new DecorationTracker(); + t.setSelectionSource({ hasSelection: () => true, getSelectionRange: () => range(-8, 40) }); + const s = state(); + t.mark(s, 2, 0, 10, 0); + expect(invalid(s)).toEqual([...Array(TOTAL).keys()]); + }); + + test('a hovered link changes its rows, and the rows it left', () => { + const t = new DecorationTracker(); + t.setHoveredLink(range(4, 4)); + let s = state(); + t.mark(s, 1, 0, 10, 0); + expect(t.frame.link).toEqual(range(5, 5)); + expect(invalid(s)).toEqual([4, 5]); + t.setHoveredLink(null); + s = state(); + t.mark(s, 1, 0, 10, 0); + expect(invalid(s)).toEqual([4, 5]); + expect(t.frame.link).toBeNull(); + }); + + test('search spans per canvas row; a row is changed until it is drawn with them', () => { + const t = new DecorationTracker(); + // One match on screen row 1 (scrollback 5), columns 2-4 + const highlights: SearchHighlights = { + matches: new Uint32Array([2, 6, 4, 6]), + selected: null, + } as unknown as SearchHighlights; + t.setSearchHighlights(highlights); + let s = state(); + // Viewport row 0 at canvas row 3, at the bottom: screen row 1 is canvas row 4 + t.mark(s, 3, 0, 10, 5); + expect(t.frame.searchSpans[4]).toEqual([{ start: 2, end: 4, selected: false }]); + expect(invalid(s)).toEqual([3, 4]); + // Not drawn yet: changed again + s = state(); + t.mark(s, 3, 0, 10, 5); + expect(invalid(s)).toEqual([3, 4]); + t.repainted(Uint8Array.from({ length: TOTAL }, (_, k) => (k === 4 ? 1 : 0))); + s = state(); + t.mark(s, 3, 0, 10, 5); + expect(invalid(s)).toEqual([]); + }); + + test('a copied scroll moves what the rows were drawn with', () => { + const t = new DecorationTracker(); + t.setSelectionSource({ hasSelection: () => true, getSelectionRange: () => range(1, 1) }); + t.setHoveredLink(range(2, 2)); + settle(t, state(), 2); + // The content moved down 3 rows and the viewport's top with it + t.shift(3, TOTAL); + const s = state(); + t.mark(s, 5, 0, 10, 0); + expect(invalid(s)).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/render/frame-lines.test.ts b/src/client/vendor/tests/render/frame-lines.test.ts index 9b3a8a3..5294c25 100644 --- a/src/client/vendor/tests/render/frame-lines.test.ts +++ b/src/client/vendor/tests/render/frame-lines.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from 'bun:test'; import type { GhosttyCell } from '../../ghostty-web/engine/cell-types'; -import { frameLines } from '../../ghostty-web/render/frame-lines'; +import { + frameLines, + type RowSource, + rowContent, + screenGraphemes, + scrollbackGraphemes, +} from '../../ghostty-web/render/frame-lines'; +import type { IRenderable } from '../../ghostty-web/render/renderable'; import { loadGhostty } from '../support/wasm'; // One frame's row reader: one viewport read, rows served as slices of it. @@ -74,3 +81,48 @@ describe('frameLines', () => { term.free(); }); }); + +describe('the row a canvas row shows', () => { + const screen = [[cellAt(0, 0)], [cellAt(1, 0)]]; + const scrollback = [[cellAt(90, 0)], [cellAt(91, 0)], [cellAt(92, 0)]]; + const buffer = { + getGraphemeString: (row: number, col: number) => `screen ${row}:${col}`, + } as unknown as IRenderable; + const source: RowSource = { + buffer, + getLine: (y) => screen[y] ?? null, + rows: 2, + scrollback: { + getScrollbackLine: (offset) => scrollback[offset], + getScrollbackLength: () => 3, + getScrollbackGraphemeString: (offset, col) => `scrollback ${offset}:${col}`, + }, + scrollbackLength: 3, + }; + + // Viewport row y at viewportY 2: rows 0 and 1 are the last two scrollback lines + test.each([ + [-2, null, undefined], + [-1, 9000, 'scrollback 0:4'], + [0, 9100, 'scrollback 1:4'], + [1, 9200, 'scrollback 2:4'], + [2, 0, 'screen 0:4'], + [3, 100, 'screen 1:4'], + [4, null, undefined], + ])('viewport row %p', (y, codepoint, grapheme) => { + const row = rowContent(source, y, 2); + expect(row.line?.[0].codepoint ?? null).toBe(codepoint); + expect(row.grapheme?.(4)).toBe(grapheme); + }); + + test('no scrollback: the rows above the screen are blank', () => { + expect(rowContent({ ...source, scrollback: undefined }, 0, 1)).toEqual({ line: null }); + }); + + test('a buffer or provider without grapheme lookups gives spaces', () => { + expect(screenGraphemes(null, 0)(3)).toBe(' '); + expect(screenGraphemes({} as IRenderable, 0)(3)).toBe(' '); + const provider = { getScrollbackLine: () => null, getScrollbackLength: () => 0 }; + expect(scrollbackGraphemes(provider, 0)(3)).toBe(' '); + }); +}); diff --git a/src/client/vendor/tests/render/row-repainter.test.ts b/src/client/vendor/tests/render/row-repainter.test.ts new file mode 100644 index 0000000..c51a252 --- /dev/null +++ b/src/client/vendor/tests/render/row-repainter.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; +import type { PaintSettings, RowContent, RowPainter } from '../../ghostty-web/render/renderable'; +import { ROW_CUT, ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; +import { REPAINT_CHUNK_ROWS, RowRepainter } from '../../ghostty-web/render/row-repainter'; +import { + COLS, + type LineSelection, + previousFrame, + ROWS, + rasterRenderer, + scrollbackOf, + selectionOf, + splitPosition, + writtenTerminal, +} from '../support/renderer-harness'; +import { loadGhostty } from '../support/wasm'; + +/** A context that records every call and property write, in order. */ +function recordingContext() { + const calls: string[] = []; + const ctx = new Proxy( + {}, + { + get: + (_t, key) => + (...args: unknown[]) => + calls.push(`${String(key)}(${args.join(',')})`), + set: (_t, key, value) => { + calls.push(`${String(key)}=${value}`); + return true; + }, + }, + ); + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +/** Paint settings for 8 × 15 px cells at `dpr`. */ +function settingsAt(dpr: number): PaintSettings { + return { + fontSize: 15, + fontFamily: 'monospace', + metrics: { width: 8, height: 15, baseline: 11 }, + theme: DEFAULT_THEME, + devicePixelRatio: dpr, + }; +} + +/** A RowRepainter on recording contexts, with a painter that logs which rows it drew where. */ +function repainter(dpr = 2) { + const own = recordingContext(); + const scratchContext = recordingContext(); + const canvas = { width: 240 * dpr, height: 0 } as HTMLCanvasElement; + const scratch = { + width: 99, + height: 99, + getContext: () => scratchContext.ctx, + } as unknown as HTMLCanvasElement; + const painted: string[] = []; + const rows: RowPainter = { + paintRow: (ctx, k) => painted.push(`${ctx === own.ctx ? 'canvas' : 'scratch'} ${k}`), + }; + const counter = { draws: 0 }; + const r = new RowRepainter(canvas, own.ctx, scratch, rows, settingsAt(dpr), counter); + const rowAt = (): RowContent => ({ line: null }); + return { r, own, scratchContext, scratch, painted, counter, rowAt }; +} + +describe('RowRepainter', () => { + test('frees the scratch canvas when created and on freeScratch', () => { + const t = repainter(); + expect([t.scratch.width, t.scratch.height]).toEqual([0, 0]); + t.scratch.height = 40; + t.r.freeScratch(); + expect(t.scratch.height).toBe(0); + }); + + test('a cut run between invalid rows is drawn on the canvas itself', () => { + const t = repainter(); + const state = new Uint8Array(6).fill(ROW_INVALID); + t.r.repaint(state, 2, 3, true, COLS, t.rowAt); + expect(t.painted).toEqual(['canvas 2', 'canvas 3']); + expect([...state]).toEqual([0, 0, ROW_JOINED, ROW_CUT, 0, 0]); + expect(t.counter.draws).toBe(1); + }); + + test('a run next to kept rows is drawn on the scratch canvas with the next line, then copied', () => { + const t = repainter(); + const state = new Uint8Array(6).fill(ROW_JOINED); + t.r.repaint(state, 1, 2, false, COLS, t.rowAt); + expect(t.painted).toEqual(['scratch 1', 'scratch 2', 'scratch 3']); + expect([...state]).toEqual(Array(6).fill(ROW_JOINED)); + // The scratch is moved up so canvas row 1 is at its top, and three rows high + expect(t.scratchContext.calls[0]).toBe(`setTransform(2,0,0,2,0,${-30})`); + expect(t.scratch.height).toBe(3 * 30); + // The rows are replaced, not blended, in device pixels + expect(t.own.calls).toEqual([ + 'save()', + 'setTransform(1,0,0,1,0,0)', + 'globalAlpha=1', + 'globalCompositeOperation=source-over', + 'imageSmoothingEnabled=false', + `clearRect(0,30,480,60)`, + `drawImage([object Object],0,0,480,60,0,30,480,60)`, + 'restore()', + ]); + }); + + test('a long run is drawn in chunks, each with its next line, and the last cut', () => { + const t = repainter(1); + const total = 2 * REPAINT_CHUNK_ROWS + 4; + const state = new Uint8Array(total + 1).fill(ROW_JOINED); + t.r.repaint(state, 0, total - 1, true, COLS, t.rowAt); + // Chunk ends: the next line is drawn for the first two, not for the cut last one + const drawn = t.painted.map((p) => Number(p.split(' ')[1])); + expect(drawn).toEqual([ + ...Array.from({ length: REPAINT_CHUNK_ROWS + 1 }, (_, i) => i), + ...Array.from({ length: REPAINT_CHUNK_ROWS + 1 }, (_, i) => i + REPAINT_CHUNK_ROWS), + ...Array.from({ length: 4 }, (_, i) => i + 2 * REPAINT_CHUNK_ROWS), + ]); + expect(t.scratch.height).toBe((REPAINT_CHUNK_ROWS + 1) * 15); + expect(state[total - 1]).toBe(ROW_CUT); + }); + + test('repaintView repaints only the rows not as a full redraw leaves them', () => { + const t = repainter(); + // Rows 2-5 in view, the last cut; row 3 changed + const state = Uint8Array.from([0, 0, ROW_JOINED, ROW_INVALID, ROW_JOINED, ROW_CUT, 0]); + const repainted = t.r.repaintView(state, 2, 2, 5, COLS, t.rowAt); + expect([...repainted]).toEqual([0, 0, 0, 1, 0, 0, 0]); + expect(t.painted).toEqual(['scratch 3', 'scratch 4']); + // Nothing to do: nothing drawn + t.painted.length = 0; + expect([...t.r.repaintView(state, 2, 2, 5, COLS, t.rowAt)]).toEqual(Array(7).fill(0)); + expect(t.painted).toEqual([]); + }); + + test('shiftRows copies the canvas onto itself with the copy operator', () => { + const t = repainter(2); + const canvas = { width: 480, height: 300 } as HTMLCanvasElement; + const counter = { draws: 0 }; + const noRows = { paintRow: () => {} }; + const r = new RowRepainter(canvas, t.own.ctx, t.scratch, noRows, settingsAt(2), counter); + r.shiftRows(2); + r.shiftRows(-1); + expect(t.own.calls.filter((c) => c.startsWith('drawImage'))).toEqual([ + 'drawImage([object Object],0,0,480,240,0,60,480,240)', + 'drawImage([object Object],0,30,480,270,0,0,480,270)', + ]); + expect(t.own.calls).toContain('globalCompositeOperation=copy'); + expect(counter.draws).toBe(2); + }); + + test('a scratch canvas without a 2D context throws', () => { + const t = repainter(); + const broken = { width: 0, height: 0, getContext: () => null } as unknown as HTMLCanvasElement; + const canvas = { width: 480 } as HTMLCanvasElement; + const noRows = { paintRow: () => {} }; + const r = new RowRepainter(canvas, t.own.ctx, broken, noRows, settingsAt(1), { draws: 0 }); + const state = new Uint8Array(4).fill(ROW_JOINED); + expect(() => r.repaint(state, 1, 1, false, COLS, t.rowAt)).toThrow('2D rendering context'); + }); +}); + +describe('partial repaints equal a full redraw (ADR 043)', () => { + test(`the rows in view equal the previous renderer at every position`, async () => { + const t = await writtenTerminal(); + const sb = t.provider.getScrollbackLength(); + for (const [dpr, overscan] of [ + [1, 3], + [2, 1], + [2, 2], + [2, undefined], + ] as const) { + const live = rasterRenderer(dpr, { overscan }); + const h = live.r.getMetrics().height; + live.r.render(t.term, true, 0, t.provider, 0); + // Trackpad steps up and down, a jump past the overscan, line steps, the top + const positions = [ + ...Array.from({ length: 24 }, (_, i) => i * 0.35), + 18.6, + 15.4, + ...Array.from({ length: 12 }, (_, i) => 15.4 - i * 0.45), + ...[1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0.5, 0], + ...[sb, sb - 0.25, sb - 7.75, 0], + ]; + for (const p of positions) { + const { line, offset } = splitPosition(p, h, dpr); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 0); + expect(live.r.scrollOffset).toBe(offset); + expect(live.shown()).toEqual(previousFrame(t, line, offset, dpr)); + // The bottom strip's line is the last row's own, at any fraction: not the + // glyphs the part in view ends on, offset above it. + const area = live.r.visibleArea; + expect(area.lastLine).toBe(area.y + area.height - 1 + offset * dpr); + const whole = previousFrame(t, line, 0, dpr); + expect(live.lastLine()).toEqual(whole.slice(whole.length - COLS * 8 * dpr)); + } + } + t.term.free(); + }); + + test(`random scrolls, output, selection, hover and scrollbar equal a full redraw`, async () => { + const { term } = await writtenTerminal(); + const provider = scrollbackOf(term); + // A fixed pseudo-random sequence + let seed = 7; + const random = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + let view = 0; + let selection: LineSelection | null = null; + const manager = selectionOf( + term, + () => selection, + () => view, + ); + let range: { startX: number; startY: number; endX: number; endY: number } | null = null; + for (const overscan of [1, 3]) { + const options = { overscan }; + const live = rasterRenderer(2, options); + live.r.setSelectionManager(manager); + const h = live.r.getMetrics().height; + let position = 0; + let opacity = 1; + for (let step = 0; step < 500; step++) { + const sb = term.getScrollbackLength(); + // Some steps also move, so a change can land on the frame that copies rows + const op = random(); + if (op >= 0.45 && random() < 0.4) position += (random() - 0.5) * 10; + if (op < 0.45) position += (random() - 0.5) * (random() < 0.3 ? 14 : 4); + else if (op < 0.5) position = 0; + else if (op < 0.6) { + const i = Math.floor(random() * 30); + term.write(i % 2 ? `out ${i} é g\r\n` : `\x1b[4${i % 8}m${i}\x1b[0m 中 g`); + } else if (op < 0.7) opacity = opacity ? 0 : 1; + else if (op < 0.8) { + const line = Math.floor(random() * (sb + ROWS)); + selection = + random() < 0.3 + ? null + : { + startCol: Math.floor(random() * COLS), + startLine: line, + endCol: Math.floor(random() * COLS), + endLine: line + Math.floor(random() * 6), + }; + } else { + const y = Math.floor(random() * ROWS); + range = random() < 0.3 ? null : { startX: 2, startY: y, endX: 6, endY: y }; + } + position = Math.max(0, Math.min(term.getScrollbackLength(), position)); + const { line, offset } = splitPosition(position, h, 2); + view = line; + live.r.setHoveredLinkRange(range); + live.r.setScrollOffset(offset); + live.r.render(term, false, view, provider, opacity); + const full = rasterRenderer(2, options); + full.r.setSelectionManager(manager); + full.r.setHoveredLinkRange(range); + full.r.setScrollOffset(offset); + full.r.render(term, true, view, provider, opacity); + expect(live.shown()).toEqual(full.shown()); + } + } + term.free(); + }); + + test(`long runs are repainted in chunks on a small scratch canvas, as a full redraw`, async () => { + const tall = 3 * REPAINT_CHUNK_ROWS + 5; + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(COLS, tall, { scrollbackLines: 10000 }); + const provider = scrollbackOf(term); + // Every line overflows into the rows above and below, so each chunk's edges matter + const lines = (from: number, count: number) => + Array.from({ length: count }, (_, i) => `\x1b[4${(from + i) % 8}m${from + i} é g\x1b[0m`); + term.write(lines(0, tall * 2).join('\r\n')); + for (const overscan of [1, 4]) { + const live = rasterRenderer(2, { overscan }, tall); + const rowHeight = live.r.getMetrics().height * 2; + live.r.render(term, true, 0, provider, 1); + // New output moves every row, a clear blanks them, and scrolls copy and draw rows + for (const [output, y] of [ + [`\r\n${lines(100, 3).join('\r\n')}`, 0], + ['', 2 * REPAINT_CHUNK_ROWS + 1], + ['', 4], + ['', 0], + ['\x1b[2J\x1b[H', 0], + [lines(200, tall - 1).join('\r\n'), 0], + ] as const) { + term.write(output); + live.r.render(term, false, y, provider, 1); + const full = rasterRenderer(2, { overscan }, tall); + full.r.render(term, true, y, provider, 1); + expect(live.shown()).toEqual(full.shown()); + expect(live.scratch.height).toBeLessThanOrEqual((REPAINT_CHUNK_ROWS + 1) * rowHeight); + } + } + term.free(); + }); + + test(`rows out of view follow output at the bottom`, async () => { + const { term } = await writtenTerminal(); + const provider = scrollbackOf(term); + const live = rasterRenderer(2, { overscan: 4 }); + live.r.render(term, true, 0, provider, 0); + // Draw the rows above the viewport, return, and let output (a new line, then a line + // changed in place) move the lines they show + for (const output of [ + '\x1b[8;1H\r\nnew line é g', + '\rsame line g\x1b[K', + '\r\n\x1b[41m中中\x1b[0m', + ]) { + for (const y of [3, 2, 1, 0]) live.r.render(term, false, y, provider, 0); + term.write(output); + live.r.render(term, false, 0, provider, 0); + for (const [y, offset] of [ + [0, 7.5], + [2, 0], + [3, 0.5], + ]) { + live.r.setScrollOffset(offset); + live.r.render(term, false, y, provider, 0); + expect(live.shown()).toEqual(previousFrame({ term, provider }, y, offset, 2)); + } + live.r.setScrollOffset(0); + } + term.free(); + }); + + test(`a scroll draws only the rows that came into view and the edges`, async () => { + const t = await writtenTerminal(); + const live = rasterRenderer(2, { overscan: 6 }); + const h = live.r.getMetrics().height; + live.r.render(t.term, true, 0, t.provider, 1); + let view = 0; + for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { + const before = live.drawn.length; + live.r.render(t.term, false, y, t.provider, 1); + // The rows that came into view and the one their glyphs overflow into, the new + // last row, and the row the old cursor moved to with the row below it. + expect(live.drawn.length - before).toBeLessThanOrEqual(Math.abs(y - view) + 4); + view = y; + } + // Up and down within the overscan once its rows are drawn: no copy, and per line + // only the rows at the edges, which lose or gain what the line beyond draws into them + for (let p = 1; p <= 4; p += 0.25) { + const { line, offset } = splitPosition(p, h, 2); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 1); + } + const copies = live.copies(); + const before = live.drawn.length; + for (let p = 4; p >= 1; p -= 0.25) { + const { line, offset } = splitPosition(p, h, 2); + live.r.setScrollOffset(offset); + live.r.render(t.term, false, line, t.provider, 1); + } + expect(live.copies()).toBe(copies); + expect(live.drawn.length - before).toBeLessThanOrEqual(3 * 4 + 2); + // Frames at a fraction of a line with nothing else changed draw nothing + const still = live.drawn.length; + for (const offset of [0.5, 3, 7.5, 3]) { + live.r.setScrollOffset(offset); + live.r.render(t.term, false, 1, t.provider, 1); + } + expect(live.drawn.length).toBe(still); + t.term.free(); + }); + + test(`a scroll past the overscan copies the rows still in view`, async () => { + const t = await writtenTerminal(); + const live = rasterRenderer(2, { overscan: 2 }); + live.r.render(t.term, true, 0, t.provider, 0); + live.r.render(t.term, false, 3, t.provider, 0); + expect(live.copies()).toBe(1); + expect(live.shown()).toEqual(previousFrame(t, 3, 0, 2)); + t.term.free(); + }); + + test(`a scrolled frame with nothing changed draws no row`, async () => { + const { term, provider } = await writtenTerminal(); + const live = rasterRenderer(2); + live.r.render(term, true, 0, provider, 1); + live.r.render(term, false, 6, provider, 1); + const before = live.drawn.length; + for (let i = 0; i < 3; i++) live.r.render(term, false, 6, provider, 1); + expect(live.drawn.length).toBe(before); + // Dropping the scrollbar repaints the rows in view, so none keeps the thumb. + live.r.render(term, false, 6, provider, 0); + expect(live.drawn.length - before).toBe(ROWS); + const full = rasterRenderer(2); + full.r.render(term, true, 6, provider, 0); + expect(live.shown()).toEqual(full.shown()); + term.free(); + }); +}); diff --git a/src/client/vendor/tests/render/scroll-tracker.test.ts b/src/client/vendor/tests/render/scroll-tracker.test.ts new file mode 100644 index 0000000..f42075e --- /dev/null +++ b/src/client/vendor/tests/render/scroll-tracker.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import type { RowSource } from '../../ghostty-web/render/frame-lines'; +import { ScrollTracker } from '../../ghostty-web/render/scroll-tracker'; + +/** A 4-row source with `dirty` rows and `scrollbackLength` lines above the screen. */ +function source(dirty: number[], scrollbackLength = 10): RowSource { + return { + buffer: { isRowDirty: (y: number) => dirty.includes(y) } as unknown as RowSource['buffer'], + getLine: () => null, + rows: 4, + scrollback: undefined, + scrollbackLength, + }; +} + +describe('ScrollTracker', () => { + test('a frame forced in full does not scan the dirty rows', () => { + const tracker = new ScrollTracker(); + expect(tracker.track(source([1]), 0, 0, true)).toEqual({ + dirtyRows: [], + linesMoved: true, + repaintAll: true, + }); + }); + + // Each row: the previous frame (viewportY, scrollback), this frame, the dirty rows, and + // what the tracker reports + test.each([ + ['nothing changed at the bottom', [0, 10], [0, 10], [], false, false], + ['dirty rows at the bottom repaint as dirty', [0, 10], [0, 10], [1, 3], true, false], + ['new scrollback at the bottom', [0, 10], [0, 11], [], true, false], + ['new scrollback while scrolled', [2, 10], [2, 11], [], true, true], + ['a dirty row while scrolled', [2, 10], [2, 10], [0], true, true], + ['a whole-line move alone', [0, 10], [3, 10], [], false, false], + ['a whole-line move with output', [0, 10], [3, 10], [0], true, true], + ['a fractional viewportY', [0, 10], [0.5, 10], [], false, true], + ['from a fractional viewportY', [0.5, 10], [1, 10], [], false, true], + ] as const)('%s', (_name, [lastY, lastSb], [y, sb], dirty, linesMoved, repaintAll) => { + const tracker = new ScrollTracker(); + tracker.track(source([], lastSb), lastY, Math.floor(lastY), false); + const moves = tracker.track(source([...dirty], sb), y, Math.floor(y), false); + expect(moves).toEqual({ dirtyRows: [...dirty], linesMoved, repaintAll }); + }); +}); diff --git a/src/client/vendor/tests/render/scrollbar-painter.test.ts b/src/client/vendor/tests/render/scrollbar-painter.test.ts new file mode 100644 index 0000000..c84385f --- /dev/null +++ b/src/client/vendor/tests/render/scrollbar-painter.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; +import type { PaintSettings } from '../../ghostty-web/render/renderable'; +import { ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; +import { ScrollbarPainter } from '../../ghostty-web/render/scrollbar-painter'; + +/** A context that records calls and writes as strings. */ +function recordingContext() { + const calls: string[] = []; + const ctx = new Proxy( + {}, + { + get: + (_t, key) => + (...args: unknown[]) => + calls.push(`${String(key)}(${args.join(',')})`), + set: (_t, key, value) => { + calls.push(`${String(key)}=${value}`); + return true; + }, + }, + ); + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const settings: PaintSettings = { + fontSize: 15, + fontFamily: 'mono', + metrics: { width: 8, height: 20, baseline: 14 }, + theme: DEFAULT_THEME, + devicePixelRatio: 2, +}; + +/** A painter over a 200 CSS px wide canvas at ratio 2. */ +function painter() { + const counter = { draws: 0 }; + const canvas = { width: 400 } as HTMLCanvasElement; + return { p: new ScrollbarPainter(canvas, settings, counter), counter }; +} + +describe('ScrollbarPainter.paint', () => { + test('clears its column over the rows in view, then draws the track and thumb', () => { + const { p, counter } = painter(); + const { ctx, calls } = recordingContext(); + // 5 rows in view from canvas row 2, 15 lines of scrollback, scrolled up 3, at half opacity + p.paint(ctx, 3, 15, 5, 0.5, 2); + expect(calls).toEqual([ + 'clearRect(186,40,14,100)', + `fillStyle=${DEFAULT_THEME.background}`, + 'fillRect(186,40,14,100)', + 'fillStyle=rgba(128, 128, 128, 0.05)', + 'fillRect(188,44,8,92)', + 'fillStyle=rgba(128, 128, 128, 0.25)', + 'fillRect(188,99.2,8,23)', + ]); + expect(counter.draws).toBe(1); + }); + + test.each([ + ['fully transparent', 3, 15, 0], + ['without scrollback', 0, 0, 1], + ])('%s, it only clears its column', (_name, viewportY, scrollback, opacity) => { + const { p } = painter(); + const { ctx, calls } = recordingContext(); + p.paint(ctx, viewportY, scrollback, 5, opacity, 0); + expect(calls.length).toBe(3); + }); + + test('at the bottom the thumb is fainter', () => { + const { p } = painter(); + const { ctx, calls } = recordingContext(); + p.paint(ctx, 0, 15, 5, 1, 0); + expect(calls).toContain('fillStyle=rgba(128, 128, 128, 0.3)'); + }); +}); + +describe('ScrollbarPainter.moveTo', () => { + const state = () => new Uint8Array(10).fill(ROW_JOINED); + const invalid = (s: Uint8Array) => [...s.keys()].filter((k) => s[k] === ROW_INVALID); + + test('invalidates the rows the old scrollbar covered that the new one does not', () => { + const { p } = painter(); + let s = state(); + p.moveTo(s, 2, 4); + expect(invalid(s)).toEqual([]); + // Same rows: nothing + s = state(); + p.moveTo(s, 2, 4); + expect(invalid(s)).toEqual([]); + // Two rows down: the two it left + s = state(); + p.moveTo(s, 4, 4); + expect(invalid(s)).toEqual([4, 5].map((k) => k - 2)); + // Dropped: every row it covered + s = state(); + p.moveTo(s, null, 4); + expect(invalid(s)).toEqual([4, 5, 6, 7]); + s = state(); + p.moveTo(s, null, 4); + expect(invalid(s)).toEqual([]); + }); + + test('follows a copied scroll', () => { + const { p } = painter(); + p.moveTo(state(), 2, 4); + p.shift(3); + const s = state(); + p.moveTo(s, 5, 4); + expect(invalid(s)).toEqual([]); + p.moveTo(state(), null, 4); + p.shift(3); + const after = state(); + p.moveTo(after, 1, 4); + expect(invalid(after)).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/render/viewport-clip.test.ts b/src/client/vendor/tests/render/viewport-clip.test.ts new file mode 100644 index 0000000..be4315e --- /dev/null +++ b/src/client/vendor/tests/render/viewport-clip.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { createDrawingCanvas, ViewportClip } from '../../ghostty-web/render/viewport-clip'; + +/** A canvas whose style records each write. */ +function canvas() { + const writes: string[] = []; + const style = new Proxy({} as Record<string, string>, { + set: (t, key, value) => { + t[key as string] = value; + writes.push(`${String(key)}=${value}`); + return true; + }, + }); + return { canvas: { style } as unknown as HTMLCanvasElement, writes }; +} + +describe('createDrawingCanvas', () => { + test('inserts a positioned canvas the mouse passes through before the element', () => { + const inserted: unknown[] = []; + const made = { style: {}, dataset: {} } as HTMLCanvasElement; + const element = { + parentNode: { insertBefore: (node: unknown, before: unknown) => inserted.push(node, before) }, + } as unknown as HTMLCanvasElement; + const result = createDrawingCanvas({ createElement: () => made } as never, element); + expect(result).toBe(made); + expect({ ...made.style }).toEqual({ + position: 'absolute', + pointerEvents: 'none', + display: 'block', + } as CSSStyleDeclaration); + expect(made.dataset.edge).toBe('true'); + expect(inserted).toEqual([made, element]); + }); + + test('an element outside the document is left alone', () => { + const made = { style: {}, dataset: {} } as HTMLCanvasElement; + const element = { parentNode: null } as unknown as HTMLCanvasElement; + expect(createDrawingCanvas({ createElement: () => made } as never, element)).toBe(made); + }); +}); + +describe('ViewportClip', () => { + test('moves the canvas up and clips it to the rows in view, writing only changes', () => { + const { canvas: c, writes } = canvas(); + const clip = new ViewportClip(c); + // 4 rows in view of a 10-row canvas, from canvas row 3, 5 px down, 20 px rows + clip.show(3, 5, 4, 10, 20); + expect(writes).toEqual(['transform=translateY(-55px)', 'clipPath=inset(55px 0px 65px 0px)']); + expect(clip.offset).toBe(5); + clip.show(3, 5, 4, 10, 20); + expect(writes.length).toBe(2); + clip.show(3, 0, 4, 10, 20); + expect(writes.slice(2)).toEqual([ + 'transform=translateY(-60px)', + 'clipPath=inset(60px 0px 60px 0px)', + ]); + }); + + test('the visible area is in device pixels, its last line below the part in view at an offset', () => { + const { canvas: c } = canvas(); + const clip = new ViewportClip(c); + clip.show(3, 5, 4, 10, 20); + expect(clip.area(2, 20, 7)).toEqual({ + canvas: c, + y: 110, + height: 160, + lastLine: 279, + version: 7, + }); + }); +}); diff --git a/src/client/vendor/tests/renderer.test.ts b/src/client/vendor/tests/renderer.test.ts index f4f8259..fae21cf 100644 --- a/src/client/vendor/tests/renderer.test.ts +++ b/src/client/vendor/tests/renderer.test.ts @@ -1,565 +1,55 @@ import { describe, expect, test } from 'bun:test'; -import { CanvasRenderer, REPAINT_CHUNK_ROWS } from '../ghostty-web/renderer'; -import { loadGhostty } from './support/wasm'; - -/** A 2D context that records the text drawn on each canvas row. */ -function recordingCanvas(cellHeight: number) { - const drawn = new Map<number, string[]>(); - const ctx = new Proxy( - { - fillText: (text: string, _x: number, y: number) => { - const row = Math.floor(y / cellHeight); - drawn.set(row, [...(drawn.get(row) ?? []), text]); - }, - measureText: () => ({ width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }), - } as Record<string | symbol, unknown>, - { get: (t, k) => (k in t ? t[k] : () => undefined), set: () => true }, - ); - const canvas = { - getContext: () => ctx, - style: {}, - dataset: {}, - width: 0, - height: 0, - addEventListener: () => {}, - removeEventListener: () => {}, - }; - return { canvas: canvas as unknown as HTMLCanvasElement, drawn }; -} - -describe('graphemes while scrolled', () => { - test(`draws scrollback graphemes from their own row`, async () => { - const doc = globalThis.document; - const { canvas, drawn } = recordingCanvas(15); - globalThis.document = { createElement: () => canvas } as unknown as Document; - try { - const ghostty = await loadGhostty(); - const term = ghostty.createTerminal(30, 6, { scrollbackLines: 10000 }); - // A grapheme at a different column on every line, so a lookup on the wrong - // row finds something else. - for (let i = 0; i < 20; i++) term.write(`${'x'.repeat(i % 7)}e\u0301 ${i}\r\n`); - term.write('$ abcdefghijklmnop'); - const sb = term.getScrollbackLength(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => sb, - getScrollbackGraphemeString: (o: number, c: number) => - term.getScrollbackGraphemeString(o, c), - }; - const renderer = new CanvasRenderer(canvas, { devicePixelRatio: 1, overscan: 1 }); - renderer.resize(30, 6); - renderer.render(term, true, 3, provider); - // Below the overscan row, canvas rows 1-3 show the last three scrollback lines - // (line i is the i-th written); rows 4-6 the top of the screen. - for (let row = 0; row < 3; row++) { - expect(drawn.get(row + 1)).toContain('e\u0301'); - expect(drawn.get(row + 1)?.join('')).toContain(` ${sb - 3 + row} `); - } - expect(drawn.get(4)).toContain('e\u0301'); - term.free(); - } finally { - globalThis.document = doc; - } - }); -}); - -/** - * A 2D context that rasterises into one string per device pixel: enough of canvas to - * compare a partial frame with a full redraw. Glyphs fill their cell; a combining acute - * also covers the bottom of the row above and a `g` the top of the row below, like - * glyphs that overflow their cell. Like Chrome, a glyph whose cell lies outside the clip - * is not drawn at all, even where its overflow reaches into the clip. A translucent - * `rgba()` fill blends over what is there, so drawing it twice differs from drawing it - * once. `lose()` blanks the canvas and resets the context, as a lost and restored - * context does. - */ -function rasterCanvas() { - type Rect = [number, number, number, number]; - interface State { - m: [number, number, number, number]; - clip: Rect | null; - fillStyle: string; - strokeStyle: string; - font: string; - globalAlpha: number; - globalCompositeOperation: string; - } - let width = 0; - let height = 0; - let pixels: string[] = []; - const fresh = (): State => ({ - m: [1, 1, 0, 0], - clip: null, - fillStyle: '#000', - strokeStyle: '#000', - font: '', - globalAlpha: 1, - globalCompositeOperation: 'source-over', - }); - let st = fresh(); - let stack: State[] = []; - let path: Rect | null = null; - let line: number[] = []; - const reset = () => { - pixels = new Array(width * height).fill(''); - st = fresh(); - stack = []; - }; - const device = (x: number, y: number, w: number, h: number): Rect => { - const [sx, sy, tx, ty] = st.m; - return [x * sx + tx, y * sy + ty, (x + w) * sx + tx, (y + h) * sy + ty].map(Math.round) as Rect; +import { ManualClock } from '../../test-support/manual-clock'; +import { CanvasRenderer } from '../ghostty-web/renderer'; +import { rasterCanvas } from './support/raster-canvas'; +import { + COLS, + previousFrame, + ROWS, + rasterRenderer, + writtenTerminal, +} from './support/renderer-harness'; + +// The facade: options and deps, the frame's stale and resize triggers, the getters and +// teardown. What it paints is tested with the painters in render/. + +/** A fake window whose ratio can change, with the media query listener the watch arms. */ +function ratioWindow(ratio: number) { + const listeners = new Set<() => void>(); + const win = { + devicePixelRatio: ratio, + matchMedia: () => ({ + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener), + }), }; - const paint = ([x0, y0, x1, y1]: Rect, value: string) => { - const c = st.clip ?? [0, 0, width, height]; - const blends = value.startsWith('rgba('); - for (let y = Math.max(y0, c[1], 0); y < Math.min(y1, c[3], height); y++) { - for (let x = Math.max(x0, c[0], 0); x < Math.min(x1, c[2], width); x++) { - const i = y * width + x; - pixels[i] = blends ? `${pixels[i]} + ${value}` : value; - } + const change = (next: number) => { + win.devicePixelRatio = next; + for (const listener of [...listeners]) { + listeners.delete(listener); + listener(); } }; - const ink = (style: string) => (st.globalAlpha === 1 ? style : `${style}@${st.globalAlpha}`); - const ctx = new Proxy( - { - measureText: () => ({ width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }), - save: () => stack.push({ ...st, m: [...st.m] }), - restore: () => { - st = stack.pop() ?? st; - }, - scale: (x: number, y: number) => { - st.m = [st.m[0] * x, st.m[1] * y, st.m[2], st.m[3]]; - }, - setTransform: (a: number, _b: number, _c: number, d: number, e: number, f: number) => { - st.m = [a, d, e, f]; - }, - clearRect: (x: number, y: number, w: number, h: number) => paint(device(x, y, w, h), ''), - fillRect: (x: number, y: number, w: number, h: number) => - paint(device(x, y, w, h), ink(st.fillStyle)), - fillText: (text: string, x: number, y: number) => { - const cell = device(x, y - 11, 8, 15); - if (st.clip && (cell[3] <= st.clip[1] || cell[1] >= st.clip[3])) return; - const up = text.includes('́') ? 4 : 0; - const down = text === 'g' ? 4 : 0; - paint(device(x, y - 11 - up, 8, 15 + up + down), ink(`${st.fillStyle} ${st.font} ${text}`)); - }, - beginPath: () => { - path = null; - line = []; - }, - rect: (x: number, y: number, w: number, h: number) => { - path = device(x, y, w, h); - }, - clip: () => { - const c = st.clip ?? [0, 0, width, height]; - const p = path ?? c; - st.clip = [ - Math.max(c[0], p[0]), - Math.max(c[1], p[1]), - Math.min(c[2], p[2]), - Math.min(c[3], p[3]), - ]; - }, - moveTo: (x: number, y: number) => { - line = [x, y]; - }, - lineTo: (x: number) => { - line = [line[0], line[1], x]; - }, - stroke: () => - paint(device(line[0], line[1] - 0.5, line[2] - line[0], 1), `line ${st.strokeStyle}`), - drawImage: ( - src: { pixels: string[]; width: number }, - sx: number, - sy: number, - sw: number, - sh: number, - dx: number, - dy: number, - ) => { - expect(st.m).toEqual([1, 1, 0, 0]); - const from = [...src.pixels]; - if (st.globalCompositeOperation === 'copy') paint([0, 0, width, height], ''); - for (let y = 0; y < sh; y++) { - for (let x = 0; x < sw; x++) { - pixels[(dy + y) * width + dx + x] = from[(sy + y) * src.width + sx + x]; - } - } - }, - } as Record<string | symbol, unknown>, - { - get: (t, k) => (k in t ? t[k] : k in st ? st[k as keyof State] : () => undefined), - set: (_t, k, v) => { - if (k in st) (st as unknown as Record<string | symbol, unknown>)[k] = v; - return true; - }, - }, - ); - const restored = new Set<() => void>(); - const canvas = { - getContext: () => ctx, - style: {}, - dataset: {}, - addEventListener: (type: string, listener: () => void) => { - if (type === 'contextrestored') restored.add(listener); - }, - removeEventListener: (type: string, listener: () => void) => { - if (type === 'contextrestored') restored.delete(listener); - }, - get pixels() { - return pixels; - }, - get width() { - return width; - }, - set width(w: number) { - width = w; - reset(); - }, - get height() { - return height; - }, - set height(h: number) { - height = h; - reset(); - }, - }; - const lose = () => { - reset(); - for (const listener of restored) listener(); - }; - return { canvas: canvas as unknown as HTMLCanvasElement, pixels: () => pixels, lose }; + return { win: win as unknown as Window, change, listeners }; } -describe('overscan and copied scrolls (ADR 043)', () => { - const cols = 30; - const rows = 8; - - /** A fresh renderer on its own raster canvas, counting the rows it draws and its copies. */ - function renderer(dpr: number, options: ConstructorParameters<typeof CanvasRenderer>[1] = {}) { - const element = rasterCanvas(); - const created: ReturnType<typeof rasterCanvas>[] = []; - const doc = globalThis.document; - // The canvas the renderer draws on is the first it creates. The others (scratch, font - // measuring) rasterise on their own. - globalThis.document = { - createElement: () => { - const canvas = rasterCanvas(); - created.push(canvas); - return canvas.canvas; - }, - } as unknown as Document; - try { - const r = new CanvasRenderer(element.canvas, { ...options, devicePixelRatio: dpr }); - const raster = created[0]; - r.resize(cols, rows); - const drawn: number[] = []; - let copies = 0; - const internals = r as unknown as { - renderLine: (...args: unknown[]) => void; - shiftRows: (...args: unknown[]) => void; - }; - const renderLine = internals.renderLine.bind(r); - internals.renderLine = (...args: unknown[]) => { - drawn.push(args[1] as number); - renderLine(...args); - }; - const shiftRows = internals.shiftRows.bind(r); - internals.shiftRows = (...args: unknown[]) => { - copies++; - shiftRows(...args); - }; - /** The device pixels the element shows. */ - const shown = () => { - const { y, height } = r.visibleArea; - const width = raster.canvas.width; - return raster.pixels().slice(y * width, (y + height) * width); - }; - /** The device pixels of the line the bottom edge strip stretches. */ - const lastLine = () => { - const width = raster.canvas.width; - const y = r.visibleArea.lastLine; - return raster.pixels().slice(y * width, (y + 1) * width); - }; - return { r, shown, lastLine, lose: raster.lose, drawn, copies: () => copies }; - } finally { - globalThis.document = doc; - } - } - - /** Splits a fractional position into viewportY and an offset snapped to device pixels. */ - function split(position: number, cellHeight: number, dpr: number) { - const line = Math.floor(position); - const offset = Math.round((position - line) * cellHeight * dpr) / dpr; - return offset >= cellHeight ? { line: line + 1, offset: 0 } : { line, offset }; - } - - async function terminal() { - const ghostty = await loadGhostty(); - const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); - // Colours, backgrounds, a wide character, combining marks and descenders that - // overflow into the rows above and below. - for (let i = 0; i < 40; i++) { - term.write( - i % 3 === 0 - ? `\x1b[3${i % 8}mline ${i} e\u0301 g\x1b[0m\r\n` - : i % 3 === 1 - ? `\x1b[4${i % 8}m bg ${i} \x1b[0m 中 g\r\n` - : `plain ${i} a\u0301\r\n`, - ); - // OSC 8 links, two lines each - if (i % 7 === 0) - term.write(`\x1b]8;;https://x/${i % 2}\x1b\\link ${i}\r\nmore g\x1b]8;;\x1b\\\r\n`); - } - // The cursor mid-screen, so a scroll moves it onto another row in view. - term.write('$ prompt\x1b[5;3H'); - const sb = term.getScrollbackLength(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => sb, - getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), - }; - return { term, provider }; - } - - type Term = Awaited<ReturnType<typeof terminal>>; - - /** - * What the previous renderer showed at a position, drawn independently: the viewport - * as a screen of its own lines, nothing below it, and at a fraction of a line the - * bottom of the line above drawn on its own (ADR 040's strip). No scrollbar. - */ - function previous({ term, provider }: Term, view: number, offset: number, dpr: number) { - const sb = provider.getScrollbackLength(); - const source = (r: number) => - r < view - ? { - line: provider.getScrollbackLine(sb - view + r), - grapheme: (c: number) => provider.getScrollbackGraphemeString(sb - view + r, c), - } - : { - line: term.getLine(r - view), - grapheme: (c: number) => term.getGraphemeString(r - view, c), - }; - const screen = (from: number, count: number, cursor: boolean) => { - const lines = Array.from({ length: count }, (_, i) => source(from + i)); - const t = renderer(dpr); - t.r.resize(cols, count); - t.r.render( - { - getLine: (y) => lines[y]?.line ?? null, - getGraphemeString: (y, c) => lines[y].grapheme(c), - getCursor: () => (cursor ? term.getCursor() : { x: 0, y: 0, visible: false }), - getDimensions: () => ({ cols, rows: count }), - isRowDirty: () => false, - clearDirty: () => {}, - }, - true, - 0, - ); - return t.shown(); - }; - const width = cols * 8 * dpr; - const cut = offset * dpr * width; - const viewport = screen(0, rows, view === 0); - if (!offset) return viewport; - const above = screen(-1, 1, false); - return [...above.slice(above.length - cut), ...viewport.slice(0, viewport.length - cut)]; - } - - test(`the rows in view equal the previous renderer at every position`, async () => { - const t = await terminal(); - const sb = t.provider.getScrollbackLength(); - for (const [dpr, overscan] of [ - [1, 3], - [2, 1], - [2, 2], - [2, undefined], - ] as const) { - const live = renderer(dpr, { overscan }); - const h = live.r.getMetrics().height; - live.r.render(t.term, true, 0, t.provider, 0); - // Trackpad steps up and down, a jump past the overscan, line steps, the top - const positions = [ - ...Array.from({ length: 24 }, (_, i) => i * 0.35), - 18.6, - 15.4, - ...Array.from({ length: 12 }, (_, i) => 15.4 - i * 0.45), - 1, - 2, - 5, - 4, - 0, - 3, - 10, - 9, - 17, - 16, - 0.5, - 0, - sb, - sb - 0.25, - sb - 7.75, - 0, - ]; - for (const p of positions) { - const { line, offset } = split(p, h, dpr); - live.r.setScrollOffset(offset); - live.r.render(t.term, false, line, t.provider, 0); - expect(live.r.scrollOffset).toBe(offset); - expect(live.shown()).toEqual(previous(t, line, offset, dpr)); - // The bottom strip's line is the last row's own, at any fraction: not the - // glyphs the part in view ends on, offset above it. - const area = live.r.visibleArea; - expect(area.lastLine).toBe(area.y + area.height - 1 + offset * dpr); - const whole = previous(t, line, 0, dpr); - expect(live.lastLine()).toEqual(whole.slice(whole.length - cols * 8 * dpr)); - } - } - t.term.free(); - }); - - test(`an offset set for a taller cell shows at most a line`, async () => { - const t = await terminal(); - const live = renderer(2); - const h = live.r.getMetrics().height; - live.r.setScrollOffset(h + 6); - live.r.render(t.term, true, 3, t.provider, 0); - expect(live.r.scrollOffset).toBe(h - 0.5); - expect(live.shown()).toEqual(previous(t, 3, h - 0.5, 2)); - t.term.free(); - }); - - test(`random scrolls, output, selection, hover and scrollbar equal a full redraw`, async () => { - const { term } = await terminal(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), - }; - // A fixed pseudo-random sequence - let seed = 7; - const random = () => { - seed = (seed * 1103515245 + 12345) % 2147483648; - return seed / 2147483648; - }; - let view = 0; - let selection: { - startCol: number; - startLine: number; - endCol: number; - endLine: number; - } | null = null; - const manager = { - hasSelection: () => selection !== null, - getSelectionRange: () => { - if (!selection) return null; - const sb = term.getScrollbackLength(); - return { - startCol: selection.startCol, - startRow: selection.startLine - sb + view, - endCol: selection.endCol, - endRow: selection.endLine - sb + view, - }; - }, - } as unknown as Parameters<CanvasRenderer['setSelectionManager']>[0]; - let range: { startX: number; startY: number; endX: number; endY: number } | null = null; - for (const overscan of [1, 3]) { - const options = { overscan }; - const live = renderer(2, options); - live.r.setSelectionManager(manager); - const h = live.r.getMetrics().height; - let position = 0; - let opacity = 1; - for (let step = 0; step < 500; step++) { - const sb = term.getScrollbackLength(); - // Some steps also move, so a change can land on the frame that copies rows - const op = random(); - if (op >= 0.45 && random() < 0.4) position += (random() - 0.5) * 10; - if (op < 0.45) position += (random() - 0.5) * (random() < 0.3 ? 14 : 4); - else if (op < 0.5) position = 0; - else if (op < 0.6) { - const i = Math.floor(random() * 30); - term.write(i % 2 ? `out ${i} e\u0301 g\r\n` : `\x1b[4${i % 8}m${i}\x1b[0m \u4e2d g`); - } else if (op < 0.7) opacity = opacity ? 0 : 1; - else if (op < 0.8) { - const line = Math.floor(random() * (sb + rows)); - selection = - random() < 0.3 - ? null - : { - startCol: Math.floor(random() * cols), - startLine: line, - endCol: Math.floor(random() * cols), - endLine: line + Math.floor(random() * 6), - }; - } else { - const y = Math.floor(random() * rows); - range = random() < 0.3 ? null : { startX: 2, startY: y, endX: 6, endY: y }; - } - position = Math.max(0, Math.min(term.getScrollbackLength(), position)); - const { line, offset } = split(position, h, 2); - view = line; - live.r.setHoveredLinkRange(range); - live.r.setScrollOffset(offset); - live.r.render(term, false, view, provider, opacity); - const full = renderer(2, options); - full.r.setSelectionManager(manager); - full.r.setHoveredLinkRange(range); - full.r.setScrollOffset(offset); - full.r.render(term, true, view, provider, opacity); - expect(live.shown()).toEqual(full.shown()); - } - } - term.free(); - }); - - test(`long runs are repainted in chunks on a small scratch canvas, as a full redraw`, async () => { - const tall = 3 * REPAINT_CHUNK_ROWS + 5; - const ghostty = await loadGhostty(); - const term = ghostty.createTerminal(cols, tall, { scrollbackLines: 10000 }); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), - }; - // Every line overflows into the rows above and below, so each chunk's edges matter - const lines = (from: number, count: number) => - Array.from( - { length: count }, - (_, i) => `\x1b[4${(from + i) % 8}m${from + i} e\u0301 g\x1b[0m`, - ); - term.write(lines(0, tall * 2).join('\r\n')); - for (const overscan of [1, 4]) { - const live = renderer(2, { overscan }); - live.r.resize(cols, tall); - const scratch = (live.r as unknown as { scratch: HTMLCanvasElement }).scratch; - const rowHeight = live.r.getMetrics().height * 2; - live.r.render(term, true, 0, provider, 1); - // New output moves every row, a clear blanks them, and scrolls copy and draw rows - for (const [output, y] of [ - [`\r\n${lines(100, 3).join('\r\n')}`, 0], - ['', 2 * REPAINT_CHUNK_ROWS + 1], - ['', 4], - ['', 0], - ['\x1b[2J\x1b[H', 0], - [lines(200, tall - 1).join('\r\n'), 0], - ] as const) { - term.write(output); - live.r.render(term, false, y, provider, 1); - const full = renderer(2, { overscan }); - full.r.resize(cols, tall); - full.r.render(term, true, y, provider, 1); - expect(live.shown()).toEqual(full.shown()); - expect(scratch.height).toBeLessThanOrEqual((REPAINT_CHUNK_ROWS + 1) * rowHeight); - } - } - term.free(); - }); +/** A document that makes raster canvases, the first being the one the renderer draws on. */ +function rasterDocument() { + const created: ReturnType<typeof rasterCanvas>[] = []; + const document = { + createElement: () => { + const canvas = rasterCanvas(); + created.push(canvas); + return canvas.canvas; + }, + } as unknown as Document; + return { document, created }; +} +describe('CanvasRenderer', () => { test('visibleArea.version moves when the canvas is drawn on and not on an idle frame', async () => { - const t = await terminal(); - const live = renderer(2); + const t = await writtenTerminal(); + const live = rasterRenderer(2); live.r.render(t.term, true, 0, t.provider, 0); live.r.render(t.term, false, 0, t.provider, 0); const idle = live.r.visibleArea.version; @@ -576,340 +66,86 @@ describe('overscan and copied scrolls (ADR 043)', () => { t.term.free(); }); - test(`a hovered OSC 8 link underlines and repaints only its own range`, async () => { - const ghostty = await loadGhostty(); - const term = ghostty.createTerminal(cols, rows); - // Two links, on rows 1 and 4: the engine gives every link cell the same hyperlink_id - term.write('a\r\n\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\nb\r\nc\r\n'); - term.write('\x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); - expect(term.getLine(1)?.[0].hyperlink_id).toBe(term.getLine(4)?.[0].hyperlink_id); - const live = renderer(1, { overscan: 2 }); - live.r.render(term, true, 0, undefined, 0); - live.drawn.length = 0; - live.r.setHoveredLinkRange({ startX: 0, startY: 4, endX: 5, endY: 4 }); - live.r.render(term, false, 0, undefined, 0); - // The hovered row, the row above it, which may hold its overflow, and the row below, - // which overflows into it (overscan rows first) - expect([...new Set(live.drawn)].sort()).toEqual([3, 4, 5].map((y) => y + 2)); - const width = cols * 8; - const underlined = (y: number) => - live - .shown() - .slice(y * 15 * width, (y + 1) * 15 * width) - .some((p) => p.startsWith('line #4A90E2')); - expect([0, 1, 2, 3, 4, 5].map(underlined)).toEqual([false, false, false, false, true, false]); - term.free(); - }); - - test(`rows out of view follow output at the bottom`, async () => { - const { term } = await terminal(); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), - }; - const live = renderer(2, { overscan: 4 }); - live.r.render(term, true, 0, provider, 0); - // Draw the rows above the viewport, return, and let output (a new line, then a line - // changed in place) move the lines they show - for (const output of [ - '\x1b[8;1H\r\nnew line e\u0301 g', - '\rsame line g\x1b[K', - '\r\n\x1b[41m\u4e2d\u4e2d\x1b[0m', - ]) { - for (const y of [3, 2, 1, 0]) live.r.render(term, false, y, provider, 0); - term.write(output); - live.r.render(term, false, 0, provider, 0); - for (const [y, offset] of [ - [0, 7.5], - [2, 0], - [3, 0.5], - ]) { - live.r.setScrollOffset(offset); - live.r.render(term, false, y, provider, 0); - expect(live.shown()).toEqual(previous({ term, provider }, y, offset, 2)); - } - live.r.setScrollOffset(0); - } - term.free(); - }); - - test(`a scroll draws only the rows that came into view and the edges`, async () => { - const t = await terminal(); - const live = renderer(2, { overscan: 6 }); + test(`an offset set for a taller cell shows at most a line`, async () => { + const t = await writtenTerminal(); + const live = rasterRenderer(2); const h = live.r.getMetrics().height; - live.r.render(t.term, true, 0, t.provider, 1); - let view = 0; - for (const y of [1, 2, 5, 4, 0, 3, 10, 9, 17, 16, 0, 1]) { - const before = live.drawn.length; - live.r.render(t.term, false, y, t.provider, 1); - // The rows that came into view and the one their glyphs overflow into, the new - // last row, and the row the old cursor moved to with the row below it. - expect(live.drawn.length - before).toBeLessThanOrEqual(Math.abs(y - view) + 4); - view = y; - } - // Up and down within the overscan once its rows are drawn: no copy, and per line - // only the rows at the edges, which lose or gain what the line beyond draws into them - for (let p = 1; p <= 4; p += 0.25) { - const { line, offset } = split(p, h, 2); - live.r.setScrollOffset(offset); - live.r.render(t.term, false, line, t.provider, 1); - } - const copies = live.copies(); - const before = live.drawn.length; - for (let p = 4; p >= 1; p -= 0.25) { - const { line, offset } = split(p, h, 2); - live.r.setScrollOffset(offset); - live.r.render(t.term, false, line, t.provider, 1); - } - expect(live.copies()).toBe(copies); - expect(live.drawn.length - before).toBeLessThanOrEqual(3 * 4 + 2); - // Frames at a fraction of a line with nothing else changed draw nothing - const still = live.drawn.length; - for (const offset of [0.5, 3, 7.5, 3]) { - live.r.setScrollOffset(offset); - live.r.render(t.term, false, 1, t.provider, 1); - } - expect(live.drawn.length).toBe(still); + live.r.setScrollOffset(h + 6); + live.r.render(t.term, true, 3, t.provider, 0); + expect(live.r.scrollOffset).toBe(h - 0.5); + expect(live.shown()).toEqual(previousFrame(t, 3, h - 0.5, 2)); t.term.free(); }); - test(`a scrolled frame with nothing changed draws no row`, async () => { - const { term, provider } = await terminal(); - const live = renderer(2); - live.r.render(term, true, 0, provider, 1); - live.r.render(term, false, 6, provider, 1); + test.each([ + ['setFontSize', (r: CanvasRenderer) => r.setFontSize(20), '20px monospace'], + ['setFontFamily', (r: CanvasRenderer) => r.setFontFamily('serif'), '15px serif'], + ['remeasureFont', (r: CanvasRenderer) => r.remeasureFont(), '15px monospace'], + ])('%s marks every row stale for the next frame', async (_name, change, font) => { + const { term, provider } = await writtenTerminal(); + const live = rasterRenderer(1); + live.r.render(term, true, 0, provider, 0); const before = live.drawn.length; - for (let i = 0; i < 3; i++) live.r.render(term, false, 6, provider, 1); + live.r.render(term, false, 0, provider, 0); expect(live.drawn.length).toBe(before); - // Dropping the scrollbar repaints the rows in view, so none keeps the thumb. - live.r.render(term, false, 6, provider, 0); - expect(live.drawn.length - before).toBe(rows); - const full = renderer(2); - full.r.render(term, true, 6, provider, 0); - expect(live.shown()).toEqual(full.shown()); - term.free(); - }); - - test(`selection, link underline and cursor follow a scroll`, async () => { - const { term, provider } = await terminal(); - const sb = provider.getScrollbackLength(); - let view = 0; - // A selection anchored to buffer lines, as SelectionManager keeps it, reaching past - // the viewport. - const first = { startCol: 3, startLine: sb - 4, endCol: 12, endLine: sb + 2 }; - const second = { startCol: 0, startLine: sb - 12, endCol: 4, endLine: sb - 9 }; - let selection = first; - const manager = { - hasSelection: () => true, - getSelectionRange: () => ({ - startCol: selection.startCol, - startRow: selection.startLine - sb + view, - endCol: selection.endCol, - endRow: selection.endLine - sb + view, - }), - } as unknown as Parameters<CanvasRenderer['setSelectionManager']>[0]; - const range = { startX: 2, startY: 6, endX: 9, endY: 6 }; - for (const overscan of [1, 4]) { - const options = { overscan, theme: { cursor: 'rgba(255, 0, 0, 0.3)' } }; - const live = renderer(2, options); - live.r.setSelectionManager(manager); - live.r.setHoveredLinkRange(range); - live.r.render(term, true, 0, provider, 1); - selection = first; - for (const [y, offset, changed] of [ - [1, 0], - [3, 0], - [3, 7.5], - [2, 0.5], - [0, 4], - [0, 0], - [2, 3], - [7, 0], - [9, 1], - [6, 0], - [6, 0, second], - [6, 2], - [11, 0], - // The selection changes on a frame that copies rows, with the old one still in view - [13, 0, first], - [12, 0, second], - [5, 3, first], - [0, 0], - ] as const) { - if (changed) selection = changed; - view = y; - live.r.setScrollOffset(offset); - live.r.render(term, false, view, provider, 1); - const full = renderer(2, options); - full.r.setSelectionManager(manager); - full.r.setHoveredLinkRange(range); - full.r.setScrollOffset(offset); - full.r.render(term, true, view, provider, 1); - expect(live.shown()).toEqual(full.shown()); - } - } + change(live.r); + live.r.render(term, false, 0, provider, 0); + expect(live.drawn.length - before).toBe(ROWS); + expect(live.shown().some((p) => p.includes(` ${font} `))).toBe(true); term.free(); }); - test(`search highlights follow scrolls, output and selection changes (ADR 055)`, async () => { - const { term, provider } = await terminal(); - const sb = () => term.getScrollbackLength(); - const live = { provider: { ...provider, getScrollbackLength: sb } }; - term.setSearch('g'); - while (term.searchStep(50)) {} - const read = () => ({ matches: term.searchMatches(), selected: term.searchSelectedMatch() }); - let highlights = read(); - expect(highlights.matches.length).toBeGreaterThan(40); - for (const overscan of [1, 4]) { - const r = renderer(2, { overscan }); - r.r.setSearchHighlights(highlights); - r.r.render(term, true, 0, live.provider, 0); - const steps: [number, number, (() => void)?][] = [ - [0, 0], - [3, 0, () => term.searchSelect('next')], - [3, 7.5], - [9, 0, () => term.searchSelect('next')], - [12, 0], - [6, 2, () => term.searchSelect('previous')], - [0, 0, () => term.write('one more g\r\n')], - [2, 0], - [2, 0, () => term.setSearch('plain')], - [0, 0, () => term.setSearch('')], - ]; - for (const [view, offset, change] of steps) { - if (change) { - change(); - while (term.searchStep(50)) {} - highlights = read(); - r.r.setSearchHighlights(highlights.matches.length ? highlights : null); - } - r.r.setScrollOffset(offset); - r.r.render(term, false, view, live.provider, 0); - const full = renderer(2, { overscan }); - full.r.setSearchHighlights(highlights.matches.length ? highlights : null); - full.r.setScrollOffset(offset); - full.r.render(term, true, view, live.provider, 0); - expect(r.shown()).toEqual(full.shown()); - } - term.setSearch('g'); - while (term.searchStep(50)) {} - highlights = read(); - } - // The matches' text in the search colours, the selected one's in its own - const r = renderer(1, { - theme: { searchForeground: '#111111', searchSelectedForeground: '#222222' }, - }); - term.searchSelect('next'); - r.r.setSearchHighlights(read()); - r.r.render(term, true, 0, live.provider, 0); - const glyphs = (color: string) => r.shown().filter((v) => v.startsWith(`${color} `)); - expect(glyphs('#111111').length).toBeGreaterThan(0); - expect(glyphs('#222222').length).toBeGreaterThan(0); + test('clear blanks the canvas and the next frame repaints every row', async () => { + const { term, provider } = await writtenTerminal(); + const live = rasterRenderer(1); + live.r.render(term, true, 0, provider, 0); + const shown = live.shown(); + const version = live.r.visibleArea.version; + live.r.clear(); + expect(live.r.visibleArea.version).toBeGreaterThan(version); + expect(new Set(live.shown())).toEqual(new Set(['#1e1e1e'])); + live.r.render(term, false, 0, provider, 0); + expect(live.shown()).toEqual(shown); term.free(); }); - test(`a translucent cursor drawn again is not blended over itself`, async () => { - const { term, provider } = await terminal(); - const theme = { cursor: 'rgba(255, 0, 0, 0.3)' }; - // The shape is the engine's (ADR 049) - for (const [cursorStyle, ps] of [ - ['block', 2], - ['bar', 6], - ['underline', 4], - ] as const) { - term.write(`\x1b[${ps} q`); - const live = renderer(2, { theme, cursorStyle }); - live.r.render(term, true, 0, provider, 1); - // Idle frames, a scroll away and back, and a frame that repaints the cursor row - for (const [y, force] of [ - [0, false], - [0, false], - [2, false], - [0, false], - [0, false], - [0, true], - [0, false], - ] as const) { - live.r.render(term, force, y, provider, 1); - const full = renderer(2, { theme, cursorStyle }); - full.r.render(term, true, y, provider, 1); - expect(live.shown()).toEqual(full.shown()); - } - live.r.dispose(); - } + test('a scrolled frame without scrollback rows repaints every row on the next frame', async () => { + const { term } = await writtenTerminal(); + const live = rasterRenderer(1); + live.r.render(term, true, 0, undefined, 0); + live.r.render(term, false, 2, undefined, 0); + const before = live.drawn.length; + live.r.render(term, false, 2, undefined, 0); + expect(live.drawn.length).toBeGreaterThan(before); term.free(); }); - test(`draws the cursor shape the engine reports, not the configured one (ADR 049)`, async () => { - const { term, provider } = await terminal(); - // The same terminal with the shape left to the renderer's option - const optionShape = new Proxy(term, { + test('a buffer that asks for a full redraw gets one', async () => { + const { term, provider } = await writtenTerminal(); + const live = rasterRenderer(1); + live.r.render(term, true, 0, provider, 0); + const before = live.drawn.length; + const full = new Proxy(term, { get: (t, k) => { - if (k === 'getCursor') { - return () => { - const { style: _s, blinking: _b, ...cursor } = t.getCursor(); - return cursor; - }; - } + if (k === 'needsFullRedraw') return () => true; const v = t[k as keyof typeof t]; return typeof v === 'function' ? v.bind(t) : v; }, }); - term.write('\x1b[4 q'); - const live = renderer(2, { cursorStyle: 'block' }); - live.r.render(term, true, 0, provider, 1); - const underline = renderer(2, { cursorStyle: 'underline' }); - underline.r.render(optionShape, true, 0, provider, 1); - expect(live.shown()).toEqual(underline.shown()); - // A later DECSCUSR repaints the cursor cell on the next frame - term.write('\x1b[6 q'); - live.r.render(term, false, 0, provider, 1); - const bar = renderer(2, { cursorStyle: 'bar' }); - bar.r.render(optionShape, true, 0, provider, 1); - expect(live.shown()).toEqual(bar.shown()); - expect(live.shown()).not.toEqual(underline.shown()); - term.free(); - }); - - test(`the block cursor redraws the grapheme of its own screen row`, async () => { - const ghostty = await loadGhostty(); - const term = ghostty.createTerminal(cols, rows, { scrollbackLines: 10000 }); - // Every row starts with a cluster of its own, the cursor on a combining one - term.write( - Array.from({ length: rows }, (_, i) => `${'abcdefgh'[i]}\u0301\u0302 ${i}`).join('\r\n'), - ); - term.write('\x1b[3;1H'); - const provider = { - getScrollbackLine: (o: number) => term.getScrollbackLine(o), - getScrollbackLength: () => term.getScrollbackLength(), - getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), - }; - const live = renderer(2, { cursorStyle: 'block' }); - const internals = live.r as unknown as { renderCellText: (...args: unknown[]) => void }; - const renderCellText = internals.renderCellText.bind(live.r); - const accented: string[] = []; - internals.renderCellText = (...args: unknown[]) => { - // The cursor draws its character in the accent colour - if (args[3] !== undefined) accented.push((args[4] as (col: number) => string)(0)); - renderCellText(...args); - }; - live.r.render(term, true, 0, provider, 1); - expect(accented).toEqual([term.getGraphemeString(2, 0)]); - expect(accented[0]).toBe('c\u0301\u0302'); - live.r.dispose(); + live.r.render(full, false, 0, provider, 0); + expect(live.drawn.length - before).toBe(ROWS); term.free(); }); test(`a restored context is repainted on the next frame`, async () => { - const { term, provider } = await terminal(); + const { term, provider } = await writtenTerminal(); for (const y of [6, 0]) { - const live = renderer(2); + const live = rasterRenderer(2); live.r.render(term, true, 0, provider, 1); live.r.render(term, false, y, provider, 1); live.lose(); live.r.render(term, false, y, provider, 1); - const full = renderer(2); + const full = rasterRenderer(2); full.r.render(term, true, y, provider, 1); expect(live.shown()).toEqual(full.shown()); // Disposed, it no longer listens @@ -920,4 +156,73 @@ describe('overscan and copied scrolls (ADR 043)', () => { } term.free(); }); + + test('sizes the element and the drawing canvas with its overscan rows', () => { + const { document, created } = rasterDocument(); + const element = { style: {} } as HTMLCanvasElement; + const r = new CanvasRenderer(element, { devicePixelRatio: 2, overscan: 3 }, { document }); + r.resize(COLS, ROWS); + expect(r.getCanvas()).toBe(element); + expect([r.charWidth, r.charHeight]).toEqual([8, 15]); + expect(r.getMetrics()).toEqual({ width: 8, height: 15, baseline: 11 }); + expect(element.style.width).toBe(`${COLS * 8}px`); + expect(element.style.height).toBe(`${ROWS * 15}px`); + expect([element.width, element.height]).toEqual([0, 0]); + const canvas = created[0].canvas; + expect(canvas.style.height).toBe(`${(ROWS + 6) * 15}px`); + expect([canvas.width, canvas.height]).toEqual([COLS * 8 * 2, (ROWS + 6) * 15 * 2]); + expect(r.visibleArea.canvas).toBe(canvas); + }); + + test('follows the window ratio, rounded up, until disposed', async () => { + const { term, provider } = await writtenTerminal(); + const { document, created } = rasterDocument(); + const { win, change, listeners } = ratioWindow(1.5); + const r = new CanvasRenderer({ style: {} } as HTMLCanvasElement, {}, { document, window: win }); + expect(r.devicePixelRatio).toBe(2); + r.render(term, true, 0, provider, 0); + expect(created[0].canvas.width).toBe(COLS * 8 * 2); + change(3); + expect(r.devicePixelRatio).toBe(3); + r.render(term, false, 0, provider, 0); + expect(created[0].canvas.width).toBe(COLS * 8 * 3); + expect(listeners.size).toBe(1); + r.dispose(); + expect(listeners.size).toBe(0); + term.free(); + }); + + test('dispose stops the blink, removes the drawing canvas and frees the scratch, once', () => { + const clock = new ManualClock(); + const { document, created } = rasterDocument(); + let removed = 0; + const r = new CanvasRenderer( + { style: {} } as HTMLCanvasElement, + { devicePixelRatio: 1, cursorBlink: true }, + { document, timers: clock }, + ); + Object.assign(created[0].canvas, { remove: () => removed++ }); + expect(clock.pending.timers).toBe(1); + created[1].canvas.height = 30; + r.dispose(); + expect(clock.pending.timers).toBe(0); + expect(removed).toBe(1); + expect(created[1].canvas.height).toBe(0); + r.dispose(); + expect(clock.pending.timers).toBe(0); + }); + + test('a canvas without a 2D context throws', () => { + const document = { createElement: () => ({ style: {}, dataset: {}, getContext: () => null }) }; + expect( + () => + new CanvasRenderer( + {} as HTMLCanvasElement, + { devicePixelRatio: 1 }, + { + document: document as unknown as Document, + }, + ), + ).toThrow('2D rendering context'); + }); }); diff --git a/src/client/vendor/tests/support/raster-canvas.ts b/src/client/vendor/tests/support/raster-canvas.ts new file mode 100644 index 0000000..bcbb76c --- /dev/null +++ b/src/client/vendor/tests/support/raster-canvas.ts @@ -0,0 +1,229 @@ +// Canvas fakes for the renderer tests (test support, ADR 064): a context that records the +// text drawn per row, and one that rasterises into a string per device pixel, so a partial +// frame can be compared with a full redraw pixel for pixel. + +import { expect } from 'bun:test'; + +/** The cell height the fake glyph box gives (10 px ascent + 3 px descent + 2 px padding). */ +export const CELL_HEIGHT = 15; + +/** A 2D context that records the text drawn on each canvas row. */ +export function recordingCanvas(cellHeight: number = CELL_HEIGHT) { + const drawn = new Map<number, string[]>(); + const ctx = new Proxy( + { + fillText: (text: string, _x: number, y: number) => { + const row = Math.floor(y / cellHeight); + drawn.set(row, [...(drawn.get(row) ?? []), text]); + }, + measureText: () => ({ width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }), + } as Record<string | symbol, unknown>, + { get: (t, k) => (k in t ? t[k] : () => undefined), set: () => true }, + ); + const canvas = { + getContext: () => ctx, + style: {}, + dataset: {}, + width: 0, + height: 0, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + return { canvas: canvas as unknown as HTMLCanvasElement, drawn }; +} + +/** + * What the renderer did on the canvases sharing one log: the canvas rows it painted a line + * on, in order (a row's own clear followed by its text; blank rows past the buffer's ends + * are not lines), and the copied scrolls (drawImage with the 'copy' operator). + */ +export interface DrawLog { + rows: number[]; + copies: number; +} + +type Rect = [number, number, number, number]; + +interface State { + m: [number, number, number, number]; + clip: Rect | null; + fillStyle: string; + strokeStyle: string; + font: string; + globalAlpha: number; + globalCompositeOperation: string; +} + +/** + * A 2D context that rasterises into one string per device pixel: enough of canvas to + * compare a partial frame with a full redraw. Glyphs fill their cell; a combining acute + * also covers the bottom of the row above and a `g` the top of the row below, like + * glyphs that overflow their cell. Like Chrome, a glyph whose cell lies outside the clip + * is not drawn at all, even where its overflow reaches into the clip. A translucent + * `rgba()` fill blends over what is there, so drawing it twice differs from drawing it + * once. `lose()` blanks the canvas and resets the context, as a lost and restored + * context does. Lines painted and copies are added to `log`. + */ +export function rasterCanvas(log: DrawLog = { rows: [], copies: 0 }) { + let width = 0; + let height = 0; + let pixels: string[] = []; + const fresh = (): State => ({ + m: [1, 1, 0, 0], + clip: null, + fillStyle: '#000', + strokeStyle: '#000', + font: '', + globalAlpha: 1, + globalCompositeOperation: 'source-over', + }); + let st = fresh(); + let stack: State[] = []; + let path: Rect | null = null; + let line: number[] = []; + // The row whose own clear was the last row-sized clear outside save(); its next text, + // outside save() too, makes it a painted line (a cursor's text is drawn inside save()) + let pendingRow: number | null = null; + const reset = () => { + pixels = new Array(width * height).fill(''); + st = fresh(); + stack = []; + }; + const device = (x: number, y: number, w: number, h: number): Rect => { + const [sx, sy, tx, ty] = st.m; + return [x * sx + tx, y * sy + ty, (x + w) * sx + tx, (y + h) * sy + ty].map(Math.round) as Rect; + }; + const paint = ([x0, y0, x1, y1]: Rect, value: string) => { + const c = st.clip ?? [0, 0, width, height]; + const blends = value.startsWith('rgba('); + for (let y = Math.max(y0, c[1], 0); y < Math.min(y1, c[3], height); y++) { + for (let x = Math.max(x0, c[0], 0); x < Math.min(x1, c[2], width); x++) { + const i = y * width + x; + pixels[i] = blends ? `${pixels[i]} + ${value}` : value; + } + } + }; + const ink = (style: string) => (st.globalAlpha === 1 ? style : `${style}@${st.globalAlpha}`); + const ctx = new Proxy( + { + measureText: () => ({ width: 8, actualBoundingBoxAscent: 10, actualBoundingBoxDescent: 3 }), + save: () => stack.push({ ...st, m: [...st.m] }), + restore: () => { + st = stack.pop() ?? st; + }, + scale: (x: number, y: number) => { + st.m = [st.m[0] * x, st.m[1] * y, st.m[2], st.m[3]]; + }, + setTransform: (a: number, _b: number, _c: number, d: number, e: number, f: number) => { + st.m = [a, d, e, f]; + }, + clearRect: (x: number, y: number, w: number, h: number) => { + if (stack.length === 0 && x === 0 && h === CELL_HEIGHT) pendingRow = y / CELL_HEIGHT; + paint(device(x, y, w, h), ''); + }, + fillRect: (x: number, y: number, w: number, h: number) => + paint(device(x, y, w, h), ink(st.fillStyle)), + fillText: (text: string, x: number, y: number) => { + if (stack.length === 0 && pendingRow === Math.floor(y / CELL_HEIGHT)) { + log.rows.push(pendingRow); + pendingRow = null; + } + const cell = device(x, y - 11, 8, 15); + if (st.clip && (cell[3] <= st.clip[1] || cell[1] >= st.clip[3])) return; + const up = text.includes('́') ? 4 : 0; + const down = text === 'g' ? 4 : 0; + paint(device(x, y - 11 - up, 8, 15 + up + down), ink(`${st.fillStyle} ${st.font} ${text}`)); + }, + beginPath: () => { + path = null; + line = []; + }, + rect: (x: number, y: number, w: number, h: number) => { + path = device(x, y, w, h); + }, + clip: () => { + const c = st.clip ?? [0, 0, width, height]; + const p = path ?? c; + st.clip = [ + Math.max(c[0], p[0]), + Math.max(c[1], p[1]), + Math.min(c[2], p[2]), + Math.min(c[3], p[3]), + ]; + }, + moveTo: (x: number, y: number) => { + line = [x, y]; + }, + lineTo: (x: number) => { + line = [line[0], line[1], x]; + }, + stroke: () => + paint(device(line[0], line[1] - 0.5, line[2] - line[0], 1), `line ${st.strokeStyle}`), + drawImage: ( + src: { pixels: string[]; width: number }, + sx: number, + sy: number, + sw: number, + sh: number, + dx: number, + dy: number, + ) => { + expect(st.m).toEqual([1, 1, 0, 0]); + const from = [...src.pixels]; + if (st.globalCompositeOperation === 'copy') { + log.copies++; + paint([0, 0, width, height], ''); + } + for (let y = 0; y < sh; y++) { + for (let x = 0; x < sw; x++) { + pixels[(dy + y) * width + dx + x] = from[(sy + y) * src.width + sx + x]; + } + } + }, + } as Record<string | symbol, unknown>, + { + get: (t, k) => (k in t ? t[k] : k in st ? st[k as keyof State] : () => undefined), + set: (_t, k, v) => { + if (k in st) (st as unknown as Record<string | symbol, unknown>)[k] = v; + return true; + }, + }, + ); + const restored = new Set<() => void>(); + const canvas = { + getContext: () => ctx, + style: {}, + dataset: {}, + addEventListener: (type: string, listener: () => void) => { + if (type === 'contextrestored') restored.add(listener); + }, + removeEventListener: (type: string, listener: () => void) => { + if (type === 'contextrestored') restored.delete(listener); + }, + get pixels() { + return pixels; + }, + get width() { + return width; + }, + set width(w: number) { + width = w; + reset(); + }, + get height() { + return height; + }, + set height(h: number) { + height = h; + reset(); + }, + }; + const lose = () => { + reset(); + for (const listener of restored) listener(); + }; + return { canvas: canvas as unknown as HTMLCanvasElement, pixels: () => pixels, lose }; +} + +/** A raster canvas as `rasterCanvas` returns it. */ +export type RasterCanvas = ReturnType<typeof rasterCanvas>; diff --git a/src/client/vendor/tests/support/renderer-harness.ts b/src/client/vendor/tests/support/renderer-harness.ts new file mode 100644 index 0000000..4af37ad --- /dev/null +++ b/src/client/vendor/tests/support/renderer-harness.ts @@ -0,0 +1,204 @@ +// The canvas renderer's test harness (test support, ADR 064): renderers on raster +// canvases with an injected document, a terminal with overflowing glyphs, colours and +// links, and the independent "previous renderer" every partial frame is compared with. + +import type { IntervalTimers, IScrollbackProvider } from '../../ghostty-web/render/renderable'; +import { CanvasRenderer, type RendererOptions } from '../../ghostty-web/renderer'; +import { type DrawLog, type RasterCanvas, rasterCanvas } from './raster-canvas'; +import { loadGhostty } from './wasm'; + +/** The test terminal's width and height. */ +export const COLS = 30; +export const ROWS = 8; + +/** A renderer on its own raster canvases, with what it painted and copied. */ +export interface RasterRenderer { + r: CanvasRenderer; + /** The device pixels the element shows. */ + shown(): string[]; + /** The device pixels of the line the bottom edge strip stretches. */ + lastLine(): string[]; + /** Blanks the drawing canvas and resets its context, as a lost and restored context does. */ + lose(): void; + /** The canvas rows painted with a line, in order. */ + drawn: number[]; + /** The copied scrolls so far. */ + copies(): number; + /** The scratch canvas partial repaints draw on. */ + scratch: HTMLCanvasElement; +} + +/** + * A fresh renderer at ratio `dpr`, sized COLS × `rows`, on raster canvases its injected + * document creates: the drawing canvas first, then the scratch and the font measure. The + * cursor blinks on `timers` when given. + */ +export function rasterRenderer( + dpr: number, + options: RendererOptions = {}, + rows: number = ROWS, + timers?: IntervalTimers, +): RasterRenderer { + const element = rasterCanvas(); + const log: DrawLog = { rows: [], copies: 0 }; + const created: RasterCanvas[] = []; + const document = { + createElement: () => { + const canvas = rasterCanvas(log); + created.push(canvas); + return canvas.canvas; + }, + } as unknown as Document; + const deps = { document, timers }; + const r = new CanvasRenderer(element.canvas, { ...options, devicePixelRatio: dpr }, deps); + const [raster, scratch] = created; + r.resize(COLS, rows); + return { + r, + shown: () => { + const { y, height } = r.visibleArea; + const width = raster.canvas.width; + return raster.pixels().slice(y * width, (y + height) * width); + }, + lastLine: () => { + const width = raster.canvas.width; + const y = r.visibleArea.lastLine; + return raster.pixels().slice(y * width, (y + 1) * width); + }, + lose: raster.lose, + drawn: log.rows, + copies: () => log.copies, + scratch: scratch.canvas, + }; +} + +/** Splits a fractional position into viewportY and an offset snapped to device pixels. */ +export function splitPosition(position: number, cellHeight: number, dpr: number) { + const line = Math.floor(position); + const offset = Math.round((position - line) * cellHeight * dpr) / dpr; + return offset >= cellHeight ? { line: line + 1, offset: 0 } : { line, offset }; +} + +/** A scrollback provider over `term`'s scrollback, read live. */ +export function scrollbackOf(term: TestTerminal): Required<IScrollbackProvider> { + return { + getScrollbackLine: (o: number) => term.getScrollbackLine(o), + getScrollbackLength: () => term.getScrollbackLength(), + getScrollbackGraphemeString: (o: number, c: number) => term.getScrollbackGraphemeString(o, c), + }; +} + +/** A terminal of the official engine. */ +export type TestTerminal = ReturnType<Awaited<ReturnType<typeof loadGhostty>>['createTerminal']>; + +/** The test terminal and a provider over the scrollback length it had when written. */ +export interface Written { + term: TestTerminal; + provider: Required<IScrollbackProvider>; +} + +/** + * A COLS × ROWS terminal with 40 lines of colours, backgrounds, a wide character, + * combining marks and descenders that overflow into the rows above and below, OSC 8 links, + * and the cursor mid-screen, so a scroll moves it onto another row in view. + */ +export async function writtenTerminal(): Promise<Written> { + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(COLS, ROWS, { scrollbackLines: 10000 }); + for (let i = 0; i < 40; i++) { + term.write( + i % 3 === 0 + ? `\x1b[3${i % 8}mline ${i} é g\x1b[0m\r\n` + : i % 3 === 1 + ? `\x1b[4${i % 8}m bg ${i} \x1b[0m 中 g\r\n` + : `plain ${i} á\r\n`, + ); + // OSC 8 links, two lines each + if (i % 7 === 0) + term.write(`\x1b]8;;https://x/${i % 2}\x1b\\link ${i}\r\nmore g\x1b]8;;\x1b\\\r\n`); + } + term.write('$ prompt\x1b[5;3H'); + const sb = term.getScrollbackLength(); + const provider = { ...scrollbackOf(term), getScrollbackLength: () => sb }; + return { term, provider }; +} + +/** + * What the previous renderer showed at a position, drawn independently: the viewport + * as a screen of its own lines, nothing below it, and at a fraction of a line the + * bottom of the line above drawn on its own (ADR 040's strip). No scrollbar. + */ +export function previousFrame( + { term, provider }: Written, + view: number, + offset: number, + dpr: number, +): string[] { + const sb = provider.getScrollbackLength(); + const source = (r: number) => + r < view + ? { + line: provider.getScrollbackLine(sb - view + r), + grapheme: (c: number) => provider.getScrollbackGraphemeString(sb - view + r, c), + } + : { + line: term.getLine(r - view), + grapheme: (c: number) => term.getGraphemeString(r - view, c), + }; + const screen = (from: number, count: number, cursor: boolean) => { + const lines = Array.from({ length: count }, (_, i) => source(from + i)); + const t = rasterRenderer(dpr, {}, count); + t.r.render( + { + getLine: (y) => lines[y]?.line ?? null, + getGraphemeString: (y, c) => lines[y].grapheme(c), + getCursor: () => (cursor ? term.getCursor() : { x: 0, y: 0, visible: false }), + getDimensions: () => ({ cols: COLS, rows: count }), + isRowDirty: () => false, + clearDirty: () => {}, + }, + true, + 0, + ); + return t.shown(); + }; + const width = COLS * 8 * dpr; + const cut = offset * dpr * width; + const viewport = screen(0, ROWS, view === 0); + if (!offset) return viewport; + const above = screen(-1, 1, false); + return [...above.slice(above.length - cut), ...viewport.slice(0, viewport.length - cut)]; +} + +/** A selection source over lines of the buffer, as SelectionManager keeps it. */ +export interface LineSelection { + startCol: number; + startLine: number; + endCol: number; + endLine: number; +} + +/** + * A selection source reading `selection()` and mapping its buffer lines to viewport rows + * at `view()` over `term`'s scrollback. + */ +export function selectionOf( + term: TestTerminal, + selection: () => LineSelection | null, + view: () => number, +) { + return { + hasSelection: () => selection() !== null, + getSelectionRange: () => { + const s = selection(); + if (!s) return null; + const sb = term.getScrollbackLength(); + return { + startCol: s.startCol, + startRow: s.startLine - sb + view(), + endCol: s.endCol, + endRow: s.endLine - sb + view(), + }; + }, + }; +} From bda8663d9d6daa88c2631e3aa06f56190a7a2b1d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 18:56:32 -0400 Subject: [PATCH 243/295] docs(arch): render/ tree lists the scroll tracker, decoration tracker and viewport clip The render split, part 2 put three more parts under render/ besides the painters: scroll-tracker.ts, decoration-tracker.ts and viewport-clip.ts. This keeps the CanvasRenderer facade under its 500-line limit, and each part is one focused object (ADR 061). --- docs/specs/architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 99a7dab..981f94b 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -206,6 +206,7 @@ ghostty-web/ mouse-controller.ts wheel-controller.ts scrollbar-controller.ts render/ renderable.ts default-theme.ts device-pixel-ratio.ts frame-lines.ts cell-range.ts row-band.ts font-metrics.ts cell-painter.ts row-repainter.ts cursor-painter.ts scrollbar-geometry.ts scrollbar-painter.ts + scroll-tracker.ts decoration-tracker.ts viewport-clip.ts input/ key-types.ts key-map.ts key-encoding.ts text-input.ts (EchoGuard) mouse-report.ts selection/ selection-coords.ts selection-gestures.ts auto-scroll.ts context-menu-textarea.ts clipboard-copy.ts ``` From 7ff16b41b92818972f020f4f3d81d7aa38dfafeb Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:07:45 -0400 Subject: [PATCH 244/295] refactor(client): input split: key table, key encoding, text input and mouse reports input-handler.ts drops from 856 to 202 lines. InputHandler is now the facade over input/ (ADR 061): it owns the listeners and hands each event to one of: - input/key-map.ts: KEY_MAP, the KeyboardEvent.code table (an exempt data table). - input/key-encoding.ts: keyForCode, keyMods, isPrintableKey, plainKeySequence, and encodeKeyPress / encoderText, the encoder call of handleKeyDown (DECCKM synced first, the lowercase ASCII base character passed as utf8). - input/text-input.ts: beforeInputText (the inputType switch), EchoGuard (the three 100 ms de-duplication windows, each cleared as before, on an injected clock defaulting to performance.now) and removeCompositionText. - input/mouse-report.ts: MouseTrackingConfig, MouseEventReport, MOUSE_BUTTONS, motionButton, mouseMods and MouseReporter (the pressed and Shift-bypassed bitmasks, press/release/motion). input-handler.ts re-exports the types and functions it exported before. Every listener InputHandler adds was already removed in dispose(); they now go into one DisposableStore through listen(), in the same order, on the same targets, with the same (bubble) phase. The listener snapshot is unchanged, the empty compositionupdate listener included (it goes with ADR 065). The isDisposed guards, which no removed listener could reach, and the never-set keypressListener field are removed. Tests: the pure tests move to vendor/tests/input/ (key-encoding, text-input, mouse-report), with new tables for the key table, modifiers, printable keys and the encoder text, the EchoGuard windows on a ManualClock (99 ms an echo, 100 ms new input, how each path clears), the beforeinput table and the text-node cleanup in happy-dom. MouseReporter is tested on a happy-dom document. input-handler.test.ts now runs on an injected happy-dom window: set-up and teardown (dispose twice), every keydown branch (Cmd/Ctrl+V pass-through, Cmd+C copy, keyCode 229, unmapped keys, encoder failure), the key bytes as before, the beforeinput paths and echoes, and compositions. input-handler.ts and every input/ file are at 100% lines and functions. The ratchet entry for input-handler.ts is removed. PROVENANCE.md records the moves. --- scripts/architecture/ratchet-allowlist.ts | 4 - src/client/vendor/ghostty-web/PROVENANCE.md | 7 +- .../vendor/ghostty-web/input-handler.ts | 866 +++--------------- .../vendor/ghostty-web/input/key-encoding.ts | 88 ++ .../vendor/ghostty-web/input/key-map.ts | 140 +++ .../vendor/ghostty-web/input/mouse-report.ts | 153 ++++ .../vendor/ghostty-web/input/text-input.ts | 115 +++ src/client/vendor/tests/input-handler.test.ts | 643 +++++++------ .../vendor/tests/input/key-encoding.test.ts | 162 ++++ .../vendor/tests/input/mouse-report.test.ts | 222 +++++ .../vendor/tests/input/text-input.test.ts | 144 +++ 11 files changed, 1499 insertions(+), 1045 deletions(-) create mode 100644 src/client/vendor/ghostty-web/input/key-encoding.ts create mode 100644 src/client/vendor/ghostty-web/input/key-map.ts create mode 100644 src/client/vendor/ghostty-web/input/mouse-report.ts create mode 100644 src/client/vendor/ghostty-web/input/text-input.ts create mode 100644 src/client/vendor/tests/input/key-encoding.test.ts create mode 100644 src/client/vendor/tests/input/mouse-report.test.ts create mode 100644 src/client/vendor/tests/input/text-input.test.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 177e93f..4e9a2f8 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -16,10 +16,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/vendor/ghostty-web/input-handler.ts': { - lines: 856, - step: 'vendor input split into input/ (ADR 061)', - }, 'src/client/vendor/ghostty-web/selection-manager.ts': { lines: 736, step: 'vendor selection split into selection/ (ADR 061)', diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 61ff2e8..de1b749 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -49,6 +49,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `engine/wasm-loader.ts` (from `ghostty.ts`): the wasm is read through an injected reader and fetch (`WasmLoaderDeps`; production: `Bun.file` where Bun runs, then the global `fetch`), and upstream's Node `fs/promises` fallback is removed: the browser never reached it and tests run under Bun, where `Bun.file` reads first (ADR 061). The default URL is `../../ghostty-vt/ghostty-vt-small.wasm`, for the file's new depth. - `renderer.ts`: `CanvasRenderer` takes an optional third argument, `RendererDeps` (the `document` that creates its canvases, the `window` whose ratio it reads and watches, and the blink's interval `timers`), each defaulting to the browser's, so tests inject them instead of replacing globals (ADR 061). The painters take the context they draw on as an argument; the renderer no longer swaps its own context for the scratch canvas' during a partial repaint. - `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `Glide`, `AT_ONCE`, `FRAME_MS`, `linear`, `easeOutCubic`, `Pace` and `Ease` (`glide.ts`), `watchDevicePixelRatio` (`renderer.ts`) and `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`), which the app imported by deep path or did not have. +- `input-handler.ts`: the listeners go into one `DisposableStore` (`listen`), so `dispose()` removes them all as before; the per-handler `isDisposed` checks, which no removed listener could reach, and the `keypressListener` field, which nothing ever set, are removed (ADR 061). The `EchoGuard` (`input/text-input.ts`) reads an injected clock, `performance.now()` by default as before. ## webtty-original files @@ -62,7 +63,7 @@ Not from upstream; each starts with, or will start with when it is next touched, ## Layout - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. -- `input/`: keyboard and mouse input (`key-types.ts`). +- `input/`: keyboard and mouse input: the key encoder's enums (`key-types.ts`), the browser key table (`key-map.ts`), what a keydown sends (`key-encoding.ts`), IME and mobile text with its echo guard (`text-input.ts`), and the mouse reports (`mouse-report.ts`). `input-handler.ts` is their facade. - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. @@ -121,3 +122,7 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `renderer.ts`: the dirty-row scan and the move checks of `render()`, `lastViewportY`, `lastScrollbackLength` (webtty) | `render/scroll-tracker.ts` (`ScrollTracker`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | | `renderer.ts`: the drawing canvas set-up of the constructor (now `createDrawingCanvas`), the transform and clip of `render()` (now `show`), the shown fields and the `visibleArea` body (now `area`) (webtty) | `render/viewport-clip.ts` (`ViewportClip`) | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | | `renderer.ts`: `RendererOptions`, and the paint settings, draw counter and frame decorations the painters share (new `PaintSettings`, `DrawCounter`, `FrameDecorations`, `RowPainter`, `CellTextPainter`, `IntervalTimers`, `CursorStyle`, `RendererDeps`); `renderer.ts` re-exports `RendererOptions` | `render/renderable.ts` | refactor(client): render split, part 2: painters; CanvasRenderer a facade (hot path) | +| `input-handler.ts`: `KEY_MAP` | `input/key-map.ts` | refactor(client): input split: key table, key encoding, text input and mouse reports | +| `input-handler.ts`: `mapKeyCode` (now `keyForCode`), `extractModifiers` (now `keyMods`), `isPrintableCharacter` (now `isPrintableKey`), `plainKeySequence` (webtty), and the encoder call of `handleKeyDown` (now `encoderText` and `encodeKeyPress`) | `input/key-encoding.ts` | refactor(client): input split: key table, key encoding, text input and mouse reports | +| `input-handler.ts`: the input type switch of `handleBeforeInput` (now `beforeInputText`), the de-duplication fields and `recordKeyDownData`, `shouldIgnoreBeforeInput`, `shouldIgnoreBeforeInputFromComposition`, `shouldIgnoreCompositionEnd`, `recordBeforeInputData`, `recordCompositionData`, `getNow`, `BEFORE_INPUT_IGNORE_MS` (now `EchoGuard`, `ECHO_WINDOW_MS`, `defaultNow`), `cleanupCompositionTextNodes` (now `removeCompositionText`) | `input/text-input.ts` | refactor(client): input split: key table, key encoding, text input and mouse reports | +| `input-handler.ts`: `MouseTrackingConfig`, `MouseEventReport`, `MOUSE_BUTTONS`, `motionButton`, `mouseMods` (webtty, ADR 049); `isOverTerminal`, `sendMouseEvent` (now `report`), `handleMouseDown`, `handleMouseUp`, `handleMouseMove` (now `down`, `up`, `move`) and the pressed and bypassed bitmasks (webtty, ADR 049, ADR 051); `input-handler.ts` re-exports the types and functions | `input/mouse-report.ts` (`MouseReporter`) | refactor(client): input split: key table, key encoding, text input and mouse reports | diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index ae954d9..5b9ebae 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -1,265 +1,46 @@ -/** - * InputHandler - Converts browser keyboard events to terminal input - * - * Handles: - * - Keyboard event listening on a container element - * - Mapping KeyboardEvent.code to USB HID Key codes - * - Extracting modifier keys (Ctrl, Alt, Shift, Meta) - * - Encoding keys using Ghostty's KeyEncoder - * - Emitting data for Terminal to send to PTY - * - * Limitations: - * - Does not handle IME/composition events (CJK input) - to be added later - * - Captures all keyboard input (preventDefault on everything) - */ - -import type { Ghostty, MouseButton, MouseInput } from './ghostty'; -import { pressToApp } from './mouse-route'; -import type { KeyEncoder } from './ghostty'; -import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; - -/** - * Map KeyboardEvent.code values to USB HID Key enum values - * Based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code - */ -const KEY_MAP: Record<string, Key> = { - // Letters - KeyA: Key.A, - KeyB: Key.B, - KeyC: Key.C, - KeyD: Key.D, - KeyE: Key.E, - KeyF: Key.F, - KeyG: Key.G, - KeyH: Key.H, - KeyI: Key.I, - KeyJ: Key.J, - KeyK: Key.K, - KeyL: Key.L, - KeyM: Key.M, - KeyN: Key.N, - KeyO: Key.O, - KeyP: Key.P, - KeyQ: Key.Q, - KeyR: Key.R, - KeyS: Key.S, - KeyT: Key.T, - KeyU: Key.U, - KeyV: Key.V, - KeyW: Key.W, - KeyX: Key.X, - KeyY: Key.Y, - KeyZ: Key.Z, - - // Numbers - Digit1: Key.ONE, - Digit2: Key.TWO, - Digit3: Key.THREE, - Digit4: Key.FOUR, - Digit5: Key.FIVE, - Digit6: Key.SIX, - Digit7: Key.SEVEN, - Digit8: Key.EIGHT, - Digit9: Key.NINE, - Digit0: Key.ZERO, - - // Special keys - Enter: Key.ENTER, - Escape: Key.ESCAPE, - Backspace: Key.BACKSPACE, - Tab: Key.TAB, - Space: Key.SPACE, - - // Punctuation - Minus: Key.MINUS, - Equal: Key.EQUAL, - BracketLeft: Key.BRACKET_LEFT, - BracketRight: Key.BRACKET_RIGHT, - Backslash: Key.BACKSLASH, - Semicolon: Key.SEMICOLON, - Quote: Key.QUOTE, - Backquote: Key.GRAVE, - Comma: Key.COMMA, - Period: Key.PERIOD, - Slash: Key.SLASH, - - // Function keys - CapsLock: Key.CAPS_LOCK, - F1: Key.F1, - F2: Key.F2, - F3: Key.F3, - F4: Key.F4, - F5: Key.F5, - F6: Key.F6, - F7: Key.F7, - F8: Key.F8, - F9: Key.F9, - F10: Key.F10, - F11: Key.F11, - F12: Key.F12, - - // Special function keys - PrintScreen: Key.PRINT_SCREEN, - ScrollLock: Key.SCROLL_LOCK, - Pause: Key.PAUSE, - Insert: Key.INSERT, - Home: Key.HOME, - PageUp: Key.PAGE_UP, - Delete: Key.DELETE, - End: Key.END, - PageDown: Key.PAGE_DOWN, - - // Arrow keys - ArrowRight: Key.RIGHT, - ArrowLeft: Key.LEFT, - ArrowDown: Key.DOWN, - ArrowUp: Key.UP, - - // Keypad - NumLock: Key.NUM_LOCK, - NumpadDivide: Key.KP_DIVIDE, - NumpadMultiply: Key.KP_MULTIPLY, - NumpadSubtract: Key.KP_MINUS, - NumpadAdd: Key.KP_PLUS, - NumpadEnter: Key.KP_ENTER, - Numpad1: Key.KP_1, - Numpad2: Key.KP_2, - Numpad3: Key.KP_3, - Numpad4: Key.KP_4, - Numpad5: Key.KP_5, - Numpad6: Key.KP_6, - Numpad7: Key.KP_7, - Numpad8: Key.KP_8, - Numpad9: Key.KP_9, - Numpad0: Key.KP_0, - NumpadDecimal: Key.KP_PERIOD, - - // International - IntlBackslash: Key.INTL_BACKSLASH, - ContextMenu: Key.CONTEXT_MENU, - - // Additional function keys - F13: Key.F13, - F14: Key.F14, - F15: Key.F15, - F16: Key.F16, - F17: Key.F17, - F18: Key.F18, - F19: Key.F19, - F20: Key.F20, - F21: Key.F21, - F22: Key.F22, - F23: Key.F23, - F24: Key.F24, -}; - -/** - * InputHandler class - * Attaches keyboard event listeners to a container and converts - * keyboard events to terminal input data - */ -/** - * Mouse tracking configuration - */ -export interface MouseTrackingConfig { - /** Check if any mouse tracking mode is enabled */ - hasMouseTracking: () => boolean; - /** - * The report for a mouse event at a client position, encoded by Ghostty's mouse - * encoder in the app's tracking mode and format, or '' when the app does not get one. - * (webtty: upstream encoded SGR or X10 itself, ADR 049) - */ - encode: (event: MouseEventReport) => string; -} - -/** A mouse event to report, at a client (viewport) position. (webtty, ADR 049) */ -export type MouseEventReport = Omit<MouseInput, 'x' | 'y'> & { clientX: number; clientY: number }; - -/** - * The sequence for a key the official encoder does not send as xterm does, or null to - * encode the key. For Shift+Enter and Shift+Escape the encoder sends Ghostty's - * modifyOtherKeys `CSI 27;2;13~` and `CSI 27;2;27~`, which most apps do not read; xterm - * sends CR and ESC, as upstream did. Every other key is encoded: navigation and function - * keys follow the cursor key mode (Home is `ESC O H` under DECCKM) and the modifiers - * (Shift+Home is `CSI 1;2 H`). (webtty: upstream sent a fixed table for Enter, Tab, - * Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, which - * ignored both.) - */ -export function plainKeySequence(key: Key, mods: Mods): string | null { - if (mods !== Mods.SHIFT) return null; - if (key === Key.ENTER) return '\r'; - if (key === Key.ESCAPE) return '\x1b'; - return null; -} +// Derived from coder/ghostty-web lib/input-handler.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Keyboard, IME and mouse input (facade): `InputHandler` owns the listeners on the +// container, the input element and the document, and hands each event to the key encoding, +// the text-input echo guard or the mouse reporter in input/ (ADR 047, ADR 049, ADR 061). +import type { KeyEncoder } from './engine/engine-types'; +import type { Ghostty } from './ghostty'; +import { + encodeKeyPress, + isPrintableKey, + keyForCode, + keyMods, + plainKeySequence, +} from './input/key-encoding'; +import { type MouseTrackingConfig, MouseReporter } from './input/mouse-report'; +import { beforeInputText, EchoGuard, removeCompositionText } from './input/text-input'; +import { DisposableStore, type ListenerTarget, listen } from './lifecycle/disposable-store'; + +export { + MOUSE_BUTTONS, + type MouseEventReport, + type MouseTrackingConfig, + motionButton, + mouseMods, +} from './input/mouse-report'; +export { plainKeySequence } from './input/key-encoding'; + +/** What the terminal sends input through; mouseReport is true for a mouse report (webtty, ADR 051). */ +export type InputSink = (data: string, mouseReport?: boolean) => void; /** - * Ghostty's button for `MouseEvent.button`: 0 left, 1 middle, 2 right, and 3/4 back - * and forward, which Ghostty reports as buttons 8/9 (codes 128/129). (webtty: upstream - * sent `MouseEvent.button` as the code, so back was a release and forward Shift.) + * Attaches keyboard, IME and mouse listeners to a container and converts their events + * to terminal input data. */ -export const MOUSE_BUTTONS: readonly MouseButton[] = ['left', 'middle', 'right', 'eight', 'nine']; - -/** - * The button a motion report carries, from the pressed-button bitmask (bit n is - * `MouseEvent.button` n): the lowest one held, or none, which Ghostty reports as 35. - * (webtty: upstream sent 32 for hover, the same code as a left-button drag.) - */ -export function motionButton(pressed: number): MouseButton | null { - for (let i = 0; i < MOUSE_BUTTONS.length; i++) { - if (pressed & (1 << i)) return MOUSE_BUTTONS[i]; - } - return null; -} - -/** - * The modifiers of a mouse event. Ghostty reports Shift (+4), Alt (+8) and Ctrl (+16), - * not Super. (webtty: upstream sent Meta as +8 and dropped Alt.) - */ -export function mouseMods( - e: Pick<MouseEvent, 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'> -): number { - return ( - (e.shiftKey ? Mods.SHIFT : 0) | - (e.altKey ? Mods.ALT : 0) | - (e.ctrlKey ? Mods.CTRL : 0) | - (e.metaKey ? Mods.SUPER : 0) - ); -} - export class InputHandler { - private encoder: KeyEncoder; - private container: HTMLElement; - private inputElement?: HTMLElement; - // mouseReport is true for a mouse report, which leaves the selection alone (webtty, ADR 051) - private onDataCallback: (data: string, mouseReport?: boolean) => void; - private getModeCallback?: (mode: number) => boolean; - private onCopyCallback?: () => boolean; - private mouseConfig?: MouseTrackingConfig; - private keydownListener: ((e: KeyboardEvent) => void) | null = null; - private keypressListener: ((e: KeyboardEvent) => void) | null = null; - private beforeInputListener: ((e: InputEvent) => void) | null = null; - private compositionStartListener: ((e: CompositionEvent) => void) | null = null; - private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null; - private compositionEndListener: ((e: CompositionEvent) => void) | null = null; - private mousedownListener: ((e: MouseEvent) => void) | null = null; - private mouseupListener: ((e: MouseEvent) => void) | null = null; - private mousemoveListener: ((e: MouseEvent) => void) | null = null; - // Where move and release are heard: the document, so a drag keeps reporting past the - // terminal's edge (webtty, ADR 049) - private mouseTarget: Pick<HTMLElement, 'addEventListener' | 'removeEventListener'>; + private readonly encoder: KeyEncoder; + private readonly container: HTMLElement; + private readonly onData: InputSink; + private readonly getMode?: (mode: number) => boolean; + private readonly onCopy?: () => boolean; + private readonly mouse: MouseReporter; + private readonly echoes = new EchoGuard(); + private readonly listeners = new DisposableStore(); private isComposing = false; - private isDisposed = false; - private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting - // Buttons pressed with Shift while the app tracks the mouse: webtty's (selection, menu), - // so nothing about them reaches the app until they are released (webtty, ADR 051) - private mouseButtonsBypassed = 0; - private lastKeyDownData: string | null = null; - private lastKeyDownTime = 0; - private lastCompositionData: string | null = null; - private lastCompositionTime = 0; - private lastBeforeInputData: string | null = null; - private lastBeforeInputTime = 0; - private static readonly BEFORE_INPUT_IGNORE_MS = 100; /** * Create a new InputHandler @@ -275,7 +56,7 @@ export class InputHandler { constructor( ghostty: Ghostty, container: HTMLElement, - onData: (data: string, mouseReport?: boolean) => void, + onData: InputSink, getMode?: (mode: number) => boolean, onCopy?: () => boolean, inputElement?: HTMLElement, @@ -283,166 +64,78 @@ export class InputHandler { ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; - this.inputElement = inputElement; - this.onDataCallback = onData; - this.getModeCallback = getMode; - this.onCopyCallback = onCopy; - this.mouseConfig = mouseConfig; - this.mouseTarget = container.ownerDocument ?? container; - - // Attach event listeners - this.attach(); + this.onData = onData; + this.getMode = getMode; + this.onCopy = onCopy; + this.mouse = new MouseReporter(container, mouseConfig, onData); + this.attach(inputElement); } - /** - * Attach keyboard event listeners to container - */ - private attach(): void { + /** Makes the container focusable and adds the listeners, in upstream's order. */ + private attach(inputElement: HTMLElement | undefined): void { + const container = this.container; // Make container focusable so it can receive keyboard events (browser only) - if ( - typeof this.container.hasAttribute === 'function' && - typeof this.container.setAttribute === 'function' - ) { - if (!this.container.hasAttribute('tabindex')) { - this.container.setAttribute('tabindex', '0'); - } - - // Add visual focus indication (only if style exists - for browser environments) - if (this.container.style) { - this.container.style.outline = 'none'; // Remove default outline - } - } - - this.keydownListener = this.handleKeyDown.bind(this); - this.container.addEventListener('keydown', this.keydownListener); - + if (typeof container.hasAttribute === 'function' && typeof container.setAttribute === 'function') { + if (!container.hasAttribute('tabindex')) container.setAttribute('tabindex', '0'); + // No default focus outline (only if style exists - for browser environments) + if (container.style) container.style.outline = 'none'; + } + // Move and release are heard on the document, so a drag keeps reporting past the + // terminal's edge (webtty, ADR 049) + const mouseTarget: ListenerTarget = container.ownerDocument ?? container; + const store = this.listeners; + store.add(listen(container, 'keydown', (e) => this.handleKeyDown(e))); // Paste is not handled here: webtty's capture listener on the container takes every // paste event first and sends it (ADR 037), and a cancelled paste fires no // insertFromPaste beforeinput. (webtty) - - if (this.inputElement) { - this.beforeInputListener = this.handleBeforeInput.bind(this); - this.inputElement.addEventListener('beforeinput', this.beforeInputListener); - } - - this.compositionStartListener = this.handleCompositionStart.bind(this); - this.container.addEventListener('compositionstart', this.compositionStartListener); - - this.compositionUpdateListener = this.handleCompositionUpdate.bind(this); - this.container.addEventListener('compositionupdate', this.compositionUpdateListener); - - this.compositionEndListener = this.handleCompositionEnd.bind(this); - this.container.addEventListener('compositionend', this.compositionEndListener); - - // Mouse event listeners (for terminal mouse tracking) - this.mousedownListener = this.handleMouseDown.bind(this); - this.container.addEventListener('mousedown', this.mousedownListener); - - this.mouseupListener = this.handleMouseUp.bind(this); - this.mouseTarget.addEventListener('mouseup', this.mouseupListener); - - this.mousemoveListener = this.handleMouseMove.bind(this); - this.mouseTarget.addEventListener('mousemove', this.mousemoveListener); - + if (inputElement) { + store.add(listen(inputElement, 'beforeinput', (e) => this.handleBeforeInput(e))); + } + store.add(listen(container, 'compositionstart', () => this.handleCompositionStart())); + // Removed with the other no-op listeners by ADR 065 (V14) + store.add(listen(container, 'compositionupdate', () => this.handleCompositionUpdate())); + store.add(listen(container, 'compositionend', (e) => this.handleCompositionEnd(e))); + store.add(listen(container, 'mousedown', (e) => this.mouse.down(e))); + store.add(listen(mouseTarget, 'mouseup', (e) => this.mouse.up(e as MouseEvent))); + store.add(listen(mouseTarget, 'mousemove', (e) => this.mouse.move(e as MouseEvent))); // Wheel input is the terminal's alone (Terminal.handleWheel, paced). A listener here // also fired for a wheel over the padding, where the container itself is the target, // and sent that tick a second time, unpaced. (webtty) } - /** - * Map KeyboardEvent.code to USB HID Key enum value - * @param code - KeyboardEvent.code value - * @returns Key enum value or null if unmapped - */ - private mapKeyCode(code: string): Key | null { - return KEY_MAP[code] ?? null; - } - - /** - * Extract modifier flags from KeyboardEvent - * @param event - KeyboardEvent - * @returns Mods flags - */ - private extractModifiers(event: KeyboardEvent): Mods { - let mods = Mods.NONE; - - if (event.shiftKey) mods |= Mods.SHIFT; - if (event.ctrlKey) mods |= Mods.CTRL; - if (event.altKey) mods |= Mods.ALT; - if (event.metaKey) mods |= Mods.SUPER; - - // Note: CapsLock and NumLock are not in KeyboardEvent modifiers - // They would need to be tracked separately if needed - // For now, we don't set CAPSLOCK or NUMLOCK flags - - return mods; - } - - /** - * Check if this is a printable character with no special modifiers - * @param event - KeyboardEvent - * @returns true if printable character - */ - private isPrintableCharacter(event: KeyboardEvent): boolean { - // If Ctrl, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character - // Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters - if (event.ctrlKey && !event.altKey) return false; - if (event.altKey && !event.ctrlKey) return false; - if (event.metaKey) return false; // Cmd key on Mac - - // If key produces a single printable character - return event.key.length === 1; + /** Sends text typed or encoded from a keydown, and remembers it for the echo guard. */ + private sendKey(data: string): void { + this.onData(data); + this.echoes.recordKeyDown(data); } - /** - * Handle keydown event - * @param event - KeyboardEvent - */ private handleKeyDown(event: KeyboardEvent): void { - if (this.isDisposed) return; - // Ignore keydown events during composition // Note: Some browsers send keyCode 229 for all keys during composition - if (this.isComposing || event.isComposing || event.keyCode === 229) { - return; - } + if (this.isComposing || event.isComposing || event.keyCode === 229) return; - // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault) - if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { - // Let the browser's native paste event fire - return; - } + // Ctrl+V and Cmd+V are not prevented, so the browser's paste event fires + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') return; - // Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) - // Note: Ctrl+C on all platforms sends interrupt signal (0x03) + // Cmd+C copies the selection (on Mac it should copy, not send an interrupt; Ctrl+C + // sends 0x03 on every platform). With no selection it falls through to the browser, + // which may have other text selected. if (event.metaKey && event.code === 'KeyC') { - // Try to copy selection via callback - // If there's a selection and copy succeeds, prevent default - // If no selection, let it fall through (browser may have other text selected) - if (this.onCopyCallback && this.onCopyCallback()) { - event.preventDefault(); - } + if (this.onCopy?.()) event.preventDefault(); return; } - // For printable characters without modifiers, send the character directly - // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc. - if (this.isPrintableCharacter(event)) { + // Printable characters without modifiers are sent as they are: a-z, A-Z (with + // shift), 0-9, punctuation + if (isPrintableKey(event)) { event.preventDefault(); - this.onDataCallback(event.key); - this.recordKeyDownData(event.key); - return; - } - - // Map the physical key code - const key = this.mapKeyCode(event.code); - if (key === null) { - // Unknown key - ignore it + this.sendKey(event.key); return; } - // Extract modifiers - const mods = this.extractModifiers(event); + const key = keyForCode(event.code); + if (key === null) return; + const mods = keyMods(event); // The few keys the encoder does not send as xterm does; every other key, navigation // and function keys included, is encoded below, which follows the cursor key mode @@ -450,407 +143,60 @@ export class InputHandler { const plain = plainKeySequence(key, mods); if (plain !== null) { event.preventDefault(); - this.onDataCallback(plain); - this.recordKeyDownData(plain); + this.sendKey(plain); return; } - // Determine action (we only care about PRESS for now, not RELEASE or REPEAT) - const action = KeyAction.PRESS; - - // For non-printable keys or keys with modifiers, encode using Ghostty try { - // Sync encoder options with terminal mode state - // Mode 1 (DECCKM) controls whether arrow keys send CSI or SS3 sequences - if (this.getModeCallback) { - const appCursorMode = this.getModeCallback(1); - this.encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursorMode); - } - - // For letter/number keys, even with modifiers, pass the base character - // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01) - // For special keys (Enter, Arrow keys, etc.), don't pass utf8 - const utf8 = - event.key.length === 1 && event.key.charCodeAt(0) < 128 - ? event.key.toLowerCase() // Use lowercase for consistency - : undefined; - - const encoded = this.encoder.encode({ - action, - key, - mods, - utf8, - }); - - // Convert Uint8Array to string - const decoder = new TextDecoder(); - const data = decoder.decode(encoded); - - // Prevent default browser behavior + const data = encodeKeyPress(this.encoder, key, mods, event.key, this.getMode?.(1)); event.preventDefault(); event.stopPropagation(); - - // Emit the data - if (data.length > 0) { - this.onDataCallback(data); - this.recordKeyDownData(data); - } + if (data.length > 0) this.sendKey(data); } catch (error) { // Encoding failed - log but don't crash console.warn('Failed to encode key:', event.code, error); } } - /** - * Handle beforeinput event (mobile/IME input) - * @param event - InputEvent - */ + /** Mobile keyboards and IMEs: text, line breaks and deletes not seen as a keydown. */ private handleBeforeInput(event: InputEvent): void { - if (this.isDisposed) return; - - if (this.isComposing || event.isComposing) { - return; - } - - const inputType = event.inputType; + if (this.isComposing || event.isComposing) return; const data = event.data ?? ''; - let output: string | null = null; - - switch (inputType) { - case 'insertText': - case 'insertReplacementText': - output = data.length > 0 ? data.replace(/\n/g, '\r') : null; - break; - case 'insertLineBreak': - case 'insertParagraph': - output = '\r'; - break; - case 'deleteContentBackward': - output = '\x7F'; - break; - case 'deleteContentForward': - output = '\x1B[3~'; - break; - default: - return; - } - - if (!output) { - return; - } - - if (this.shouldIgnoreBeforeInput(output)) { - event.preventDefault(); - event.stopPropagation(); - return; - } - - if (data && this.shouldIgnoreBeforeInputFromComposition(data)) { - event.preventDefault(); - event.stopPropagation(); - return; - } + const output = beforeInputText(event.inputType, data); + if (!output) return; event.preventDefault(); event.stopPropagation(); - this.onDataCallback(output); - if (data) { - this.recordBeforeInputData(data); - } + // The same text a keydown or a composition just sent + if (this.echoes.isKeyDownEcho(output)) return; + if (data && this.echoes.isCompositionEcho(data)) return; + + this.onData(output); + if (data) this.echoes.recordBeforeInput(data); } - /** - * Handle compositionstart event - */ - private handleCompositionStart(_event: CompositionEvent): void { - if (this.isDisposed) return; + private handleCompositionStart(): void { this.isComposing = true; } - /** - * Handle compositionupdate event - */ - private handleCompositionUpdate(_event: CompositionEvent): void { - if (this.isDisposed) return; + private handleCompositionUpdate(): void { // We could track the current composition string here if we wanted to // display it in a custom way, but for now we rely on the browser's // input method editor UI. } - /** - * Handle compositionend event - */ private handleCompositionEnd(event: CompositionEvent): void { - if (this.isDisposed) return; this.isComposing = false; - const data = event.data; - if (data && data.length > 0) { - if (this.shouldIgnoreCompositionEnd(data)) { - this.cleanupCompositionTextNodes(); - return; - } - this.onDataCallback(data); - this.recordCompositionData(data); - } - - this.cleanupCompositionTextNodes(); - } - - /** - * Cleanup text nodes in container after composition - */ - private cleanupCompositionTextNodes(): void { - // Cleanup text nodes in container (fix for duplicate text display) - // When the container is contenteditable, the browser might insert text nodes - // upon composition end. We need to remove them to prevent duplicate display. - if (this.container && this.container.childNodes) { - for (let i = this.container.childNodes.length - 1; i >= 0; i--) { - const node = this.container.childNodes[i]; - // Node.TEXT_NODE === 3 - if (node.nodeType === 3) { - this.container.removeChild(node); - } - } - } - } - - // ========================================================================== - // Mouse Event Handling (for terminal mouse tracking) - // ========================================================================== - - /** Whether a mouse event is over the terminal (the container or what is in it). */ - private isOverTerminal(event: MouseEvent): boolean { - const target = event.target as Node | null; - return !!target && (target === this.container || !!this.container.contains?.(target)); - } - - /** - * Sends a mouse event as Ghostty encodes it. Ghostty decides what is reported: the - * tracking mode (presses only in X10 mode 9, no motion in 1000, motion with a button - * in 1002), motion once per cell, the padding as the nearest edge cell, and motion - * outside the terminal only while a button is held. (webtty, ADR 049) - */ - private sendMouseEvent( - event: MouseEvent, - action: MouseInput['action'], - button: MouseButton | null - ): void { - const data = this.mouseConfig?.encode({ - action, - button, - mods: mouseMods(event), - clientX: event.clientX, - clientY: event.clientY, - anyButtonPressed: this.mouseButtonsPressed !== 0, - }); - if (data) this.onDataCallback(data, true); - } - - /** - * Handle mousedown event - */ - private handleMouseDown(event: MouseEvent): void { - if (this.isDisposed) return; - const tracking = this.mouseConfig?.hasMouseTracking() ?? false; - if (!tracking) return; - - const button = MOUSE_BUTTONS[event.button]; - if (!button) return; - // Shift+press is webtty's: the app gets neither it, the drag nor the release - // (webtty, ADR 051) - if (!pressToApp(tracking, event.shiftKey)) { - this.mouseButtonsBypassed |= 1 << event.button; - return; - } - // Back and forward would leave the session's page (webtty) - if (event.button >= 3) event.preventDefault(); - - // Track pressed buttons for motion events - this.mouseButtonsPressed |= 1 << event.button; - - this.sendMouseEvent(event, 'press', button); - - // Don't prevent default - let SelectionManager handle selection - } - - /** - * Handle mouseup event, anywhere in the document: outside the terminal only for a - * button pressed in it (webtty: upstream lost a release outside the container) - */ - private handleMouseUp(event: MouseEvent): void { - if (this.isDisposed) return; - if (!this.mouseConfig?.hasMouseTracking()) return; - - const button = MOUSE_BUTTONS[event.button]; - if (!button) return; - const bit = 1 << event.button; - if (this.mouseButtonsBypassed & bit) { - this.mouseButtonsBypassed &= ~bit; - return; + if (data && data.length > 0 && !this.echoes.isBeforeInputEcho(data)) { + this.onData(data); + this.echoes.recordComposition(data); } - if (!(this.mouseButtonsPressed & bit) && !this.isOverTerminal(event)) return; - if (event.button >= 3) event.preventDefault(); - - // Clear pressed button - this.mouseButtonsPressed &= ~bit; - - this.sendMouseEvent(event, 'release', button); + removeCompositionText(this.container); } - /** - * Handle mousemove event, anywhere in the document: outside the terminal only while a - * button pressed in it is held, so a drag past the edge keeps reporting (webtty) - */ - private handleMouseMove(event: MouseEvent): void { - if (this.isDisposed) return; - if (!this.mouseConfig?.hasMouseTracking()) return; - - // No button held: a release outside the document never cleared the bitmask - if (event.buttons === 0) { - this.mouseButtonsPressed = 0; - this.mouseButtonsBypassed = 0; - } - // A Shift drag is webtty's selection (webtty, ADR 051) - if (this.mouseButtonsBypassed !== 0) return; - if (this.mouseButtonsPressed === 0 && !this.isOverTerminal(event)) return; - - this.sendMouseEvent(event, 'motion', motionButton(this.mouseButtonsPressed)); - } - - /** - * Record keydown data for beforeinput de-duplication - */ - private recordKeyDownData(data: string): void { - this.lastKeyDownData = data; - this.lastKeyDownTime = this.getNow(); - } - - /** - * Check if beforeinput should be ignored due to a recent keydown - */ - private shouldIgnoreBeforeInput(data: string): boolean { - if (!this.lastKeyDownData) { - return false; - } - const now = this.getNow(); - const isDuplicate = - now - this.lastKeyDownTime < InputHandler.BEFORE_INPUT_IGNORE_MS && - this.lastKeyDownData === data; - this.lastKeyDownData = null; - return isDuplicate; - } - - /** - * Check if beforeinput text should be ignored due to a recent composition end - */ - private shouldIgnoreBeforeInputFromComposition(data: string): boolean { - if (!this.lastCompositionData) { - return false; - } - const now = this.getNow(); - const isDuplicate = - now - this.lastCompositionTime < InputHandler.BEFORE_INPUT_IGNORE_MS && - this.lastCompositionData === data; - if (isDuplicate) { - this.lastCompositionData = null; - } - return isDuplicate; - } - - /** - * Check if composition end should be ignored due to a recent beforeinput text - */ - private shouldIgnoreCompositionEnd(data: string): boolean { - if (!this.lastBeforeInputData) { - return false; - } - const now = this.getNow(); - const isDuplicate = - now - this.lastBeforeInputTime < InputHandler.BEFORE_INPUT_IGNORE_MS && - this.lastBeforeInputData === data; - if (isDuplicate) { - this.lastBeforeInputData = null; - } - return isDuplicate; - } - - /** - * Record beforeinput text for composition de-duplication - */ - private recordBeforeInputData(data: string): void { - this.lastBeforeInputData = data; - this.lastBeforeInputTime = this.getNow(); - } - - /** - * Record composition end data for beforeinput de-duplication - */ - private recordCompositionData(data: string): void { - this.lastCompositionData = data; - this.lastCompositionTime = this.getNow(); - } - - /** - * Get current time in milliseconds - */ - private getNow(): number { - return typeof performance !== 'undefined' && typeof performance.now === 'function' - ? performance.now() - : Date.now(); - } - - /** - * Dispose the InputHandler and remove event listeners - */ + /** Removes every listener; a second call does nothing. */ dispose(): void { - if (this.isDisposed) return; - - if (this.keydownListener) { - this.container.removeEventListener('keydown', this.keydownListener); - this.keydownListener = null; - } - - if (this.keypressListener) { - this.container.removeEventListener('keypress', this.keypressListener); - this.keypressListener = null; - } - - if (this.beforeInputListener && this.inputElement) { - this.inputElement.removeEventListener('beforeinput', this.beforeInputListener); - this.beforeInputListener = null; - } - - if (this.compositionStartListener) { - this.container.removeEventListener('compositionstart', this.compositionStartListener); - this.compositionStartListener = null; - } - - if (this.compositionUpdateListener) { - this.container.removeEventListener('compositionupdate', this.compositionUpdateListener); - this.compositionUpdateListener = null; - } - - if (this.compositionEndListener) { - this.container.removeEventListener('compositionend', this.compositionEndListener); - this.compositionEndListener = null; - } - - if (this.mousedownListener) { - this.container.removeEventListener('mousedown', this.mousedownListener); - this.mousedownListener = null; - } - - if (this.mouseupListener) { - this.mouseTarget.removeEventListener('mouseup', this.mouseupListener); - this.mouseupListener = null; - } - - if (this.mousemoveListener) { - this.mouseTarget.removeEventListener('mousemove', this.mousemoveListener); - this.mousemoveListener = null; - } - - this.isDisposed = true; + this.listeners.dispose(); } } diff --git a/src/client/vendor/ghostty-web/input/key-encoding.ts b/src/client/vendor/ghostty-web/input/key-encoding.ts new file mode 100644 index 0000000..8de9e90 --- /dev/null +++ b/src/client/vendor/ghostty-web/input/key-encoding.ts @@ -0,0 +1,88 @@ +// Derived from coder/ghostty-web lib/input-handler.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Key encoding (util): what a keydown sends, from the DOM event to the bytes, through the +// key table and Ghostty's key encoder; the InputHandler decides when to call it (ADR 047, +// ADR 061). + +import type { KeyEncoder } from '../engine/engine-types'; +import { KEY_MAP } from './key-map'; +import { Key, KeyAction, KeyEncoderOption, Mods } from './key-types'; + +/** The parts of a keydown the encoding reads, so tests pass plain objects. */ +export type KeyEventFields = Pick< + KeyboardEvent, + 'key' | 'code' | 'shiftKey' | 'ctrlKey' | 'altKey' | 'metaKey' +>; + +/** The Ghostty key for a `KeyboardEvent.code`, or null for a key the table does not know. */ +export function keyForCode(code: string): Key | null { + return KEY_MAP[code] ?? null; +} + +/** + * The modifier flags of a key event. CapsLock and NumLock are not in KeyboardEvent's + * modifiers, so they are never set. + */ +export function keyMods(event: KeyEventFields): Mods { + let mods = Mods.NONE; + if (event.shiftKey) mods |= Mods.SHIFT; + if (event.ctrlKey) mods |= Mods.CTRL; + if (event.altKey) mods |= Mods.ALT; + if (event.metaKey) mods |= Mods.SUPER; + return mods; +} + +/** + * Whether a key types its character as it is: one character, and no Ctrl, Alt or Meta + * (Cmd on Mac), except Ctrl+Alt, which is AltGr on some keyboards and types characters. + */ +export function isPrintableKey(event: KeyEventFields): boolean { + if (event.ctrlKey && !event.altKey) return false; + if (event.altKey && !event.ctrlKey) return false; + if (event.metaKey) return false; + return event.key.length === 1; +} + +/** + * The sequence for a key the official encoder does not send as xterm does, or null to + * encode the key. For Shift+Enter and Shift+Escape the encoder sends Ghostty's + * modifyOtherKeys `CSI 27;2;13~` and `CSI 27;2;27~`, which most apps do not read; xterm + * sends CR and ESC, as upstream did. Every other key is encoded: navigation and function + * keys follow the cursor key mode (Home is `ESC O H` under DECCKM) and the modifiers + * (Shift+Home is `CSI 1;2 H`). (webtty: upstream sent a fixed table for Enter, Tab, + * Backspace, Escape, the navigation keys and F1-F12, unmodified or with Shift, which + * ignored both.) + */ +export function plainKeySequence(key: Key, mods: Mods): string | null { + if (mods !== Mods.SHIFT) return null; + if (key === Key.ENTER) return '\r'; + if (key === Key.ESCAPE) return '\x1b'; + return null; +} + +/** + * The text the encoder is given with a key: the lowercase base character of an ASCII + * key, even with modifiers, so Ctrl+A encodes as 0x01; none for Enter, the arrows and + * the other named keys. + */ +export function encoderText(eventKey: string): string | undefined { + return eventKey.length === 1 && eventKey.charCodeAt(0) < 128 ? eventKey.toLowerCase() : undefined; +} + +/** + * Encodes a key press with Ghostty's encoder and returns the text to send ('' for none). + * `appCursor` is DEC mode 1 (DECCKM), which picks CSI or SS3 for the cursor keys; when + * it is undefined the encoder keeps its option as it is. The encoder may throw. + */ +export function encodeKeyPress( + encoder: KeyEncoder, + key: Key, + mods: Mods, + eventKey: string, + appCursor: boolean | undefined, +): string { + if (appCursor !== undefined) { + encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursor); + } + const encoded = encoder.encode({ action: KeyAction.PRESS, key, mods, utf8: encoderText(eventKey) }); + return new TextDecoder().decode(encoded); +} diff --git a/src/client/vendor/ghostty-web/input/key-map.ts b/src/client/vendor/ghostty-web/input/key-map.ts new file mode 100644 index 0000000..f464d89 --- /dev/null +++ b/src/client/vendor/ghostty-web/input/key-map.ts @@ -0,0 +1,140 @@ +// Derived from coder/ghostty-web lib/input-handler.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The browser key table (constants): KeyboardEvent.code to the Ghostty `Key` the encoder +// takes, one entry per physical key, so the file is an exempt data table (ADR 061). + +import { Key } from './key-types'; + +/** + * Map KeyboardEvent.code values to USB HID Key enum values + * Based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code + */ +export const KEY_MAP: Readonly<Record<string, Key>> = { + // Letters + KeyA: Key.A, + KeyB: Key.B, + KeyC: Key.C, + KeyD: Key.D, + KeyE: Key.E, + KeyF: Key.F, + KeyG: Key.G, + KeyH: Key.H, + KeyI: Key.I, + KeyJ: Key.J, + KeyK: Key.K, + KeyL: Key.L, + KeyM: Key.M, + KeyN: Key.N, + KeyO: Key.O, + KeyP: Key.P, + KeyQ: Key.Q, + KeyR: Key.R, + KeyS: Key.S, + KeyT: Key.T, + KeyU: Key.U, + KeyV: Key.V, + KeyW: Key.W, + KeyX: Key.X, + KeyY: Key.Y, + KeyZ: Key.Z, + + // Numbers + Digit1: Key.ONE, + Digit2: Key.TWO, + Digit3: Key.THREE, + Digit4: Key.FOUR, + Digit5: Key.FIVE, + Digit6: Key.SIX, + Digit7: Key.SEVEN, + Digit8: Key.EIGHT, + Digit9: Key.NINE, + Digit0: Key.ZERO, + + // Special keys + Enter: Key.ENTER, + Escape: Key.ESCAPE, + Backspace: Key.BACKSPACE, + Tab: Key.TAB, + Space: Key.SPACE, + + // Punctuation + Minus: Key.MINUS, + Equal: Key.EQUAL, + BracketLeft: Key.BRACKET_LEFT, + BracketRight: Key.BRACKET_RIGHT, + Backslash: Key.BACKSLASH, + Semicolon: Key.SEMICOLON, + Quote: Key.QUOTE, + Backquote: Key.GRAVE, + Comma: Key.COMMA, + Period: Key.PERIOD, + Slash: Key.SLASH, + + // Function keys + CapsLock: Key.CAPS_LOCK, + F1: Key.F1, + F2: Key.F2, + F3: Key.F3, + F4: Key.F4, + F5: Key.F5, + F6: Key.F6, + F7: Key.F7, + F8: Key.F8, + F9: Key.F9, + F10: Key.F10, + F11: Key.F11, + F12: Key.F12, + + // Special function keys + PrintScreen: Key.PRINT_SCREEN, + ScrollLock: Key.SCROLL_LOCK, + Pause: Key.PAUSE, + Insert: Key.INSERT, + Home: Key.HOME, + PageUp: Key.PAGE_UP, + Delete: Key.DELETE, + End: Key.END, + PageDown: Key.PAGE_DOWN, + + // Arrow keys + ArrowRight: Key.RIGHT, + ArrowLeft: Key.LEFT, + ArrowDown: Key.DOWN, + ArrowUp: Key.UP, + + // Keypad + NumLock: Key.NUM_LOCK, + NumpadDivide: Key.KP_DIVIDE, + NumpadMultiply: Key.KP_MULTIPLY, + NumpadSubtract: Key.KP_MINUS, + NumpadAdd: Key.KP_PLUS, + NumpadEnter: Key.KP_ENTER, + Numpad1: Key.KP_1, + Numpad2: Key.KP_2, + Numpad3: Key.KP_3, + Numpad4: Key.KP_4, + Numpad5: Key.KP_5, + Numpad6: Key.KP_6, + Numpad7: Key.KP_7, + Numpad8: Key.KP_8, + Numpad9: Key.KP_9, + Numpad0: Key.KP_0, + NumpadDecimal: Key.KP_PERIOD, + + // International + IntlBackslash: Key.INTL_BACKSLASH, + ContextMenu: Key.CONTEXT_MENU, + + // Additional function keys + F13: Key.F13, + F14: Key.F14, + F15: Key.F15, + F16: Key.F16, + F17: Key.F17, + F18: Key.F18, + F19: Key.F19, + F20: Key.F20, + F21: Key.F21, + F22: Key.F22, + F23: Key.F23, + F24: Key.F24, +}; diff --git a/src/client/vendor/ghostty-web/input/mouse-report.ts b/src/client/vendor/ghostty-web/input/mouse-report.ts new file mode 100644 index 0000000..6cd96ce --- /dev/null +++ b/src/client/vendor/ghostty-web/input/mouse-report.ts @@ -0,0 +1,153 @@ +// Derived from coder/ghostty-web lib/input-handler.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Mouse reports to the app (object): MouseReporter keeps the buttons held and the ones +// webtty took (Shift), and sends each press, release and motion as Ghostty's mouse +// encoder reports it (ADR 049, ADR 051, ADR 061). + +import type { MouseButton, MouseInput } from '../engine/engine-types'; +import { pressToApp } from '../mouse-route'; +import { Mods } from './key-types'; + +/** A mouse event to report, at a client (viewport) position. (webtty, ADR 049) */ +export type MouseEventReport = Omit<MouseInput, 'x' | 'y'> & { clientX: number; clientY: number }; + +/** How the terminal's mouse tracking is read and its reports encoded. */ +export interface MouseTrackingConfig { + /** Check if any mouse tracking mode is enabled */ + hasMouseTracking: () => boolean; + /** + * The report for a mouse event at a client position, encoded by Ghostty's mouse + * encoder in the app's tracking mode and format, or '' when the app does not get one. + * (webtty: upstream encoded SGR or X10 itself, ADR 049) + */ + encode: (event: MouseEventReport) => string; +} + +/** + * Ghostty's button for `MouseEvent.button`: 0 left, 1 middle, 2 right, and 3/4 back + * and forward, which Ghostty reports as buttons 8/9 (codes 128/129). (webtty: upstream + * sent `MouseEvent.button` as the code, so back was a release and forward Shift.) + */ +export const MOUSE_BUTTONS: readonly MouseButton[] = ['left', 'middle', 'right', 'eight', 'nine']; + +/** + * The button a motion report carries, from the pressed-button bitmask (bit n is + * `MouseEvent.button` n): the lowest one held, or none, which Ghostty reports as 35. + * (webtty: upstream sent 32 for hover, the same code as a left-button drag.) + */ +export function motionButton(pressed: number): MouseButton | null { + for (let i = 0; i < MOUSE_BUTTONS.length; i++) { + if (pressed & (1 << i)) return MOUSE_BUTTONS[i]; + } + return null; +} + +/** + * The modifiers of a mouse event. Ghostty reports Shift (+4), Alt (+8) and Ctrl (+16), + * not Super. (webtty: upstream sent Meta as +8 and dropped Alt.) + */ +export function mouseMods( + e: Pick<MouseEvent, 'shiftKey' | 'altKey' | 'ctrlKey' | 'metaKey'>, +): number { + return ( + (e.shiftKey ? Mods.SHIFT : 0) | + (e.altKey ? Mods.ALT : 0) | + (e.ctrlKey ? Mods.CTRL : 0) | + (e.metaKey ? Mods.SUPER : 0) + ); +} + +/** What the reporter needs from the terminal's container. */ +export type MouseContainer = Pick<HTMLElement, 'contains'>; + +/** + * Turns the container's presses and the document's moves and releases into mouse + * reports while the app tracks the mouse. Ghostty decides what is reported: the + * tracking mode (presses only in X10 mode 9, no motion in 1000, motion with a button in + * 1002), motion once per cell, the padding as the nearest edge cell, and motion outside + * the terminal only while a button is held. (webtty, ADR 049) + */ +export class MouseReporter { + private pressed = 0; + // Buttons pressed with Shift while the app tracks the mouse: webtty's (selection, menu), + // so nothing about them reaches the app until they are released (webtty, ADR 051) + private bypassed = 0; + + /** `send` gets each report with mouseReport true, which leaves the selection alone (webtty, ADR 051). */ + constructor( + private readonly container: MouseContainer, + private readonly config: MouseTrackingConfig | undefined, + private readonly send: (data: string, mouseReport: true) => void, + ) {} + + /** A press on the terminal. Selection is left to the SelectionManager: nothing is prevented but back and forward. */ + down(event: MouseEvent): void { + const tracking = this.config?.hasMouseTracking() ?? false; + if (!tracking) return; + const button = MOUSE_BUTTONS[event.button]; + if (!button) return; + // Shift+press is webtty's: the app gets neither it, the drag nor the release + // (webtty, ADR 051) + if (!pressToApp(tracking, event.shiftKey)) { + this.bypassed |= 1 << event.button; + return; + } + // Back and forward would leave the session's page (webtty) + if (event.button >= 3) event.preventDefault(); + this.pressed |= 1 << event.button; + this.report(event, 'press', button); + } + + /** + * A release anywhere in the document: outside the terminal only for a button pressed + * in it (webtty: upstream lost a release outside the container) + */ + up(event: MouseEvent): void { + if (!this.config?.hasMouseTracking()) return; + const button = MOUSE_BUTTONS[event.button]; + if (!button) return; + const bit = 1 << event.button; + if (this.bypassed & bit) { + this.bypassed &= ~bit; + return; + } + if (!(this.pressed & bit) && !this.isOverTerminal(event)) return; + if (event.button >= 3) event.preventDefault(); + this.pressed &= ~bit; + this.report(event, 'release', button); + } + + /** + * A move anywhere in the document: outside the terminal only while a button pressed in + * it is held, so a drag past the edge keeps reporting (webtty) + */ + move(event: MouseEvent): void { + if (!this.config?.hasMouseTracking()) return; + // No button held: a release outside the document never cleared the bitmask + if (event.buttons === 0) { + this.pressed = 0; + this.bypassed = 0; + } + // A Shift drag is webtty's selection (webtty, ADR 051) + if (this.bypassed !== 0) return; + if (this.pressed === 0 && !this.isOverTerminal(event)) return; + this.report(event, 'motion', motionButton(this.pressed)); + } + + /** Whether a mouse event is over the terminal (the container or what is in it). */ + private isOverTerminal(event: MouseEvent): boolean { + const target = event.target as Node | null; + return !!target && (target === this.container || !!this.container.contains?.(target)); + } + + private report(event: MouseEvent, action: MouseInput['action'], button: MouseButton | null) { + const data = this.config?.encode({ + action, + button, + mods: mouseMods(event), + clientX: event.clientX, + clientY: event.clientY, + anyButtonPressed: this.pressed !== 0, + }); + if (data) this.send(data, true); + } +} diff --git a/src/client/vendor/ghostty-web/input/text-input.ts b/src/client/vendor/ghostty-web/input/text-input.ts new file mode 100644 index 0000000..80154ba --- /dev/null +++ b/src/client/vendor/ghostty-web/input/text-input.ts @@ -0,0 +1,115 @@ +// Derived from coder/ghostty-web lib/input-handler.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Text input from IMEs and mobile keyboards (object + util): the beforeinput text, the +// EchoGuard that drops text a browser reports twice (a keydown and its beforeinput, a +// composition and its beforeinput) within 100 ms, and the composition text-node cleanup. + +/** How long after one path sends text the same text on another path is an echo. */ +export const ECHO_WINDOW_MS = 100; + +/** The clock the guard reads, in ms: `performance.now()` where it exists, else `Date.now()`. */ +export function defaultNow(): number { + return typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); +} + +/** + * What a `beforeinput` sends, or null for one the terminal leaves alone (an empty + * insert, and every input type not listed). Paste is not here: webtty's capture + * listener takes every paste first (ADR 037), and a cancelled paste fires no + * insertFromPaste. (webtty) + */ +export function beforeInputText(inputType: string, data: string): string | null { + switch (inputType) { + case 'insertText': + case 'insertReplacementText': + return data.length > 0 ? data.replace(/\n/g, '\r') : null; + case 'insertLineBreak': + case 'insertParagraph': + return '\r'; + case 'deleteContentBackward': + return '\x7F'; + case 'deleteContentForward': + return '\x1B[3~'; + default: + return null; + } +} + +/** The last text one path sent and when. */ +interface Sent { + data: string | null; + time: number; +} + +/** + * Remembers the text each input path last sent, so the other path's report of the same + * text within ECHO_WINDOW_MS is dropped. The three checks clear differently, as upstream + * did: a keydown is matched against the next beforeinput only, a composition or a + * beforeinput until it is matched. + */ +export class EchoGuard { + private keyDown: Sent = { data: null, time: 0 }; + private composition: Sent = { data: null, time: 0 }; + private beforeInput: Sent = { data: null, time: 0 }; + + constructor(private readonly now: () => number = defaultNow) {} + + /** A keydown sent `data`. */ + recordKeyDown(data: string): void { + this.keyDown = { data, time: this.now() }; + } + + /** A composition end sent `data`. */ + recordComposition(data: string): void { + this.composition = { data, time: this.now() }; + } + + /** A beforeinput sent `data`. */ + recordBeforeInput(data: string): void { + this.beforeInput = { data, time: this.now() }; + } + + /** Whether a beforeinput's `output` repeats the last keydown; the keydown is forgotten either way. */ + isKeyDownEcho(output: string): boolean { + const echo = this.matches(this.keyDown, output); + this.keyDown.data = null; + return echo; + } + + /** Whether a beforeinput's text repeats the last composition, which is forgotten if so. */ + isCompositionEcho(data: string): boolean { + return this.consume(this.composition, data); + } + + /** Whether a composition's text repeats the last beforeinput, which is forgotten if so. */ + isBeforeInputEcho(data: string): boolean { + return this.consume(this.beforeInput, data); + } + + private consume(sent: Sent, data: string): boolean { + const echo = this.matches(sent, data); + if (echo) sent.data = null; + return echo; + } + + private matches(sent: Sent, data: string): boolean { + if (!sent.data) return false; + return this.now() - sent.time < ECHO_WINDOW_MS && sent.data === data; + } +} + +/** `Node.TEXT_NODE`, spelled out so the module loads where there is no DOM. */ +const TEXT_NODE = 3; + +/** + * Removes the text nodes a browser may insert into a contenteditable container when a + * composition ends, so the text does not show twice. + */ +export function removeCompositionText(container: Node): void { + if (!container.childNodes) return; + for (let i = container.childNodes.length - 1; i >= 0; i--) { + const node = container.childNodes[i]; + if (node.nodeType === TEXT_NODE) container.removeChild(node); + } +} diff --git a/src/client/vendor/tests/input-handler.test.ts b/src/client/vendor/tests/input-handler.test.ts index 966fb20..4ea32f0 100644 --- a/src/client/vendor/tests/input-handler.test.ts +++ b/src/client/vendor/tests/input-handler.test.ts @@ -1,327 +1,256 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../test-support/dom-window'; +import type { Ghostty } from '../ghostty-web/ghostty'; import { InputHandler, type MouseEventReport, - motionButton, - mouseMods, - plainKeySequence, + type MouseTrackingConfig, } from '../ghostty-web/input-handler'; -import { Key, Mods } from '../ghostty-web/types'; import { loadGhostty } from './support/wasm'; -describe('motionButton', () => { - test('no button held is none, which Ghostty reports as hover (35)', () => { - expect(motionButton(0)).toBeNull(); +// The InputHandler facade (ADR 061): its listeners, and what keys, IME compositions, +// beforeinput and mouse events send through it. + +let ghostty: Ghostty; +let dom: DomWindow; +let container: HTMLElement; +let textarea: HTMLTextAreaElement; +let sent: string[]; +let reports: boolean[]; + +beforeAll(async () => { + ghostty = await loadGhostty(); +}); + +beforeEach(() => { + dom = createDomWindow(); + container = dom.document.createElement('div'); + textarea = dom.document.createElement('textarea'); + container.append(textarea); + dom.document.body.append(container); + sent = []; + reports = []; +}); + +afterEach(() => dom.close()); + +interface HandlerOptions { + appCursor?: boolean; + getMode?: (mode: number) => boolean; + onCopy?: () => boolean; + mouse?: MouseTrackingConfig; +} + +/** An InputHandler on the test's container and textarea, recording what it sends. */ +function handler(options: HandlerOptions = {}): InputHandler { + const getMode = options.getMode ?? ((mode: number) => mode === 1 && !!options.appCursor); + return new InputHandler( + ghostty, + container, + (data, mouseReport) => { + sent.push(data); + reports.push(mouseReport ?? false); + }, + getMode, + options.onCopy, + textarea, + options.mouse, + ); +} + +/** Fires a keydown on the container; returns whether it was prevented and whether it propagated. */ +function keydown(code: string, key: string, init: KeyboardEventInit = {}) { + const event = new dom.window.KeyboardEvent('keydown', { + code, + key, + bubbles: true, + cancelable: true, + ...init, }); + let propagated = false; + const listener = () => { + propagated = true; + }; + dom.document.body.addEventListener('keydown', listener); + container.dispatchEvent(event); + dom.document.body.removeEventListener('keydown', listener); + return { prevented: event.defaultPrevented, propagated }; +} - test('a drag reports the lowest button held', () => { - expect(motionButton(1)).toBe('left'); - expect(motionButton(2)).toBe('middle'); - expect(motionButton(4)).toBe('right'); - expect(motionButton(1 | 4)).toBe('left'); - expect(motionButton(2 | 4)).toBe('middle'); - expect(motionButton(8)).toBe('eight'); +/** Fires a beforeinput on the textarea; returns whether it was prevented. */ +function beforeinput(inputType: string, data: string | null, isComposing = false): boolean { + const event = new dom.window.InputEvent('beforeinput', { + inputType, + data, + isComposing, + bubbles: true, + cancelable: true, }); -}); + textarea.dispatchEvent(event); + return event.defaultPrevented; +} -describe('mouseMods', () => { - test('passes Shift, Alt, Ctrl and Meta as their own bits', () => { - const none = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; - expect(mouseMods(none)).toBe(Mods.NONE); - expect(mouseMods({ ...none, altKey: true })).toBe(Mods.ALT); - expect(mouseMods({ ...none, metaKey: true })).toBe(Mods.SUPER); - expect(mouseMods({ ...none, shiftKey: true, ctrlKey: true })).toBe(Mods.SHIFT | Mods.CTRL); +/** Fires a composition event on the container (happy-dom's CompositionEvent drops `data`). */ +function composition(type: string, data = ''): void { + container.dispatchEvent(Object.assign(new dom.window.Event(type, { bubbles: true }), { data })); +} + +describe('InputHandler set-up and teardown', () => { + test('makes the container focusable with no outline, keeping its own tabindex', () => { + handler(); + expect(container.getAttribute('tabindex')).toBe('0'); + // happy-dom reads the shorthand back as 'none none' + expect(container.style.outline).toStartWith('none'); + container.setAttribute('tabindex', '-1'); + handler(); + expect(container.getAttribute('tabindex')).toBe('-1'); + }); + + test('dispose() removes every listener, and a second call does nothing', () => { + const add = [ + spyOn(container, 'addEventListener'), + spyOn(textarea, 'addEventListener'), + spyOn(dom.document, 'addEventListener'), + ]; + const remove = [ + spyOn(container, 'removeEventListener'), + spyOn(textarea, 'removeEventListener'), + spyOn(dom.document, 'removeEventListener'), + ]; + const input = handler(); + const added = add.map((spy) => spy.mock.calls.map(([type]) => type)); + expect(added).toEqual([ + ['keydown', 'compositionstart', 'compositionupdate', 'compositionend', 'mousedown'], + ['beforeinput'], + ['mouseup', 'mousemove'], + ]); + input.dispose(); + const removed = remove.map((spy) => spy.mock.calls.map(([type]) => type).sort()); + expect(removed).toEqual(added.map((types) => [...types].sort())); + input.dispose(); + expect(remove.map((spy) => spy.mock.calls.length)).toEqual([5, 1, 2]); + // Nothing is sent after dispose + keydown('KeyA', 'a'); + expect(sent).toEqual([]); + }); + + test('without an input element there is no beforeinput listener', () => { + const spy = spyOn(container, 'addEventListener'); + new InputHandler(ghostty, container, (data) => sent.push(data)).dispose(); + expect(spy.mock.calls.map(([type]) => type)).not.toContain('beforeinput'); }); }); -describe('InputHandler mouse', () => { - type Listener = (e: object) => void; - const target = () => { - const listeners = new Map<string, Listener[]>(); - return { - listeners, - addEventListener: (type: string, listener: Listener) => - listeners.set(type, [...(listeners.get(type) ?? []), listener]), - removeEventListener: (type: string, listener: Listener) => - listeners.set( - type, - (listeners.get(type) ?? []).filter((l) => l !== listener), - ), - fire: (type: string, e: object) => { - for (const l of listeners.get(type) ?? []) l(e); - }, - }; - }; +describe('InputHandler keys', () => { + test('a printable key is sent as it is and prevented', () => { + handler(); + expect(keydown('KeyA', 'a')).toEqual({ prevented: true, propagated: true }); + expect(keydown('KeyA', 'A', { shiftKey: true }).prevented).toBe(true); + expect(sent).toEqual(['a', 'A']); + }); - async function setup() { - const ghostty = await loadGhostty(); - const doc = target(); - const outside = {}; - const container = { - ...target(), - ownerDocument: doc, - contains: (node: unknown) => node !== outside, - }; - const encoded: MouseEventReport[] = []; - const sent: string[] = []; - const reports: boolean[] = []; - const handler = new InputHandler( - ghostty, - container as unknown as HTMLElement, - (data, mouseReport) => { - sent.push(data); - reports.push(mouseReport ?? false); - }, - undefined, - undefined, - undefined, - { - hasMouseTracking: () => true, - encode: (event) => { - encoded.push(event); - return `<${event.action}:${event.button}>`; - }, - }, - ); - const mouse = (over: boolean, extra: object) => { - const prevented = { value: false }; - return { - prevented, - event: { - target: over ? container : outside, - button: 0, - buttons: 0, - clientX: 12, - clientY: 34, - shiftKey: false, - altKey: false, - ctrlKey: false, - metaKey: false, - preventDefault: () => { - prevented.value = true; - }, - ...extra, - }, - }; - }; - return { handler, container, doc, encoded, sent, reports, mouse }; - } + test('an encoded key is prevented and stopped', () => { + handler(); + expect(keydown('ArrowUp', 'ArrowUp')).toEqual({ prevented: true, propagated: false }); + expect(keydown('KeyC', 'c', { ctrlKey: true })).toEqual({ prevented: true, propagated: false }); + expect(sent).toEqual(['\x1b[A', '\x03']); + }); - test('reports a press, then a drag and its release past the terminal edge', async () => { - const { handler, container, doc, encoded, sent, mouse } = await setup(); - container.fire('mousedown', mouse(true, { button: 0, buttons: 1, altKey: true }).event); - doc.fire('mousemove', mouse(false, { buttons: 1, clientX: -40 }).event); - doc.fire('mouseup', mouse(false, { button: 0, clientX: -40 }).event); - expect(sent).toEqual(['<press:left>', '<motion:left>', '<release:left>']); - expect(encoded[0]).toEqual({ - action: 'press', - button: 'left', - mods: Mods.ALT, - clientX: 12, - clientY: 34, - anyButtonPressed: true, + test('Shift+Enter is CR, prevented but not stopped', () => { + handler(); + expect(keydown('Enter', 'Enter', { shiftKey: true })).toEqual({ + prevented: true, + propagated: true, }); - expect(encoded[1]).toMatchObject({ clientX: -40, anyButtonPressed: true }); - expect(encoded[2]).toMatchObject({ action: 'release', anyButtonPressed: false }); - handler.dispose(); - expect(doc.listeners.get('mousemove')).toEqual([]); + expect(sent).toEqual(['\r']); }); - test('mouse reports are marked as such, so they leave the selection alone', async () => { - const { handler, container, doc, reports, mouse } = await setup(); - doc.fire('mousemove', mouse(true, {}).event); - container.fire('mousedown', mouse(true, { button: 0, buttons: 1 }).event); - doc.fire('mouseup', mouse(true, { button: 0 }).event); - expect(reports).toEqual([true, true, true]); - handler.dispose(); - }); - - test('hover is reported over the terminal only, with no button', async () => { - const { handler, doc, sent, mouse } = await setup(); - doc.fire('mousemove', mouse(true, {}).event); - doc.fire('mousemove', mouse(false, {}).event); - // A release outside the terminal of a button not pressed in it - doc.fire('mouseup', mouse(false, { button: 2 }).event); - expect(sent).toEqual(['<motion:null>']); - handler.dispose(); - }); - - // Shift is the bypass, as in Ghostty and xterm (ADR 051) - test('a Shift drag and its release send nothing, even if Shift is let go', async () => { - const { handler, container, doc, sent, mouse } = await setup(); - container.fire('mousedown', mouse(true, { button: 0, buttons: 1, shiftKey: true }).event); - doc.fire('mousemove', mouse(true, { buttons: 1, clientX: 30, shiftKey: true }).event); - doc.fire('mousemove', mouse(true, { buttons: 1, clientX: 50 }).event); - doc.fire('mouseup', mouse(true, { button: 0, clientX: 50 }).event); + test('Ctrl+V and Cmd+V pass through to the browser paste', () => { + handler(); + expect(keydown('KeyV', 'v', { ctrlKey: true })).toEqual({ prevented: false, propagated: true }); + expect(keydown('KeyV', 'v', { metaKey: true }).prevented).toBe(false); expect(sent).toEqual([]); - // The next press without Shift is the app's again - container.fire('mousedown', mouse(true, { button: 0, buttons: 1 }).event); - doc.fire('mouseup', mouse(true, { button: 0 }).event); - expect(sent).toEqual(['<press:left>', '<release:left>']); - handler.dispose(); }); - test('a Shift right-click sends nothing; a right-click is the app’s', async () => { - const { handler, container, doc, sent, mouse } = await setup(); - container.fire('mousedown', mouse(true, { button: 2, buttons: 2, shiftKey: true }).event); - doc.fire('mouseup', mouse(true, { button: 2, shiftKey: true }).event); + test('Cmd+C copies a selection and is prevented; with none it passes through', () => { + let selection = true; + handler({ onCopy: () => selection }); + expect(keydown('KeyC', 'c', { metaKey: true }).prevented).toBe(true); + selection = false; + expect(keydown('KeyC', 'c', { metaKey: true }).prevented).toBe(false); expect(sent).toEqual([]); - container.fire('mousedown', mouse(true, { button: 2, buttons: 2 }).event); - doc.fire('mouseup', mouse(true, { button: 2 }).event); - expect(sent).toEqual(['<press:right>', '<release:right>']); - handler.dispose(); - }); - - test('back and forward are buttons 8 and 9, and do not navigate', async () => { - const { handler, container, doc, sent, mouse } = await setup(); - const down = mouse(true, { button: 3, buttons: 8 }); - container.fire('mousedown', down.event); - const up = mouse(true, { button: 4 }); - doc.fire('mouseup', up.event); - expect(sent).toEqual(['<press:eight>', '<release:nine>']); - expect(down.prevented.value).toBe(true); - expect(up.prevented.value).toBe(true); - handler.dispose(); }); -}); -describe('InputHandler wheel', () => { - // The terminal's own wheel listener, paced, is the only wheel path: one here also fired - // for a wheel over the padding, where the container is the target, and sent the tick - // twice. - test('sends nothing for a wheel event on the container, even with mouse tracking', async () => { - const ghostty = await loadGhostty(); - const listeners = new Map<string, ((e: object) => void)[]>(); - const container = { - addEventListener: (type: string, listener: (e: object) => void) => - listeners.set(type, [...(listeners.get(type) ?? []), listener]), - removeEventListener: () => {}, - } as unknown as HTMLElement; - const sent: string[] = []; - const handler = new InputHandler( - ghostty, - container, - (data) => sent.push(data), - undefined, - undefined, - undefined, - { - hasMouseTracking: () => true, - encode: () => 'report', - }, - ); - const wheel = { deltaY: 40, clientX: 2, clientY: 2, preventDefault: () => {} }; - for (const listener of listeners.get('wheel') ?? []) listener(wheel); + test('Cmd+C with no copy callback passes through', () => { + handler(); + expect(keydown('KeyC', 'c', { metaKey: true }).prevented).toBe(false); expect(sent).toEqual([]); - handler.dispose(); }); -}); -describe('InputHandler paste', () => { - // webtty's capture listener on the container takes every paste and stops it (ADR 037), - // so the handler's own paste path never ran. - test('sends nothing for a paste or an insertFromPaste beforeinput', async () => { - const ghostty = await loadGhostty(); - const listeners = new Map<string, ((e: object) => void)[]>(); - const element = { - addEventListener: (type: string, listener: (e: object) => void) => - listeners.set(type, [...(listeners.get(type) ?? []), listener]), - removeEventListener: () => {}, - } as unknown as HTMLElement; - const sent: string[] = []; - const handler = new InputHandler( - ghostty, - element, - (data) => sent.push(data), - () => true, - undefined, - element, - ); - const paste = { - clipboardData: { getData: () => 'a\nb' }, - preventDefault: () => {}, - stopPropagation: () => {}, - }; - for (const listener of listeners.get('paste') ?? []) listener(paste); - const input = { - inputType: 'insertFromPaste', - data: 'a\nb', - isComposing: false, - preventDefault: () => {}, - stopPropagation: () => {}, - }; - for (const listener of listeners.get('beforeinput') ?? []) listener(input); + test('keyCode 229 and composing keydowns are the IME’s', () => { + handler(); + expect(keydown('KeyA', 'a', { keyCode: 229 }).prevented).toBe(false); + expect(keydown('KeyA', 'a', { isComposing: true }).prevented).toBe(false); expect(sent).toEqual([]); - handler.dispose(); }); -}); -describe('plainKeySequence', () => { - test('sends CR and ESC for Shift+Enter and Shift+Escape, as xterm does', () => { - expect(plainKeySequence(Key.ENTER, Mods.SHIFT)).toBe('\r'); - expect(plainKeySequence(Key.ESCAPE, Mods.SHIFT)).toBe('\x1b'); + test('a key the table does not know is left alone', () => { + handler(); + expect(keydown('MediaPlayPause', 'MediaPlayPause').prevented).toBe(false); + expect(sent).toEqual([]); }); - test('leaves every other key to the encoder', () => { - for (const key of [Key.ENTER, Key.ESCAPE, Key.TAB, Key.BACKSPACE, Key.HOME, Key.F1]) { - expect(plainKeySequence(key, Mods.NONE)).toBeNull(); - expect(plainKeySequence(key, Mods.CTRL)).toBeNull(); - } - for (const key of [Key.TAB, Key.BACKSPACE, Key.HOME, Key.END, Key.PAGE_UP, Key.F5]) { - expect(plainKeySequence(key, Mods.SHIFT)).toBeNull(); - } + test('a key the encoder sends nothing for is prevented and sends nothing', () => { + handler(); + expect(keydown('CapsLock', 'CapsLock').prevented).toBe(true); + expect(sent).toEqual([]); + }); + + test('an encoding failure is logged, not thrown, and the key is not prevented', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + handler({ + getMode: () => { + throw new Error('freed'); + }, + }); + expect(keydown('ArrowUp', 'ArrowUp').prevented).toBe(false); + expect(sent).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0].slice(0, 2)).toEqual(['Failed to encode key:', 'ArrowUp']); + warn.mockRestore(); }); }); -describe('InputHandler keys', () => { +describe('InputHandler key bytes', () => { /** Sends a keydown through a handler and returns what it wrote, under DECCKM if asked. */ - async function keys(appCursor = false) { - const ghostty = await loadGhostty(); - const listeners = new Map<string, ((e: object) => void)[]>(); - const container = { - addEventListener: (type: string, listener: (e: object) => void) => - listeners.set(type, [...(listeners.get(type) ?? []), listener]), - removeEventListener: () => {}, - } as unknown as HTMLElement; - let sent: string[] = []; - new InputHandler( - ghostty, - container, - (data) => sent.push(data), - (mode) => mode === 1 && appCursor, - ); + function keys(appCursor = false) { + handler({ appCursor }); return (code: string, key: string, mods: string[] = []) => { sent = []; - const e = { - code, - key, - keyCode: 0, - isComposing: false, + keydown(code, key, { shiftKey: mods.includes('shift'), ctrlKey: mods.includes('ctrl'), altKey: mods.includes('alt'), metaKey: mods.includes('meta'), - preventDefault: () => {}, - stopPropagation: () => {}, - }; - for (const listener of listeners.get('keydown') ?? []) listener(e); + }); return sent.join(''); }; } - test('Home and End follow application cursor mode, like the arrows', async () => { - const normal = await keys(false); + test('Home and End follow application cursor mode, like the arrows', () => { + const normal = keys(false); expect(normal('Home', 'Home')).toBe('\x1b[H'); expect(normal('End', 'End')).toBe('\x1b[F'); expect(normal('ArrowUp', 'ArrowUp')).toBe('\x1b[A'); - const app = await keys(true); + container = dom.document.createElement('div'); + const app = keys(true); expect(app('Home', 'Home')).toBe('\x1bOH'); expect(app('End', 'End')).toBe('\x1bOF'); expect(app('ArrowUp', 'ArrowUp')).toBe('\x1bOA'); }); - test('navigation keys carry their modifiers', async () => { - const send = await keys(); + test('navigation keys carry their modifiers', () => { + const send = keys(); expect(send('Home', 'Home', ['shift'])).toBe('\x1b[1;2H'); expect(send('End', 'End', ['ctrl'])).toBe('\x1b[1;5F'); expect(send('PageUp', 'PageUp')).toBe('\x1b[5~'); @@ -333,8 +262,8 @@ describe('InputHandler keys', () => { expect(send('Delete', 'Delete', ['alt'])).toBe('\x1b[3;3~'); }); - test('function keys carry their modifiers', async () => { - const send = await keys(); + test('function keys carry their modifiers', () => { + const send = keys(); expect(send('F1', 'F1')).toBe('\x1bOP'); expect(send('F4', 'F4')).toBe('\x1bOS'); expect(send('F5', 'F5')).toBe('\x1b[15~'); @@ -343,8 +272,8 @@ describe('InputHandler keys', () => { expect(send('F5', 'F5', ['ctrl'])).toBe('\x1b[15;5~'); }); - test('Enter, Tab and Shift+Tab', async () => { - const send = await keys(); + test('Enter, Tab and Shift+Tab', () => { + const send = keys(); expect(send('Enter', 'Enter')).toBe('\r'); expect(send('Enter', 'Enter', ['shift'])).toBe('\r'); expect(send('Enter', 'Enter', ['alt'])).toBe('\x1b\r'); @@ -352,25 +281,179 @@ describe('InputHandler keys', () => { expect(send('Tab', 'Tab', ['shift'])).toBe('\x1b[Z'); }); - test('Backspace sends DEL, ESC DEL with Alt and BS with Ctrl', async () => { - const send = await keys(); + test('Backspace sends DEL, ESC DEL with Alt and BS with Ctrl', () => { + const send = keys(); expect(send('Backspace', 'Backspace')).toBe('\x7f'); expect(send('Backspace', 'Backspace', ['shift'])).toBe('\x7f'); expect(send('Backspace', 'Backspace', ['alt'])).toBe('\x1b\x7f'); expect(send('Backspace', 'Backspace', ['ctrl'])).toBe('\b'); }); - test('Escape sends ESC, unmodified or with Shift', async () => { - const send = await keys(); + test('Escape sends ESC, unmodified or with Shift', () => { + const send = keys(); expect(send('Escape', 'Escape')).toBe('\x1b'); expect(send('Escape', 'Escape', ['shift'])).toBe('\x1b'); expect(send('Escape', 'Escape', ['alt'])).toBe('\x1b\x1b'); }); - test('keypad digits and operators send their characters', async () => { - const send = await keys(); + test('Alt and Ctrl letters', () => { + const send = keys(); + // The encoder's Alt-ESC prefix option is never set, so Alt+letter is the letter + expect(send('KeyA', 'a', ['alt'])).toBe('a'); + expect(send('KeyZ', 'z', ['ctrl'])).toBe('\x1a'); + // AltGr types its character + expect(send('KeyE', '€', ['ctrl', 'alt'])).toBe('€'); + }); + + test('keypad digits and operators send their characters', () => { + const send = keys(); expect(send('Numpad1', '1')).toBe('1'); expect(send('NumpadAdd', '+')).toBe('+'); expect(send('NumpadEnter', 'Enter')).toBe('\r'); }); }); + +describe('InputHandler beforeinput', () => { + test('text, line breaks and deletes are sent and prevented', () => { + handler(); + expect(beforeinput('insertText', 'a\nb')).toBe(true); + expect(beforeinput('insertReplacementText', 'word')).toBe(true); + expect(beforeinput('insertLineBreak', null)).toBe(true); + expect(beforeinput('insertParagraph', null)).toBe(true); + expect(beforeinput('deleteContentBackward', null)).toBe(true); + expect(beforeinput('deleteContentForward', null)).toBe(true); + expect(sent).toEqual(['a\rb', 'word', '\r', '\r', '\x7F', '\x1B[3~']); + }); + + test('other input types and empty text are left to the browser', () => { + handler(); + expect(beforeinput('insertText', '')).toBe(false); + expect(beforeinput('insertText', null)).toBe(false); + expect(beforeinput('deleteWordBackward', null)).toBe(false); + expect(beforeinput('formatBold', null)).toBe(false); + expect(sent).toEqual([]); + }); + + test('a beforeinput during a composition is the IME’s', () => { + handler(); + expect(beforeinput('insertText', 'a', true)).toBe(false); + composition('compositionstart'); + expect(beforeinput('insertText', 'a')).toBe(false); + expect(sent).toEqual([]); + }); + + test('the beforeinput echo of a keydown is prevented but not sent again', () => { + handler(); + keydown('KeyA', 'a'); + expect(beforeinput('insertText', 'a')).toBe(true); + // The keydown is forgotten after one check + expect(beforeinput('insertText', 'a')).toBe(true); + expect(sent).toEqual(['a', 'a']); + }); + + test('a different beforeinput after a keydown is sent', () => { + handler(); + keydown('KeyA', 'a'); + beforeinput('insertText', 'á'); + expect(sent).toEqual(['a', 'á']); + }); + + // Paste is webtty's capture listener's (ADR 037), so the handler's own path never ran. + test('sends nothing for a paste or an insertFromPaste beforeinput', () => { + handler(); + container.dispatchEvent(new dom.window.Event('paste', { bubbles: true, cancelable: true })); + expect(beforeinput('insertFromPaste', 'a\nb')).toBe(false); + expect(sent).toEqual([]); + }); +}); + +describe('InputHandler composition', () => { + test('a composition sends its text at the end, not its keydowns', () => { + handler(); + composition('compositionstart'); + expect(keydown('KeyN', 'n').prevented).toBe(false); + composition('compositionupdate', 'に'); + composition('compositionend', '日本'); + expect(sent).toEqual(['日本']); + // Keys reach the terminal again after the composition + keydown('KeyA', 'a'); + expect(sent).toEqual(['日本', 'a']); + }); + + test('an empty composition sends nothing', () => { + handler(); + composition('compositionstart'); + composition('compositionend', ''); + expect(sent).toEqual([]); + }); + + test('the text nodes a composition leaves in the container are removed', () => { + handler(); + composition('compositionstart'); + container.append('日本'); + composition('compositionend', '日本'); + expect([...container.childNodes]).toEqual([textarea]); + // Also when the composition was an echo + beforeinput('insertText', 'x'); + container.append('x'); + composition('compositionend', 'x'); + expect([...container.childNodes]).toEqual([textarea]); + }); + + test('the beforeinput echo of a composition is prevented but not sent again', () => { + handler(); + composition('compositionstart'); + composition('compositionend', '日本'); + expect(beforeinput('insertText', '日本')).toBe(true); + // Remembered only until it is matched + beforeinput('insertText', '日本'); + expect(sent).toEqual(['日本', '日本']); + }); + + test('a composition end repeating a beforeinput is not sent again', () => { + handler(); + beforeinput('insertText', 'é'); + composition('compositionend', 'é'); + expect(sent).toEqual(['é']); + }); +}); + +describe('InputHandler mouse', () => { + function tracking(): MouseTrackingConfig & { encoded: MouseEventReport[] } { + const encoded: MouseEventReport[] = []; + return { + encoded, + hasMouseTracking: () => true, + encode: (event) => { + encoded.push(event); + return `<${event.action}:${event.button}>`; + }, + }; + } + + function mouse(target: EventTarget, type: string, init: MouseEventInit = {}): void { + target.dispatchEvent( + new dom.window.MouseEvent(type, { bubbles: true, cancelable: true, ...init }), + ); + } + + test('presses on the container, moves and releases on the document, as mouse reports', () => { + const config = tracking(); + handler({ mouse: config }); + mouse(container, 'mousedown', { button: 0, buttons: 1 }); + mouse(dom.document.body, 'mousemove', { buttons: 1, clientX: -40 }); + mouse(dom.document.body, 'mouseup', { button: 0 }); + expect(sent).toEqual(['<press:left>', '<motion:left>', '<release:left>']); + expect(reports).toEqual([true, true, true]); + expect(config.encoded[1]).toMatchObject({ clientX: -40, anyButtonPressed: true }); + }); + + // The terminal's own wheel listener, paced, is the only wheel path: one here also fired + // for a wheel over the padding, where the container is the target, and sent the tick + // twice. + test('sends nothing for a wheel event on the container, even with mouse tracking', () => { + handler({ mouse: tracking() }); + container.dispatchEvent(new dom.window.WheelEvent('wheel', { deltaY: 40, bubbles: true })); + expect(sent).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/input/key-encoding.test.ts b/src/client/vendor/tests/input/key-encoding.test.ts new file mode 100644 index 0000000..5855f6c --- /dev/null +++ b/src/client/vendor/tests/input/key-encoding.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from 'bun:test'; +import type { KeyEncoder } from '../../ghostty-web/engine/engine-types'; +import { + encodeKeyPress, + encoderText, + isPrintableKey, + type KeyEventFields, + keyForCode, + keyMods, + plainKeySequence, +} from '../../ghostty-web/input/key-encoding'; +import { KEY_MAP } from '../../ghostty-web/input/key-map'; +import { + Key, + KeyAction, + KeyEncoderOption, + type KeyEvent, + Mods, +} from '../../ghostty-web/input/key-types'; +import { loadGhostty } from '../support/wasm'; + +// The DOM key to bytes path of the InputHandler (ADR 047, ADR 061). + +const NO_MODS = { shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }; + +/** A key event with only the named modifiers held. */ +function keyEvent(key: string, ...mods: (keyof typeof NO_MODS)[]): KeyEventFields { + const held = Object.fromEntries(mods.map((mod) => [mod, true])); + return { key, code: '', ...NO_MODS, ...held }; +} + +/** A key encoder that records what it is given and answers with fixed bytes. */ +class RecordingEncoder implements KeyEncoder { + readonly options: [KeyEncoderOption, boolean | number][] = []; + readonly events: KeyEvent[] = []; + constructor(private readonly reply: Uint8Array) {} + setOption(option: KeyEncoderOption, value: boolean | number): void { + this.options.push([option, value]); + } + encode(event: KeyEvent): Uint8Array { + this.events.push(event); + return this.reply; + } + dispose(): void {} +} + +describe('keyForCode', () => { + test.each([ + ['KeyA', Key.A], + ['Digit0', Key.ZERO], + ['Enter', Key.ENTER], + ['Backquote', Key.GRAVE], + ['ArrowLeft', Key.LEFT], + ['NumpadEnter', Key.KP_ENTER], + ['IntlBackslash', Key.INTL_BACKSLASH], + ['F24', Key.F24], + ])('%s is %p', (code, key) => { + expect(keyForCode(code)).toBe(key); + }); + + test('a code the table does not know is null', () => { + expect(keyForCode('')).toBeNull(); + expect(keyForCode('Fn')).toBeNull(); + expect(keyForCode('MediaPlayPause')).toBeNull(); + }); + + test('every entry maps to a distinct key', () => { + const keys = Object.values(KEY_MAP); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +describe('keyMods', () => { + test.each([ + [[], Mods.NONE], + [['shiftKey'], Mods.SHIFT], + [['ctrlKey'], Mods.CTRL], + [['altKey'], Mods.ALT], + [['metaKey'], Mods.SUPER], + [['shiftKey', 'ctrlKey', 'altKey', 'metaKey'], Mods.SHIFT | Mods.CTRL | Mods.ALT | Mods.SUPER], + ] as [(keyof typeof NO_MODS)[], Mods][])('%p is %p', (mods, expected) => { + expect(keyMods(keyEvent('a', ...mods))).toBe(expected); + }); +}); + +describe('isPrintableKey', () => { + test.each([ + ['a', [], true], + ['A', ['shiftKey'], true], + ['€', ['ctrlKey', 'altKey'], true], // AltGr + ['a', ['ctrlKey'], false], + ['a', ['altKey'], false], + ['a', ['metaKey'], false], + ['a', ['ctrlKey', 'altKey', 'metaKey'], false], + ['Enter', [], false], + ['ArrowUp', ['shiftKey'], false], + ] as [string, (keyof typeof NO_MODS)[], boolean][])('%p with %p is %p', (key, mods, expected) => { + expect(isPrintableKey(keyEvent(key, ...mods))).toBe(expected); + }); +}); + +describe('plainKeySequence', () => { + test('sends CR and ESC for Shift+Enter and Shift+Escape, as xterm does', () => { + expect(plainKeySequence(Key.ENTER, Mods.SHIFT)).toBe('\r'); + expect(plainKeySequence(Key.ESCAPE, Mods.SHIFT)).toBe('\x1b'); + }); + + test('leaves every other key to the encoder', () => { + for (const key of [Key.ENTER, Key.ESCAPE, Key.TAB, Key.BACKSPACE, Key.HOME, Key.F1]) { + expect(plainKeySequence(key, Mods.NONE)).toBeNull(); + expect(plainKeySequence(key, Mods.CTRL)).toBeNull(); + } + for (const key of [Key.TAB, Key.BACKSPACE, Key.HOME, Key.END, Key.PAGE_UP, Key.F5]) { + expect(plainKeySequence(key, Mods.SHIFT)).toBeNull(); + } + }); +}); + +describe('encoderText', () => { + test.each([ + ['a', 'a'], + ['A', 'a'], + ['1', '1'], + ['~', '~'], + ['é', undefined], + ['Enter', undefined], + ['', undefined], + ])('%p gives %p', (key, expected) => { + expect(encoderText(key)).toBe(expected); + }); +}); + +describe('encodeKeyPress', () => { + test('encodes a press with the base character and decodes the bytes', () => { + const encoder = new RecordingEncoder(new TextEncoder().encode('\x01')); + expect(encodeKeyPress(encoder, Key.A, Mods.CTRL, 'A', undefined)).toBe('\x01'); + expect(encoder.events).toEqual([ + { action: KeyAction.PRESS, key: Key.A, mods: Mods.CTRL, utf8: 'a' }, + ]); + // No mode callback: the cursor key option is left as it is + expect(encoder.options).toEqual([]); + }); + + test('sets the cursor key mode from DECCKM before encoding', () => { + const encoder = new RecordingEncoder(new Uint8Array()); + expect(encodeKeyPress(encoder, Key.UP, Mods.NONE, 'ArrowUp', true)).toBe(''); + encodeKeyPress(encoder, Key.UP, Mods.NONE, 'ArrowUp', false); + expect(encoder.options).toEqual([ + [KeyEncoderOption.CURSOR_KEY_APPLICATION, true], + [KeyEncoderOption.CURSOR_KEY_APPLICATION, false], + ]); + expect(encoder.events[0].utf8).toBeUndefined(); + }); + + test('the official encoder gives the xterm bytes', async () => { + const encoder = (await loadGhostty()).createKeyEncoder(); + expect(encodeKeyPress(encoder, Key.UP, Mods.NONE, 'ArrowUp', false)).toBe('\x1b[A'); + expect(encodeKeyPress(encoder, Key.UP, Mods.NONE, 'ArrowUp', true)).toBe('\x1bOA'); + expect(encodeKeyPress(encoder, Key.C, Mods.CTRL, 'c', false)).toBe('\x03'); + encoder.dispose(); + }); +}); diff --git a/src/client/vendor/tests/input/mouse-report.test.ts b/src/client/vendor/tests/input/mouse-report.test.ts new file mode 100644 index 0000000..1e2e248 --- /dev/null +++ b/src/client/vendor/tests/input/mouse-report.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { Mods } from '../../ghostty-web/input/key-types'; +import { + MOUSE_BUTTONS, + type MouseEventReport, + MouseReporter, + type MouseTrackingConfig, + motionButton, + mouseMods, +} from '../../ghostty-web/input/mouse-report'; + +// Mouse reports to the app (ADR 049, ADR 051): which presses, moves and releases reach +// Ghostty's encoder, with which button and modifiers. + +describe('motionButton', () => { + test('no button held is none, which Ghostty reports as hover (35)', () => { + expect(motionButton(0)).toBeNull(); + }); + + test('a drag reports the lowest button held', () => { + expect(motionButton(1)).toBe('left'); + expect(motionButton(2)).toBe('middle'); + expect(motionButton(4)).toBe('right'); + expect(motionButton(1 | 4)).toBe('left'); + expect(motionButton(2 | 4)).toBe('middle'); + expect(motionButton(8)).toBe('eight'); + expect(motionButton(16)).toBe('nine'); + // A bit past the buttons Ghostty knows + expect(motionButton(32)).toBeNull(); + }); + + test('back and forward are Ghostty buttons 8 and 9', () => { + expect(MOUSE_BUTTONS).toEqual(['left', 'middle', 'right', 'eight', 'nine']); + }); +}); + +describe('mouseMods', () => { + test('passes Shift, Alt, Ctrl and Meta as their own bits', () => { + const none = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; + expect(mouseMods(none)).toBe(Mods.NONE); + expect(mouseMods({ ...none, altKey: true })).toBe(Mods.ALT); + expect(mouseMods({ ...none, metaKey: true })).toBe(Mods.SUPER); + expect(mouseMods({ ...none, shiftKey: true, ctrlKey: true })).toBe(Mods.SHIFT | Mods.CTRL); + }); +}); + +describe('MouseReporter', () => { + let dom: DomWindow; + let container: HTMLElement; + let inner: HTMLElement; + let outside: HTMLElement; + let tracking: boolean; + let encoded: MouseEventReport[]; + let sent: [string, boolean][]; + + /** A reporter wired as the InputHandler wires it: presses on the container, the rest on the document. */ + function reporter(withConfig = true): MouseReporter { + const config = withConfig ? trackingConfig() : undefined; + const mouse = new MouseReporter(container, config, (data, report) => sent.push([data, report])); + container.addEventListener('mousedown', (e) => mouse.down(e)); + dom.document.addEventListener('mouseup', (e) => mouse.up(e)); + dom.document.addEventListener('mousemove', (e) => mouse.move(e)); + return mouse; + } + + function trackingConfig(): MouseTrackingConfig { + return { + hasMouseTracking: () => tracking, + encode: (event) => { + encoded.push(event); + return event.button === 'middle' && event.action === 'motion' + ? '' + : `<${event.action}:${event.button}>`; + }, + }; + } + + /** Dispatches a mouse event on `target` and returns whether it was prevented. */ + function fire(target: HTMLElement, type: string, init: MouseEventInit = {}): boolean { + const event = new dom.window.MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: 12, + clientY: 34, + ...init, + }); + target.dispatchEvent(event); + return event.defaultPrevented; + } + + const data = () => sent.map(([d]) => d); + + beforeEach(() => { + dom = createDomWindow(); + container = dom.document.createElement('div'); + inner = dom.document.createElement('canvas'); + outside = dom.document.createElement('p'); + container.append(inner); + dom.document.body.append(container, outside); + tracking = true; + encoded = []; + sent = []; + }); + + afterEach(() => dom.close()); + + test('reports a press, then a drag and its release past the terminal edge', () => { + reporter(); + fire(inner, 'mousedown', { button: 0, buttons: 1, altKey: true }); + fire(outside, 'mousemove', { buttons: 1, clientX: -40 }); + fire(outside, 'mouseup', { button: 0, clientX: -40 }); + expect(data()).toEqual(['<press:left>', '<motion:left>', '<release:left>']); + expect(encoded[0]).toEqual({ + action: 'press', + button: 'left', + mods: Mods.ALT, + clientX: 12, + clientY: 34, + anyButtonPressed: true, + }); + expect(encoded[1]).toMatchObject({ clientX: -40, anyButtonPressed: true }); + expect(encoded[2]).toMatchObject({ action: 'release', anyButtonPressed: false }); + }); + + test('every report is marked as a mouse report, so it leaves the selection alone', () => { + reporter(); + fire(container, 'mousemove'); + fire(container, 'mousedown', { button: 0, buttons: 1 }); + fire(container, 'mouseup', { button: 0 }); + expect(sent.map(([, report]) => report)).toEqual([true, true, true]); + }); + + test('hover is reported over the terminal only, with no button', () => { + reporter(); + fire(inner, 'mousemove'); + fire(outside, 'mousemove'); + // A release outside the terminal of a button not pressed in it + fire(outside, 'mouseup', { button: 2 }); + expect(data()).toEqual(['<motion:null>']); + // A release over the terminal is reported + fire(container, 'mouseup', { button: 2 }); + expect(data()).toEqual(['<motion:null>', '<release:right>']); + }); + + test('an empty report is not sent', () => { + reporter(); + fire(container, 'mousedown', { button: 1, buttons: 4 }); + fire(container, 'mousemove', { buttons: 4 }); + expect(data()).toEqual(['<press:middle>']); + expect(encoded.map((e) => e.action)).toEqual(['press', 'motion']); + }); + + // Shift is the bypass, as in Ghostty and xterm (ADR 051) + test('a Shift drag and its release send nothing, even if Shift is let go', () => { + reporter(); + fire(container, 'mousedown', { button: 0, buttons: 1, shiftKey: true }); + fire(container, 'mousemove', { buttons: 1, clientX: 30, shiftKey: true }); + fire(container, 'mousemove', { buttons: 1, clientX: 50 }); + fire(container, 'mouseup', { button: 0, clientX: 50 }); + expect(data()).toEqual([]); + // The next press without Shift is the app's again + fire(container, 'mousedown', { button: 0, buttons: 1 }); + fire(container, 'mouseup', { button: 0 }); + expect(data()).toEqual(['<press:left>', '<release:left>']); + }); + + test('a Shift right-click sends nothing; a right-click is the app’s', () => { + reporter(); + fire(container, 'mousedown', { button: 2, buttons: 2, shiftKey: true }); + fire(container, 'mouseup', { button: 2, shiftKey: true }); + expect(data()).toEqual([]); + fire(container, 'mousedown', { button: 2, buttons: 2 }); + fire(container, 'mouseup', { button: 2 }); + expect(data()).toEqual(['<press:right>', '<release:right>']); + }); + + test('a move with no button held forgets a release lost outside the window', () => { + reporter(); + fire(container, 'mousedown', { button: 0, buttons: 1 }); + // The release happened outside the document: the next move holds no button + fire(outside, 'mousemove', { buttons: 0 }); + fire(outside, 'mousemove', { buttons: 0 }); + expect(data()).toEqual(['<press:left>']); + fire(container, 'mousedown', { button: 0, buttons: 1, shiftKey: true }); + fire(container, 'mousemove', { buttons: 0 }); + expect(data()).toEqual(['<press:left>', '<motion:null>']); + }); + + test('back and forward are buttons 8 and 9, and do not navigate', () => { + reporter(); + expect(fire(container, 'mousedown', { button: 3, buttons: 8 })).toBe(true); + expect(fire(container, 'mouseup', { button: 4 })).toBe(true); + expect(data()).toEqual(['<press:eight>', '<release:nine>']); + // A left press is left to the selection + expect(fire(container, 'mousedown', { button: 0, buttons: 1 })).toBe(false); + }); + + test('a button Ghostty does not know is ignored', () => { + reporter(); + fire(container, 'mousedown', { button: 5, buttons: 32 }); + fire(container, 'mouseup', { button: 5 }); + expect(data()).toEqual([]); + }); + + test('nothing is reported while the app does not track the mouse', () => { + tracking = false; + reporter(); + fire(container, 'mousedown', { button: 0, buttons: 1 }); + fire(container, 'mousemove', { buttons: 1 }); + fire(container, 'mouseup', { button: 0 }); + expect(encoded).toEqual([]); + }); + + test('nothing is reported without a tracking config', () => { + reporter(false); + fire(container, 'mousedown', { button: 0, buttons: 1 }); + fire(container, 'mousemove', { buttons: 1 }); + fire(container, 'mouseup', { button: 0 }); + expect(sent).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/input/text-input.test.ts b/src/client/vendor/tests/input/text-input.test.ts new file mode 100644 index 0000000..af1737d --- /dev/null +++ b/src/client/vendor/tests/input/text-input.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { + beforeInputText, + defaultNow, + ECHO_WINDOW_MS, + EchoGuard, + removeCompositionText, +} from '../../ghostty-web/input/text-input'; + +// IME and mobile-keyboard text: the beforeinput text, the echo guard's 100 ms windows and +// the composition text-node cleanup (ADR 061). + +describe('beforeInputText', () => { + test.each([ + ['insertText', 'abc', 'abc'], + ['insertText', 'a\nb\n', 'a\rb\r'], + ['insertReplacementText', 'word', 'word'], + ['insertText', '', null], + ['insertReplacementText', '', null], + ['insertLineBreak', '', '\r'], + ['insertParagraph', 'ignored', '\r'], + ['deleteContentBackward', '', '\x7F'], + ['deleteContentForward', '', '\x1B[3~'], + // Paste is webtty's capture listener's (ADR 037) + ['insertFromPaste', 'a\nb', null], + ['insertCompositionText', 'あ', null], + ['deleteWordBackward', '', null], + ])('%s %p sends %p', (inputType, data, expected) => { + expect(beforeInputText(inputType, data)).toBe(expected); + }); +}); + +describe('EchoGuard', () => { + test('the window is 100 ms', () => { + expect(ECHO_WINDOW_MS).toBe(100); + }); + + test('a beforeinput within 100 ms of the same keydown text is an echo', () => { + const clock = new ManualClock(); + const guard = new EchoGuard(clock.now); + guard.recordKeyDown('a'); + clock.advance(99); + expect(guard.isKeyDownEcho('a')).toBe(true); + // The keydown is forgotten once checked + expect(guard.isKeyDownEcho('a')).toBe(false); + }); + + test('a keydown is forgotten after one check, even when the text differs', () => { + const guard = new EchoGuard(new ManualClock().now); + guard.recordKeyDown('a'); + expect(guard.isKeyDownEcho('b')).toBe(false); + expect(guard.isKeyDownEcho('a')).toBe(false); + }); + + test('at 100 ms the same text is new input', () => { + const clock = new ManualClock(); + const guard = new EchoGuard(clock.now); + guard.recordKeyDown('a'); + clock.advance(ECHO_WINDOW_MS); + expect(guard.isKeyDownEcho('a')).toBe(false); + }); + + test('a composition is remembered until a beforeinput repeats it', () => { + const clock = new ManualClock(); + const guard = new EchoGuard(clock.now); + expect(guard.isCompositionEcho('日本')).toBe(false); + guard.recordComposition('日本'); + expect(guard.isCompositionEcho('日')).toBe(false); + clock.advance(50); + expect(guard.isCompositionEcho('日本')).toBe(true); + expect(guard.isCompositionEcho('日本')).toBe(false); + }); + + test('a composition is not an echo source once 100 ms have passed', () => { + const clock = new ManualClock(); + const guard = new EchoGuard(clock.now); + guard.recordComposition('日本'); + clock.advance(100); + expect(guard.isCompositionEcho('日本')).toBe(false); + }); + + test('a beforeinput is remembered until a composition end repeats it', () => { + const clock = new ManualClock(); + const guard = new EchoGuard(clock.now); + expect(guard.isBeforeInputEcho('x')).toBe(false); + guard.recordBeforeInput('x'); + expect(guard.isBeforeInputEcho('y')).toBe(false); + clock.advance(99); + expect(guard.isBeforeInputEcho('x')).toBe(true); + expect(guard.isBeforeInputEcho('x')).toBe(false); + guard.recordBeforeInput('x'); + clock.advance(100); + expect(guard.isBeforeInputEcho('x')).toBe(false); + }); + + test('the three paths are kept apart', () => { + const guard = new EchoGuard(new ManualClock().now); + guard.recordKeyDown('a'); + expect(guard.isCompositionEcho('a')).toBe(false); + expect(guard.isBeforeInputEcho('a')).toBe(false); + guard.recordComposition('b'); + guard.recordBeforeInput('c'); + expect(guard.isKeyDownEcho('b')).toBe(false); + expect(guard.isCompositionEcho('b')).toBe(true); + expect(guard.isBeforeInputEcho('c')).toBe(true); + }); + + test('the default clock is performance.now, else Date.now', () => { + const before = performance.now(); + expect(defaultNow()).toBeGreaterThanOrEqual(before); + const saved = globalThis.performance; + try { + Object.defineProperty(globalThis, 'performance', { value: undefined, configurable: true }); + const wall = Date.now(); + expect(defaultNow()).toBeGreaterThanOrEqual(wall); + } finally { + Object.defineProperty(globalThis, 'performance', { value: saved, configurable: true }); + } + expect(new EchoGuard().isKeyDownEcho('a')).toBe(false); + }); +}); + +describe('removeCompositionText', () => { + let dom: DomWindow; + afterEach(() => dom.close()); + + test('removes the text nodes and keeps the elements', () => { + dom = createDomWindow(); + const container = dom.document.createElement('div'); + const canvas = dom.document.createElement('canvas'); + container.append('日', canvas, '本', '語'); + removeCompositionText(container); + expect([...container.childNodes]).toEqual([canvas]); + }); + + test('leaves an empty container alone', () => { + dom = createDomWindow(); + const container = dom.document.createElement('div'); + removeCompositionText(container); + expect(container.childNodes.length).toBe(0); + }); +}); From e693e81a7bd41053750b5a01f292d5c1de794ae4 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:09:27 -0400 Subject: [PATCH 245/295] refactor(client): InputHandler takes one options object InputHandler's seven positional arguments become InputHandlerOptions (ADR 061): ghostty (narrowed to createKeyEncoder), container, onData, getMode, onCopy, inputElement and mouseConfig, plus now, the echo guard's clock (performance.now() by default, as before). terminal.ts builds it with the same callbacks, so nothing it sends changes; the listener snapshot is unchanged. Tests: the facade tests and terminal.test.ts build the handler with the options object. New tests inject a key encoder through the factory and check the 100 ms echo window on a ManualClock through the facade. PROVENANCE.md records the change. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/input-handler.ts | 58 +++++++-------- src/client/vendor/ghostty-web/terminal.ts | 26 +++---- src/client/vendor/tests/input-handler.test.ts | 70 ++++++++++++++----- src/client/vendor/tests/terminal.test.ts | 13 ++-- 5 files changed, 98 insertions(+), 70 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index de1b749..7572649 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -50,6 +50,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `renderer.ts`: `CanvasRenderer` takes an optional third argument, `RendererDeps` (the `document` that creates its canvases, the `window` whose ratio it reads and watches, and the blink's interval `timers`), each defaulting to the browser's, so tests inject them instead of replacing globals (ADR 061). The painters take the context they draw on as an argument; the renderer no longer swaps its own context for the scratch canvas' during a partial repaint. - `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `Glide`, `AT_ONCE`, `FRAME_MS`, `linear`, `easeOutCubic`, `Pace` and `Ease` (`glide.ts`), `watchDevicePixelRatio` (`renderer.ts`) and `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`), which the app imported by deep path or did not have. - `input-handler.ts`: the listeners go into one `DisposableStore` (`listen`), so `dispose()` removes them all as before; the per-handler `isDisposed` checks, which no removed listener could reach, and the `keypressListener` field, which nothing ever set, are removed (ADR 061). The `EchoGuard` (`input/text-input.ts`) reads an injected clock, `performance.now()` by default as before. +- `input-handler.ts`: `InputHandler` takes one `InputHandlerOptions` object instead of seven positional arguments (`ghostty` narrowed to its `createKeyEncoder`, `container`, `onData`, `getMode`, `onCopy`, `inputElement`, `mouseConfig`), plus `now`, the echo guard's clock (ADR 061). ## webtty-original files diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 5b9ebae..3fdb6ba 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -27,6 +27,26 @@ export { plainKeySequence } from './input/key-encoding'; /** What the terminal sends input through; mouseReport is true for a mouse report (webtty, ADR 051). */ export type InputSink = (data: string, mouseReport?: boolean) => void; +/** What an InputHandler is built from. */ +export interface InputHandlerOptions { + /** Creates the key encoder (the Ghostty factory). */ + ghostty: Pick<Ghostty, 'createKeyEncoder'>; + /** The element the keyboard, composition and press listeners go on. */ + container: HTMLElement; + /** Receives terminal data (escape sequences to send to the PTY). */ + onData: InputSink; + /** Reads a terminal mode; mode 1 (DECCKM) picks the cursor key sequences. */ + getMode?: (mode: number) => boolean; + /** Copies the selection for Cmd+C; true when there was one. */ + onCopy?: () => boolean; + /** The element whose beforeinput events carry IME and mobile text (the textarea). */ + inputElement?: HTMLElement; + /** How mouse tracking is read and reports encoded; no reports without it. */ + mouseConfig?: MouseTrackingConfig; + /** The echo guard's clock in ms; `performance.now()` by default. */ + now?: () => number; +} + /** * Attaches keyboard, IME and mouse listeners to a container and converts their events * to terminal input data. @@ -38,37 +58,19 @@ export class InputHandler { private readonly getMode?: (mode: number) => boolean; private readonly onCopy?: () => boolean; private readonly mouse: MouseReporter; - private readonly echoes = new EchoGuard(); + private readonly echoes: EchoGuard; private readonly listeners = new DisposableStore(); private isComposing = false; - /** - * Create a new InputHandler - * @param ghostty - Ghostty instance (for creating KeyEncoder) - * @param container - DOM element to attach listeners to - * @param onData - Callback for terminal data (escape sequences to send to PTY); mouseReport - * is true for a mouse report (webtty, ADR 051) - * @param getMode - Optional callback to query terminal mode state (for application cursor mode) - * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection) - * @param inputElement - Optional input element for beforeinput events - * @param mouseConfig - Optional mouse tracking configuration - */ - constructor( - ghostty: Ghostty, - container: HTMLElement, - onData: InputSink, - getMode?: (mode: number) => boolean, - onCopy?: () => boolean, - inputElement?: HTMLElement, - mouseConfig?: MouseTrackingConfig - ) { - this.encoder = ghostty.createKeyEncoder(); - this.container = container; - this.onData = onData; - this.getMode = getMode; - this.onCopy = onCopy; - this.mouse = new MouseReporter(container, mouseConfig, onData); - this.attach(inputElement); + constructor(options: InputHandlerOptions) { + this.encoder = options.ghostty.createKeyEncoder(); + this.container = options.container; + this.onData = options.onData; + this.getMode = options.getMode; + this.onCopy = options.onCopy; + this.echoes = new EchoGuard(options.now); + this.mouse = new MouseReporter(options.container, options.mouseConfig, options.onData); + this.attach(options.inputElement); } /** Makes the container focusable and adds the listeners, in upstream's order. */ diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index a3218c3..6e6482e 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -630,21 +630,17 @@ export class Terminal implements ITerminalCore { }; // Create input handler - this.inputHandler = new InputHandler( - this.ghostty!, - parent, - this.handleInput, - (mode: number) => { - // Query terminal mode state (e.g., mode 1 for application cursor mode) - return this.wasmTerm?.getMode(mode, false) ?? false; - }, - () => { - // Handle Cmd+C copy - returns true if there was a selection to copy - return this.copySelection(); - }, - this.textarea, - mouseConfig - ); + this.inputHandler = new InputHandler({ + ghostty: this.ghostty!, + container: parent, + onData: this.handleInput, + // Query terminal mode state (e.g., mode 1 for application cursor mode) + getMode: (mode: number) => this.wasmTerm?.getMode(mode, false) ?? false, + // Handle Cmd+C copy - returns true if there was a selection to copy + onCopy: () => this.copySelection(), + inputElement: this.textarea, + mouseConfig, + }); // Create selection manager (pass textarea for context menu positioning) this.selectionManager = new SelectionManager( diff --git a/src/client/vendor/tests/input-handler.test.ts b/src/client/vendor/tests/input-handler.test.ts index 4ea32f0..8388d88 100644 --- a/src/client/vendor/tests/input-handler.test.ts +++ b/src/client/vendor/tests/input-handler.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { createDomWindow, type DomWindow } from '../../test-support/dom-window'; +import { ManualClock } from '../../test-support/manual-clock'; import type { Ghostty } from '../ghostty-web/ghostty'; +import { Key, KeyAction, type KeyEvent, Mods } from '../ghostty-web/input/key-types'; import { InputHandler, + type InputHandlerOptions, type MouseEventReport, type MouseTrackingConfig, } from '../ghostty-web/input-handler'; @@ -34,28 +37,22 @@ beforeEach(() => { afterEach(() => dom.close()); -interface HandlerOptions { - appCursor?: boolean; - getMode?: (mode: number) => boolean; - onCopy?: () => boolean; - mouse?: MouseTrackingConfig; -} - /** An InputHandler on the test's container and textarea, recording what it sends. */ -function handler(options: HandlerOptions = {}): InputHandler { - const getMode = options.getMode ?? ((mode: number) => mode === 1 && !!options.appCursor); - return new InputHandler( +function handler( + options: Partial<InputHandlerOptions> & { appCursor?: boolean } = {}, +): InputHandler { + const { appCursor, ...rest } = options; + return new InputHandler({ ghostty, container, - (data, mouseReport) => { + onData: (data, mouseReport) => { sent.push(data); reports.push(mouseReport ?? false); }, - getMode, - options.onCopy, - textarea, - options.mouse, - ); + getMode: (mode) => mode === 1 && !!appCursor, + inputElement: textarea, + ...rest, + }); } /** Fires a keydown on the container; returns whether it was prevented and whether it propagated. */ @@ -136,7 +133,7 @@ describe('InputHandler set-up and teardown', () => { test('without an input element there is no beforeinput listener', () => { const spy = spyOn(container, 'addEventListener'); - new InputHandler(ghostty, container, (data) => sent.push(data)).dispose(); + new InputHandler({ ghostty, container, onData: (data) => sent.push(data) }).dispose(); expect(spy.mock.calls.map(([type]) => type)).not.toContain('beforeinput'); }); }); @@ -221,6 +218,29 @@ describe('InputHandler keys', () => { }); }); +describe('InputHandler key encoder', () => { + test('the key encoder comes from the injected factory', () => { + const events: KeyEvent[] = []; + handler({ + ghostty: { + createKeyEncoder: () => ({ + setOption: () => {}, + encode: (event) => { + events.push(event); + return new TextEncoder().encode('<key>'); + }, + dispose: () => {}, + }), + }, + }); + keydown('F1', 'F1'); + expect(sent).toEqual(['<key>']); + expect(events).toEqual([ + { action: KeyAction.PRESS, key: Key.F1, mods: Mods.NONE, utf8: undefined }, + ]); + }); +}); + describe('InputHandler key bytes', () => { /** Sends a keydown through a handler and returns what it wrote, under DECCKM if asked. */ function keys(appCursor = false) { @@ -351,6 +371,18 @@ describe('InputHandler beforeinput', () => { expect(sent).toEqual(['a', 'a']); }); + test('the echo window is 100 ms on the injected clock', () => { + const clock = new ManualClock(); + handler({ now: clock.now }); + keydown('KeyA', 'a'); + clock.advance(99); + beforeinput('insertText', 'a'); + keydown('KeyA', 'a'); + clock.advance(100); + beforeinput('insertText', 'a'); + expect(sent).toEqual(['a', 'a', 'a']); + }); + test('a different beforeinput after a keydown is sent', () => { handler(); keydown('KeyA', 'a'); @@ -439,7 +471,7 @@ describe('InputHandler mouse', () => { test('presses on the container, moves and releases on the document, as mouse reports', () => { const config = tracking(); - handler({ mouse: config }); + handler({ mouseConfig: config }); mouse(container, 'mousedown', { button: 0, buttons: 1 }); mouse(dom.document.body, 'mousemove', { buttons: 1, clientX: -40 }); mouse(dom.document.body, 'mouseup', { button: 0 }); @@ -452,7 +484,7 @@ describe('InputHandler mouse', () => { // for a wheel over the padding, where the container is the target, and sent the tick // twice. test('sends nothing for a wheel event on the container, even with mouse tracking', () => { - handler({ mouse: tracking() }); + handler({ mouseConfig: tracking() }); container.dispatchEvent(new dom.window.WheelEvent('wheel', { deltaY: 40, bubbles: true })); expect(sent).toEqual([]); }); diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index 0310c12..ac0a526 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -901,15 +901,12 @@ describe('Terminal input and the selection', () => { const fire = (type: string, e: object) => { for (const l of listeners.get(type) ?? []) l(e); }; - const handler = new InputHandler( + const handler = new InputHandler({ ghostty, - container as unknown as HTMLElement, - (term as unknown as { handleInput(d: string, m?: boolean): void }).handleInput, - undefined, - undefined, - undefined, - { hasMouseTracking: () => true, encode: () => '\x1b[<35;4;4M' }, - ); + container: container as unknown as HTMLElement, + onData: (term as unknown as { handleInput(d: string, m?: boolean): void }).handleInput, + mouseConfig: { hasMouseTracking: () => true, encode: () => '\x1b[<35;4;4M' }, + }); const mods = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; fire('mousemove', { ...mods, target: container, buttons: 0, clientX: 1, clientY: 1 }); fire('mousedown', { From b7b47f296b3cc6e2b0d3eeea5b66eddab117b853 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:23:05 -0400 Subject: [PATCH 246/295] refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy SelectionManager becomes a facade (251 lines) over narrow interfaces in core/host-types.ts (SelectionHost, SelectionCanvasSource, SelectionEngineSource) instead of reaching into Terminal through `any`. Its parts move into selection/: - selection-coords.ts: buffer points, viewport/absolute rows, pixel to cell, the drag threshold and the canvas-edge maths (pure) - selection-gestures.ts: SelectionGestures, one method per listener, over a GestureHost; it owns the drag state and the auto-scroll - auto-scroll.ts: AutoScroll on injected timers, the 30 px / 3 lines / 50 ms constants, extendedEnd - context-menu-textarea.ts: the right-click textarea and its reset (10 ms) - clipboard-copy.ts: the ClipboardItem -> writeText -> execCommand strategy chain over an injected ClipboardEnv The listener order, targets and flags are unchanged and the listener snapshot passes as is: the document mousemove, mouseup and click listeners dispose() removed go into a DisposableStore; the canvas listeners and the document mousedown stay anonymous ("leaks until ADR 065"). The no-op requestRender and the unused rect of prepareContextMenu are dropped (ADR 061). Globals (document, timers, setTimeout, clipboard) come in through optional deps with production defaults. Tests move to vendor/tests/selection/ and run on an injected fixture (no stubbed document or navigator, a manual clock instead of a sleep); new tests cover every clipboard branch, the auto-scroll, the context-menu reset, drags outside the canvas, outside clicks and dispose. The ratchet entry for selection-manager.ts is removed. --- scripts/architecture/ratchet-allowlist.ts | 4 - src/client/vendor/ghostty-web/PROVENANCE.md | 6 + .../vendor/ghostty-web/core/host-types.ts | 44 + .../vendor/ghostty-web/selection-manager.ts | 749 +++--------------- .../ghostty-web/selection/auto-scroll.ts | 97 +++ .../ghostty-web/selection/clipboard-copy.ts | 100 +++ .../selection/context-menu-textarea.ts | 73 ++ .../ghostty-web/selection/selection-coords.ts | 125 +++ .../selection/selection-gestures.ts | 237 ++++++ .../vendor/tests/selection-manager.test.ts | 462 ----------- .../tests/selection/auto-scroll.test.ts | 106 +++ .../tests/selection/clipboard-copy.test.ts | 194 +++++ .../selection/context-menu-textarea.test.ts | 94 +++ .../tests/selection/selection-coords.test.ts | 112 +++ .../selection/selection-gestures.test.ts | 313 ++++++++ .../tests/selection/selection-manager.test.ts | 409 ++++++++++ .../vendor/tests/support/selection-fixture.ts | 133 ++++ 17 files changed, 2172 insertions(+), 1086 deletions(-) create mode 100644 src/client/vendor/ghostty-web/core/host-types.ts create mode 100644 src/client/vendor/ghostty-web/selection/auto-scroll.ts create mode 100644 src/client/vendor/ghostty-web/selection/clipboard-copy.ts create mode 100644 src/client/vendor/ghostty-web/selection/context-menu-textarea.ts create mode 100644 src/client/vendor/ghostty-web/selection/selection-coords.ts create mode 100644 src/client/vendor/ghostty-web/selection/selection-gestures.ts delete mode 100644 src/client/vendor/tests/selection-manager.test.ts create mode 100644 src/client/vendor/tests/selection/auto-scroll.test.ts create mode 100644 src/client/vendor/tests/selection/clipboard-copy.test.ts create mode 100644 src/client/vendor/tests/selection/context-menu-textarea.test.ts create mode 100644 src/client/vendor/tests/selection/selection-coords.test.ts create mode 100644 src/client/vendor/tests/selection/selection-gestures.test.ts create mode 100644 src/client/vendor/tests/selection/selection-manager.test.ts create mode 100644 src/client/vendor/tests/support/selection-fixture.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 4e9a2f8..54b444f 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -16,10 +16,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/vendor/ghostty-web/selection-manager.ts': { - lines: 736, - step: 'vendor selection split into selection/ (ADR 061)', - }, 'src/client/vendor/ghostty-web/terminal.ts': { lines: 2139, step: 'vendor terminal split into core/ (ADR 061)', diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 7572649..d6575d7 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -127,3 +127,9 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `input-handler.ts`: `mapKeyCode` (now `keyForCode`), `extractModifiers` (now `keyMods`), `isPrintableCharacter` (now `isPrintableKey`), `plainKeySequence` (webtty), and the encoder call of `handleKeyDown` (now `encoderText` and `encodeKeyPress`) | `input/key-encoding.ts` | refactor(client): input split: key table, key encoding, text input and mouse reports | | `input-handler.ts`: the input type switch of `handleBeforeInput` (now `beforeInputText`), the de-duplication fields and `recordKeyDownData`, `shouldIgnoreBeforeInput`, `shouldIgnoreBeforeInputFromComposition`, `shouldIgnoreCompositionEnd`, `recordBeforeInputData`, `recordCompositionData`, `getNow`, `BEFORE_INPUT_IGNORE_MS` (now `EchoGuard`, `ECHO_WINDOW_MS`, `defaultNow`), `cleanupCompositionTextNodes` (now `removeCompositionText`) | `input/text-input.ts` | refactor(client): input split: key table, key encoding, text input and mouse reports | | `input-handler.ts`: `MouseTrackingConfig`, `MouseEventReport`, `MOUSE_BUTTONS`, `motionButton`, `mouseMods` (webtty, ADR 049); `isOverTerminal`, `sendMouseEvent` (now `report`), `handleMouseDown`, `handleMouseUp`, `handleMouseMove` (now `down`, `up`, `move`) and the pressed and bypassed bitmasks (webtty, ADR 049, ADR 051); `input-handler.ts` re-exports the types and functions | `input/mouse-report.ts` (`MouseReporter`) | refactor(client): input split: key table, key encoding, text input and mouse reports | +| `selection-manager.ts`: the selection end shape (now `BufferPoint`), `getViewportY` (now `wholeViewportY`), `viewportRowToAbsolute`, `absoluteRowToViewport`, `pixelToCell` (now over a `CellGrid`), the swap of `getSelectionRange` (now `orderedViewportRange`), the drag threshold test (now `pastDragThreshold`), and the outside, clamp and above/below tests of the document `mousemove` and canvas `mouseleave` listeners (now `outsideRect`, `clampedOffset`, `verticalOverflow`) | `selection/selection-coords.ts` | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | +| `selection-manager.ts`: `AUTO_SCROLL_EDGE_SIZE`, `AUTO_SCROLL_SPEED`, `AUTO_SCROLL_INTERVAL` (now `AUTO_SCROLL_EDGE_PX`, `AUTO_SCROLL_LINES`, `AUTO_SCROLL_INTERVAL_MS`), `updateAutoScroll` (now `edgeDirection` and `followEdge`), `startAutoScroll`, `stopAutoScroll` and the interval fields (now `AutoScroll` on injected timers), and the selection extension of the scroll step (now `extendedEnd`) | `selection/auto-scroll.ts` (`AutoScroll`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | +| `selection-manager.ts`: `copyToClipboard` (now `copyText`), `copyWithWriteText`, `copyWithExecCommand`, over an injected `ClipboardEnv` (`browserClipboardEnv`) | `selection/clipboard-copy.ts` | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | +| `selection-manager.ts`: the body of `prepareContextMenu` (now `show`) and its reset closure (now `hideOnMenuClose`), the 10 ms delay (now `CONTEXT_MENU_RESET_DELAY_MS`); the unused `rect` is dropped | `selection/context-menu-textarea.ts` (`ContextMenuTextarea`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | +| `selection-manager.ts`: `attachEventListeners` (one method per listener: `canvasDown`, `canvasMove`, `documentMove`, `release`, `multiClick`, `documentClick`), `pressIsApps` (now the host's `appTakesPress`), the drag fields (`isSelecting`, `mouseDownX`/`Y`, `dragThresholdMet`, `mouseDownTarget`) and the listener removal of `dispose`; the no-op `requestRender` is dropped | `selection/selection-gestures.ts` (`SelectionGestures`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | +| `selection-manager.ts`: what it reads of `Terminal`, `CanvasRenderer` and the engine through `any` (webtty) | `core/host-types.ts` (`SelectionHost`, `SelectionCanvasSource`, `SelectionEngineSource`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts new file mode 100644 index 0000000..ceab536 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -0,0 +1,44 @@ +// webtty (ADR 061), not from upstream. +// What Terminal's collaborators see of it (types): narrow interfaces instead of the +// Terminal, CanvasRenderer and engine classes, so a split part never imports a facade and +// no import cycle forms (ADR 061). + +import type { GhosttyTerminal } from '../engine/engine-types'; + +/** + * What SelectionManager needs from the terminal: its size, the copyOnSelect option, the + * viewport position, and scrolling for the drag auto-scroll. + */ +export interface SelectionHost { + readonly cols: number; + readonly rows: number; + readonly options: { readonly copyOnSelect?: boolean }; + /** Lines scrolled into history; fractional while smooth scrolling. */ + getViewportY(): number; + scrollLines(amount: number): void; +} + +/** + * What SelectionManager reads from the renderer: the canvas it listens on, the cell size, + * and how far row 0 sits below the canvas top while smooth scrolling (ADR 043). + */ +export interface SelectionCanvasSource { + getCanvas(): HTMLCanvasElement; + getMetrics(): { width: number; height: number }; + readonly scrollOffset: number; +} + +/** + * The engine calls a selection makes: Ghostty's word, line and output ranges and the + * selected text (ADR 050, ADR 057), mouse tracking (ADR 051), and the grid's size. + */ +export type SelectionEngineSource = Pick< + GhosttyTerminal, + | 'getScrollbackLength' + | 'getDimensions' + | 'hasMouseTracking' + | 'selectWord' + | 'selectLine' + | 'selectOutput' + | 'selectionText' +>; diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 89347fa..4445600 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -1,109 +1,72 @@ -/** - * Selection Manager - Handles text selection in the terminal - * - * Features: - * - Mouse drag selection - * - Double-click word and triple-click line selection (Ghostty's, ADR 050), and - * Ctrl/Cmd+triple-click command output (Ghostty's, webtty ADR 057) - * - Text extraction (Ghostty's, ADR 050) - * - Clipboard copy on select (the terminal's copyOnSelect option) - * - Visual selection highlighting (integrated into CanvasRenderer cell rendering) - * - Auto-scroll during drag selection - */ - +// Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Text selection (facade): `SelectionManager` holds the selection's two ends in absolute +// rows, its text (Ghostty's, ADR 050), copy on select and the change event, and hands the +// mouse gestures, the coordinates, the context-menu textarea and the copy chain to +// selection/ (ADR 043, ADR 051, ADR 057, ADR 061). + +import type { + SelectionCanvasSource, + SelectionEngineSource, + SelectionHost, +} from './core/host-types'; +import type { ScreenRange } from './engine/engine-types'; import { EventEmitter } from './event-emitter'; -import type { GhosttyTerminal } from './ghostty'; import type { IEvent } from './interfaces'; import { pressToApp } from './mouse-route'; import type { CellRange } from './render/cell-range'; -import type { CanvasRenderer } from './renderer'; -import type { Terminal } from './terminal'; - -// ============================================================================ -// Type Definitions -// ============================================================================ +import type { AutoScrollTimers } from './selection/auto-scroll'; +import { browserClipboardEnv, type ClipboardEnv, copyText } from './selection/clipboard-copy'; +import { ContextMenuTextarea } from './selection/context-menu-textarea'; +import { + absoluteRowToViewport, + type BufferPoint, + orderedViewportRange, + pixelToCell, + viewportRowToAbsolute, + wholeViewportY, +} from './selection/selection-coords'; +import { type GestureDocument, type GestureHost, SelectionGestures } from './selection/selection-gestures'; /** A selection's start and end cell, rows inclusive: the one cell range shape (ADR 061). */ export type SelectionCoordinates = CellRange; -// ============================================================================ -// SelectionManager Class -// ============================================================================ +/** The browser globals the manager uses, injected by tests; production reads the page's. */ +export interface SelectionDeps { + document?: GestureDocument & ClipboardEnv['document']; + timers?: AutoScrollTimers; + setTimeout?: (run: () => void, ms: number) => unknown; + /** The clipboard a copy goes to; by default the page's, read at each copy. */ + clipboard?: () => ClipboardEnv; +} +/** Owns the selection: its ends in absolute rows, its text, copy and the change event. */ export class SelectionManager { - private terminal: Terminal; - private renderer: CanvasRenderer; - private wasmTerm: GhosttyTerminal; - private textarea: HTMLTextAreaElement; - - // Selection state - coordinates are in ABSOLUTE buffer space (viewportY + viewportRow) - // This ensures selection persists correctly when scrolling - private selectionStart: { col: number; absoluteRow: number } | null = null; - private selectionEnd: { col: number; absoluteRow: number } | null = null; - private isSelecting: boolean = false; - private mouseDownX: number = 0; - private mouseDownY: number = 0; - private dragThresholdMet: boolean = false; - private mouseDownTarget: EventTarget | null = null; // Track where mousedown occurred - - // Event emitter - private selectionChangedEmitter = new EventEmitter<void>(); - - // Store bound event handlers for cleanup - private boundMouseUpHandler: ((e: MouseEvent) => void) | null = null; - private boundClickHandler: ((e: MouseEvent) => void) | null = null; - private boundDocumentMouseMoveHandler: ((e: MouseEvent) => void) | null = null; - - // Auto-scroll state for drag selection - private autoScrollInterval: ReturnType<typeof setInterval> | null = null; - private autoScrollDirection: number = 0; // -1 = up, 0 = none, 1 = down - private static readonly AUTO_SCROLL_EDGE_SIZE = 30; // pixels from edge to trigger scroll - - /** - * Get current viewport Y position (how many lines scrolled into history) - */ - private getViewportY(): number { - const rawViewportY = - typeof (this.terminal as any).getViewportY === 'function' - ? (this.terminal as any).getViewportY() - : (this.terminal as any).viewportY || 0; - return Math.max(0, Math.floor(rawViewportY)); - } + // The ends in ABSOLUTE buffer rows (viewportY + viewportRow), so the selection + // persists correctly when scrolling + private selectionStart: BufferPoint | null = null; + private selectionEnd: BufferPoint | null = null; - /** - * Convert viewport row to absolute buffer row - * Absolute row is an index into combined buffer: scrollback (0 to len-1) + screen (len to len+rows-1) - */ - private viewportRowToAbsolute(viewportRow: number): number { - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - const viewportY = this.getViewportY(); - return scrollbackLength + viewportRow - viewportY; - } - - /** - * Convert absolute buffer row to viewport row (may be outside visible range) - */ - private absoluteRowToViewport(absoluteRow: number): number { - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - const viewportY = this.getViewportY(); - return absoluteRow - scrollbackLength + viewportY; - } - private static readonly AUTO_SCROLL_SPEED = 3; // lines per interval - private static readonly AUTO_SCROLL_INTERVAL = 50; // ms between scroll steps + private readonly selectionChangedEmitter = new EventEmitter<void>(); + private readonly gestures: SelectionGestures; + private readonly contextMenu: ContextMenuTextarea; + private readonly clipboard: () => ClipboardEnv; constructor( - terminal: Terminal, - renderer: CanvasRenderer, - wasmTerm: GhosttyTerminal, - textarea: HTMLTextAreaElement + private readonly terminal: SelectionHost, + private readonly renderer: SelectionCanvasSource, + private readonly wasmTerm: SelectionEngineSource, + textarea: HTMLTextAreaElement, + deps: SelectionDeps = {}, ) { - this.terminal = terminal; - this.renderer = renderer; - this.wasmTerm = wasmTerm; - this.textarea = textarea; - - // Attach mouse event listeners - this.attachEventListeners(); + const doc = deps.document ?? document; + this.clipboard = deps.clipboard ?? (() => browserClipboardEnv(textarea, doc)); + this.contextMenu = new ContextMenuTextarea(textarea, doc, deps.setTimeout); + this.gestures = new SelectionGestures( + renderer.getCanvas(), + doc, + this.gestureHost(), + deps.timers, + ); } // ========================================================================== @@ -121,7 +84,7 @@ export class SelectionManager { const { col: endCol, absoluteRow: endRow } = this.selectionEnd; return this.wasmTerm.selectionText( { col: startCol, row: startRow }, - { col: endCol, row: endRow } + { col: endCol, row: endRow }, ); } @@ -130,11 +93,8 @@ export class SelectionManager { */ hasSelection(): boolean { if (!this.selectionStart || !this.selectionEnd) return false; - // Don't report selection until drag threshold is met (prevents flash on click) - if (this.isSelecting && !this.dragThresholdMet) return false; - - return true; + return !this.gestures.pressPending; } /** @@ -143,27 +103,21 @@ export class SelectionManager { */ copySelection(): boolean { if (!this.hasSelection()) return false; - const text = this.getSelection(); - if (text) { - this.copyToClipboard(text); - return true; - } - return false; + if (!text) return false; + copyText(text, this.clipboard()); + return true; } /** - * Clear the selection + * Clear the selection. The render loop picks up the change and redraws the rows that + * were selected. */ clearSelection(): void { if (!this.hasSelection()) return; - this.selectionStart = null; this.selectionEnd = null; - this.isSelecting = false; - - // Force redraw of previously selected lines to clear the overlay - this.requestRender(); + this.gestures.endDrag(); } /** @@ -172,22 +126,9 @@ export class SelectionManager { */ getSelectionRange(): SelectionCoordinates | null { if (!this.selectionStart || !this.selectionEnd) return null; - - let { col: startCol, absoluteRow: startAbsRow } = this.selectionStart; - let { col: endCol, absoluteRow: endAbsRow } = this.selectionEnd; - - // Swap if selection goes backwards - if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { - [startCol, endCol] = [endCol, startCol]; - [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; - } - - return { - startCol, - startRow: this.absoluteRowToViewport(startAbsRow), - endCol, - endRow: this.absoluteRowToViewport(endAbsRow), - }; + return orderedViewportRange(this.selectionStart, this.selectionEnd, (row) => + absoluteRowToViewport(row, this.wasmTerm.getScrollbackLength(), this.viewportY()), + ); } /** @@ -198,33 +139,13 @@ export class SelectionManager { } /** - * Cleanup resources + * Cleanup resources: the emitter, the auto-scroll, and the document listeners for the + * drag and outside clicks. The canvas listeners and the document mousedown stay until + * ADR 065. */ dispose(): void { this.selectionChangedEmitter.dispose(); - - // Stop auto-scroll if active - this.stopAutoScroll(); - - // Clean up document event listener - if (this.boundMouseUpHandler) { - document.removeEventListener('mouseup', this.boundMouseUpHandler); - this.boundMouseUpHandler = null; - } - - // Clean up document mousemove listener - if (this.boundDocumentMouseMoveHandler) { - document.removeEventListener('mousemove', this.boundDocumentMouseMoveHandler); - this.boundDocumentMouseMoveHandler = null; - } - - // Clean up document click listener - if (this.boundClickHandler) { - document.removeEventListener('click', this.boundClickHandler); - this.boundClickHandler = null; - } - - // Canvas event listeners will be cleaned up when canvas is removed from DOM + this.gestures.dispose(); } /** @@ -233,384 +154,78 @@ export class SelectionManager { * menu (webtty: upstream listened to every contextmenu itself, ADR 051). */ prepareContextMenu(e: MouseEvent): void { - // Position textarea at mouse cursor - const canvas = this.renderer.getCanvas(); - const rect = canvas.getBoundingClientRect(); - - this.textarea.style.position = 'fixed'; - this.textarea.style.left = `${e.clientX}px`; - this.textarea.style.top = `${e.clientY}px`; - this.textarea.style.width = '1px'; - this.textarea.style.height = '1px'; - this.textarea.style.zIndex = '1000'; - this.textarea.style.opacity = '0'; - - // Enable pointer events temporarily so context menu targets the textarea - this.textarea.style.pointerEvents = 'auto'; - - // If there's a selection, populate textarea with it and select the text - if (this.hasSelection()) { - const text = this.getSelection(); - this.textarea.value = text; - this.textarea.select(); - this.textarea.setSelectionRange(0, text.length); - } else { - // No selection - clear textarea but still show menu (for paste) - this.textarea.value = ''; - } - - // Focus the textarea so the context menu appears on it - this.textarea.focus(); - - // After a short delay, restore the textarea to its hidden state - // This allows the context menu to appear first - setTimeout(() => { - // Listen for when the context menu closes (user clicks away or selects an option) - const resetTextarea = () => { - this.textarea.style.pointerEvents = 'none'; - this.textarea.style.zIndex = '-10'; - this.textarea.style.width = '0'; - this.textarea.style.height = '0'; - this.textarea.style.left = '0'; - this.textarea.style.top = '0'; - this.textarea.value = ''; - - // Remove the one-time listeners - document.removeEventListener('click', resetTextarea); - document.removeEventListener('contextmenu', resetTextarea); - this.textarea.removeEventListener('blur', resetTextarea); - }; - - // Reset on any of these events (menu closed) - document.addEventListener('click', resetTextarea, { once: true }); - document.addEventListener('contextmenu', resetTextarea, { once: true }); - this.textarea.addEventListener('blur', resetTextarea, { once: true }); - }, 10); - - // Don't prevent default - let browser show the context menu on the textarea + this.contextMenu.show(e, this.hasSelection() ? this.getSelection() : null); } // ========================================================================== // Private Methods // ========================================================================== - /** - * Attach mouse event listeners to canvas - */ - private attachEventListeners(): void { - const canvas = this.renderer.getCanvas(); - - // Mouse down - start selection or clear existing - canvas.addEventListener('mousedown', (e: MouseEvent) => { - if (e.button === 0) { - // Left click only - - // CRITICAL: Focus the terminal so it can receive keyboard input - // The canvas doesn't have tabindex, but the parent container does - if (canvas.parentElement) { - canvas.parentElement.focus(); - } - - // A press the app takes (it tracks the mouse, Shift is up) starts no selection - // and leaves the current one; Shift selects here instead. (webtty, ADR 051) - if (this.pressIsApps(e)) return; - - const cell = this.pixelToCell(e.offsetX, e.offsetY); - - // Always clear previous selection on new click - const hadSelection = this.hasSelection(); - if (hadSelection) { - this.clearSelection(); - } - - // Start new selection (convert to absolute coordinates) - const absoluteRow = this.viewportRowToAbsolute(cell.row); - this.selectionStart = { col: cell.col, absoluteRow }; - this.selectionEnd = { col: cell.col, absoluteRow }; - this.isSelecting = true; - this.mouseDownX = e.offsetX; - this.mouseDownY = e.offsetY; - this.dragThresholdMet = false; - } - }); - - // Mouse move on canvas - update selection - canvas.addEventListener('mousemove', (e: MouseEvent) => { - if (this.isSelecting) { - // Check if drag threshold has been met - if (!this.dragThresholdMet) { - const dx = e.offsetX - this.mouseDownX; - const dy = e.offsetY - this.mouseDownY; - // Use 50% of cell width as threshold to scale with font size - const threshold = this.renderer.getMetrics().width * 0.5; - if (dx * dx + dy * dy < threshold * threshold) { - return; // Below threshold, ignore - } - this.dragThresholdMet = true; - } - - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const absoluteRow = this.viewportRowToAbsolute(cell.row); - this.selectionEnd = { col: cell.col, absoluteRow }; - this.requestRender(); - - // Check if near edges for auto-scroll - this.updateAutoScroll(e.offsetY, canvas.clientHeight); - } - }); - - // Mouse leave - check for auto-scroll when leaving canvas during drag - canvas.addEventListener('mouseleave', (e: MouseEvent) => { - if (this.isSelecting) { - // Determine scroll direction based on where mouse left - const rect = canvas.getBoundingClientRect(); - if (e.clientY < rect.top) { - this.startAutoScroll(-1); // Scroll up - } else if (e.clientY > rect.bottom) { - this.startAutoScroll(1); // Scroll down - } - } - }); - - // Mouse enter - stop auto-scroll when mouse returns to canvas - canvas.addEventListener('mouseenter', () => { - if (this.isSelecting) { - this.stopAutoScroll(); - } - }); - - // Document-level mousemove for tracking mouse position during drag outside canvas - this.boundDocumentMouseMoveHandler = (e: MouseEvent) => { - if (this.isSelecting) { - // Check drag threshold (same as canvas mousemove) - if (!this.dragThresholdMet) { - const dx = e.clientX - (canvas.getBoundingClientRect().left + this.mouseDownX); - const dy = e.clientY - (canvas.getBoundingClientRect().top + this.mouseDownY); - const threshold = this.renderer.getMetrics().width * 0.5; - if (dx * dx + dy * dy < threshold * threshold) { - return; - } - this.dragThresholdMet = true; - } - - const rect = canvas.getBoundingClientRect(); - - // Update selection based on clamped position - const clampedX = Math.max(rect.left, Math.min(e.clientX, rect.right)); - const clampedY = Math.max(rect.top, Math.min(e.clientY, rect.bottom)); - - // Convert to canvas-relative coordinates - const offsetX = clampedX - rect.left; - const offsetY = clampedY - rect.top; - - // Only update if mouse is outside the canvas - if ( - e.clientX < rect.left || - e.clientX > rect.right || - e.clientY < rect.top || - e.clientY > rect.bottom - ) { - // Update auto-scroll direction based on mouse position - if (e.clientY < rect.top) { - this.startAutoScroll(-1); - } else if (e.clientY > rect.bottom) { - this.startAutoScroll(1); - } else { - this.stopAutoScroll(); - } - - // Only update selection position if NOT auto-scrolling - // During auto-scroll, the scroll handler extends the selection - if (this.autoScrollDirection === 0) { - const cell = this.pixelToCell(offsetX, offsetY); - const absoluteRow = this.viewportRowToAbsolute(cell.row); - this.selectionEnd = { col: cell.col, absoluteRow }; - this.requestRender(); - } - } - } - }; - document.addEventListener('mousemove', this.boundDocumentMouseMoveHandler); - - // Track mousedown on document to know if a click started inside the canvas - document.addEventListener('mousedown', (e: MouseEvent) => { - this.mouseDownTarget = e.target; - }); - - // CRITICAL FIX: Listen for mouseup on DOCUMENT, not just canvas - // This catches mouseup events that happen outside the canvas (common during drag) - this.boundMouseUpHandler = (e: MouseEvent) => { - if (this.isSelecting) { - this.isSelecting = false; - this.stopAutoScroll(); - - // Check if this was a click without drag (threshold never met). - if (!this.dragThresholdMet) { - this.clearSelection(); - return; - } - - if (this.hasSelection()) this.selectionMade(); - } - }; - document.addEventListener('mouseup', this.boundMouseUpHandler); - - // Double click selects a word and triple click a line, by Ghostty's rules (ADR 050); - // with Ctrl or Cmd, triple click selects the command's output (webtty, ADR 057). - // event.detail is the browser's click count. - // Not while the app takes the press (webtty, ADR 051). - canvas.addEventListener('click', (e: MouseEvent) => { - if (e.detail < 2) return; - if (this.pressIsApps(e)) return; - const cell = this.pixelToCell(e.offsetX, e.offsetY); - const at = { col: cell.col, row: this.viewportRowToAbsolute(cell.row) }; - const range = - e.detail === 2 - ? this.wasmTerm.selectWord(at) - : e.ctrlKey || e.metaKey - ? this.wasmTerm.selectOutput(at) - : this.wasmTerm.selectLine(at); - if (!range) return; - this.selectionStart = { col: range.start.col, absoluteRow: range.start.row }; - this.selectionEnd = { col: range.end.col, absoluteRow: range.end.row }; - this.requestRender(); - this.selectionMade(); - }); - - // Click outside canvas - clear selection - // This allows users to deselect by clicking anywhere outside the terminal - this.boundClickHandler = (e: MouseEvent) => { - // Don't clear selection if we're actively selecting - if (this.isSelecting) { - return; - } - - // A click is only valid for clearing selection if BOTH mousedown and mouseup - // happened outside the canvas. If mousedown was inside (drag selection), - // don't clear even if mouseup/click is outside. - const mouseDownWasInCanvas = - this.mouseDownTarget && canvas.contains(this.mouseDownTarget as Node); - if (mouseDownWasInCanvas) { - return; - } - - // Check if the click is outside the canvas - const target = e.target as Node; - if (!canvas.contains(target)) { - // Clicked outside the canvas - clear selection - if (this.hasSelection()) { - this.clearSelection(); - } - } - }; - - document.addEventListener('click', this.boundClickHandler); - } - - /** Whether the app takes this press: it tracks the mouse and Shift is up. (webtty, ADR 051) */ - private pressIsApps(e: MouseEvent): boolean { - return pressToApp(this.wasmTerm.hasMouseTracking(), e.shiftKey); - } - - /** - * Update auto-scroll based on mouse Y position within canvas - */ - private updateAutoScroll(offsetY: number, canvasHeight: number): void { - const edgeSize = SelectionManager.AUTO_SCROLL_EDGE_SIZE; - - if (offsetY < edgeSize) { - // Near top edge - scroll up - this.startAutoScroll(-1); - } else if (offsetY > canvasHeight - edgeSize) { - // Near bottom edge - scroll down - this.startAutoScroll(1); - } else { - // In middle - stop scrolling - this.stopAutoScroll(); - } - } - - /** - * Start auto-scrolling in the given direction - */ - private startAutoScroll(direction: number): void { - // Don't restart if already scrolling in same direction - if (this.autoScrollInterval !== null && this.autoScrollDirection === direction) { - return; - } - - // Stop any existing scroll - this.stopAutoScroll(); - - this.autoScrollDirection = direction; - - // Start scrolling interval - this.autoScrollInterval = setInterval(() => { - if (!this.isSelecting) { - this.stopAutoScroll(); - return; - } - - // Scroll the terminal to reveal more content in the direction user is dragging - // autoScrollDirection: -1 = dragging up (wants to see history), 1 = dragging down (wants to see newer) - // scrollLines convention: negative = scroll up into history, positive = scroll down to newer - // So direction maps directly to scrollLines sign - const scrollAmount = SelectionManager.AUTO_SCROLL_SPEED * this.autoScrollDirection; - this.terminal.scrollLines(scrollAmount); - - // Extend selection in the scroll direction - // Key insight: we need to EXTEND the selection, not reset it to viewport edge - if (this.selectionEnd) { - const dims = this.wasmTerm.getDimensions(); - if (this.autoScrollDirection < 0) { - // Scrolling up - extend selection upward (decrease absoluteRow) - // Set to top of viewport, but only if it extends the selection - const topAbsoluteRow = this.viewportRowToAbsolute(0); - if (topAbsoluteRow < this.selectionEnd.absoluteRow) { - this.selectionEnd = { col: 0, absoluteRow: topAbsoluteRow }; - } - } else { - // Scrolling down - extend selection downward (increase absoluteRow) - // Set to bottom of viewport, but only if it extends the selection - const bottomAbsoluteRow = this.viewportRowToAbsolute(dims.rows - 1); - if (bottomAbsoluteRow > this.selectionEnd.absoluteRow) { - this.selectionEnd = { col: dims.cols - 1, absoluteRow: bottomAbsoluteRow }; - } - } - } - - this.requestRender(); - }, SelectionManager.AUTO_SCROLL_INTERVAL); + /** Lines scrolled into history, as a whole number. */ + private viewportY(): number { + return wholeViewportY(this.terminal.getViewportY()); } - /** - * Stop auto-scrolling - */ - private stopAutoScroll(): void { - if (this.autoScrollInterval !== null) { - clearInterval(this.autoScrollInterval); - this.autoScrollInterval = null; - } - this.autoScrollDirection = 0; + /** A viewport row as an absolute buffer row. */ + private toAbsolute(viewportRow: number): number { + return viewportRowToAbsolute( + viewportRow, + this.wasmTerm.getScrollbackLength(), + this.viewportY(), + ); } - /** - * Convert pixel coordinates to terminal cell coordinates - */ - private pixelToCell(x: number, y: number): { col: number; row: number } { - const metrics = this.renderer.getMetrics(); - - const col = Math.floor(x / metrics.width); - // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) - const row = Math.floor((y - this.renderer.scrollOffset) / metrics.height); - - // Clamp to terminal bounds + /** The gestures' view of the grid and the selection. */ + private gestureHost(): GestureHost { + const { terminal, renderer, wasmTerm } = this; return { - col: Math.max(0, Math.min(col, this.terminal.cols - 1)), - row: Math.max(0, Math.min(row, this.terminal.rows - 1)), + pointAt: (x, y) => { + const metrics = renderer.getMetrics(); + const cell = pixelToCell(x, y, { + cellWidth: metrics.width, + cellHeight: metrics.height, + scrollOffset: renderer.scrollOffset, + cols: terminal.cols, + rows: terminal.rows, + }); + return { col: cell.col, absoluteRow: this.toAbsolute(cell.row) }; + }, + cellWidth: () => renderer.getMetrics().width, + // The app takes a press while it tracks the mouse and Shift is up (webtty, ADR 051) + appTakesPress: (e) => pressToApp(wasmTerm.hasMouseTracking(), e.shiftKey), + clickRange: (clicks, at, e) => this.clickRange(clicks, at, e), + scrollLines: (amount) => terminal.scrollLines(amount), + visibleRows: () => { + const dims = wasmTerm.getDimensions(); + return { + top: this.toAbsolute(0), + bottom: this.toAbsolute(dims.rows - 1), + lastCol: dims.cols - 1, + }; + }, + hasSelection: () => this.hasSelection(), + clearSelection: () => this.clearSelection(), + select: (start, end) => { + this.selectionStart = start; + this.selectionEnd = end; + }, + selectionEnd: () => this.selectionEnd, + extendTo: (end) => { + this.selectionEnd = end; + }, + selectionMade: () => this.selectionMade(), }; } + /** Ghostty's word (2 clicks), line (3), or with Ctrl or Cmd command output, at a point. */ + private clickRange(clicks: number, at: BufferPoint, e: MouseEvent): ScreenRange | null { + const cell = { col: at.col, row: at.absoluteRow }; + if (clicks === 2) return this.wasmTerm.selectWord(cell); + return e.ctrlKey || e.metaKey + ? this.wasmTerm.selectOutput(cell) + : this.wasmTerm.selectLine(cell); + } + /** * A mouse selection is made: with the terminal's copyOnSelect on, copy it, building * its text once, and clear it, as Claude Code and pi do with their own selections; @@ -619,115 +234,9 @@ export class SelectionManager { private selectionMade(): void { if (this.terminal.options.copyOnSelect) { const text = this.getSelection(); - if (text) this.copyToClipboard(text); + if (text) copyText(text, this.clipboard()); this.clearSelection(); } this.selectionChangedEmitter.fire(); } - - /** - * Copy text to clipboard - * - * Strategy (modern APIs first): - * 1. Try ClipboardItem API (works in Safari and modern browsers) - * - Safari requires the ClipboardItem to be created synchronously within user gesture - * 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari) - * 3. Fall back to execCommand (legacy, for older browsers) - */ - private copyToClipboard(text: string): void { - // First try: ClipboardItem API (modern, Safari-compatible) - // Safari allows this because we create the ClipboardItem synchronously - // within the user gesture, even though the write is async - if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { - try { - const blob = new Blob([text], { type: 'text/plain' }); - const clipboardItem = new ClipboardItem({ - 'text/plain': blob, - }); - navigator.clipboard.write([clipboardItem]).catch((err) => { - console.warn('ClipboardItem write failed, trying writeText:', err); - // Try writeText as fallback - this.copyWithWriteText(text); - }); - return; - } catch (err) { - // ClipboardItem not supported or failed, fall through - } - } - - // Second try: basic async writeText (works in Chrome, may fail in Safari) - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(text).catch((err) => { - console.warn('Clipboard writeText failed, trying execCommand:', err); - // Fall back to execCommand - this.copyWithExecCommand(text); - }); - return; - } - - // Third try: legacy execCommand fallback - this.copyWithExecCommand(text); - } - - /** - * Copy using navigator.clipboard.writeText - */ - private copyWithWriteText(text: string): void { - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(text).catch((err) => { - console.warn('Clipboard writeText failed, trying execCommand:', err); - this.copyWithExecCommand(text); - }); - } else { - this.copyWithExecCommand(text); - } - } - - /** - * Copy using legacy execCommand (fallback for older browsers) - */ - private copyWithExecCommand(text: string): void { - const previouslyFocused = document.activeElement as HTMLElement; - try { - // Position textarea offscreen but in a way that allows selection - const textarea = this.textarea; - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - textarea.style.top = '0'; - textarea.style.width = '1px'; - textarea.style.height = '1px'; - textarea.style.opacity = '0'; - - // Select all text and copy - textarea.focus(); - textarea.select(); - textarea.setSelectionRange(0, text.length); - - const success = document.execCommand('copy'); - - // Restore focus - if (previouslyFocused) { - previouslyFocused.focus(); - } - - if (!success) { - console.warn('execCommand copy failed'); - } - } catch (err) { - console.warn('execCommand copy threw:', err); - // Restore focus on error - if (previouslyFocused) { - previouslyFocused.focus(); - } - } - } - - /** - * Request a render update (triggers selection overlay redraw) - */ - private requestRender(): void { - // The render loop will automatically pick up the new selection state - // and redraw the affected lines. This happens at 60fps. - } } diff --git a/src/client/vendor/ghostty-web/selection/auto-scroll.ts b/src/client/vendor/ghostty-web/selection/auto-scroll.ts new file mode 100644 index 0000000..3d59020 --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/auto-scroll.ts @@ -0,0 +1,97 @@ +// Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Drag auto-scroll (object): while a selection drag is near or past the canvas's top or +// bottom edge, an interval scrolls the terminal and lets the drag extend the selection +// (ADR 061). + +import type { Disposable } from '../lifecycle/disposable-store'; +import type { BufferPoint } from './selection-coords'; + +/** How close to the canvas's top or bottom edge, in CSS pixels, a drag starts scrolling. */ +export const AUTO_SCROLL_EDGE_PX = 30; +/** Lines scrolled per step. */ +export const AUTO_SCROLL_LINES = 3; +/** Time between scroll steps, in ms. */ +export const AUTO_SCROLL_INTERVAL_MS = 50; + +/** -1 scrolls up into history, 1 down to newer rows, the sign `scrollLines` takes. */ +export type ScrollDirection = -1 | 1; + +/** The interval timers the auto-scroll runs on, injected so tests drive them by hand. */ +export interface AutoScrollTimers { + setInterval(run: () => void, ms: number): unknown; + clearInterval(id: unknown): void; +} + +/** The global timers, read when called, as upstream used them. */ +const globalTimers: AutoScrollTimers = { + setInterval: (run, ms) => setInterval(run, ms), + clearInterval: (id) => clearInterval(id as number), +}; + +/** Which way a drag at `offsetY` on a canvas `height` pixels high scrolls, or 0 for none. */ +export function edgeDirection(offsetY: number, height: number): ScrollDirection | 0 { + if (offsetY < AUTO_SCROLL_EDGE_PX) return -1; + if (offsetY > height - AUTO_SCROLL_EDGE_PX) return 1; + return 0; +} + +/** + * Where a scroll step moves the selection's moving end: to the top-left visible cell when + * scrolling up, the bottom-right one when scrolling down, only if that extends the + * selection; null leaves it where it is. + */ +export function extendedEnd( + end: BufferPoint, + direction: ScrollDirection, + view: { top: number; bottom: number; lastCol: number }, +): BufferPoint | null { + if (direction < 0) return view.top < end.absoluteRow ? { col: 0, absoluteRow: view.top } : null; + return view.bottom > end.absoluteRow ? { col: view.lastCol, absoluteRow: view.bottom } : null; +} + +/** + * Runs `step` every AUTO_SCROLL_INTERVAL_MS in one direction until stopped. Starting in + * the direction it already scrolls keeps the running interval; another direction restarts it. + */ +export class AutoScroll implements Disposable { + private timer: unknown = null; + private scrolling: ScrollDirection | 0 = 0; + + constructor( + private readonly step: (direction: ScrollDirection) => void, + private readonly timers: AutoScrollTimers = globalTimers, + ) {} + + /** The direction it scrolls in, or 0 when stopped. */ + get direction(): ScrollDirection | 0 { + return this.scrolling; + } + + /** Starts scrolling in `direction`. */ + start(direction: ScrollDirection): void { + if (this.timer !== null && this.scrolling === direction) return; + this.stop(); + this.scrolling = direction; + this.timer = this.timers.setInterval(() => this.step(direction), AUTO_SCROLL_INTERVAL_MS); + } + + /** Scrolls while a drag at `offsetY` is near the canvas's top or bottom edge, else stops. */ + followEdge(offsetY: number, height: number): void { + const direction = edgeDirection(offsetY, height); + if (direction === 0) this.stop(); + else this.start(direction); + } + + /** Stops scrolling; stopping when stopped does nothing. */ + stop(): void { + if (this.timer !== null) { + this.timers.clearInterval(this.timer); + this.timer = null; + } + this.scrolling = 0; + } + + dispose(): void { + this.stop(); + } +} diff --git a/src/client/vendor/ghostty-web/selection/clipboard-copy.ts b/src/client/vendor/ghostty-web/selection/clipboard-copy.ts new file mode 100644 index 0000000..b88600e --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/clipboard-copy.ts @@ -0,0 +1,100 @@ +// Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Copying selected text (strategy): a fallback chain of ClipboardItem, writeText and +// execCommand, each tried when the one before is missing or fails (ADR 061). + +/** The browser APIs the copy chain uses, injected so each strategy is tested on its own. */ +export interface ClipboardEnv { + clipboard?: Partial<Pick<Clipboard, 'write' | 'writeText'>>; + ClipboardItem?: new (items: Record<string, Blob>) => ClipboardItem; + document: Pick<Document, 'activeElement' | 'execCommand'>; + /** The hidden textarea execCommand copies from. */ + textarea: HTMLTextAreaElement; + warn(...args: unknown[]): void; +} + +/** The page's clipboard, read when a copy is made (Bun and older browsers have no ClipboardItem). */ +export function browserClipboardEnv( + textarea: HTMLTextAreaElement, + doc: ClipboardEnv['document'], +): ClipboardEnv { + return { + clipboard: navigator.clipboard, + ClipboardItem: typeof ClipboardItem !== 'undefined' ? ClipboardItem : undefined, + document: doc, + textarea, + warn: (...args) => console.warn(...args), + }; +} + +/** + * Copies `text`, modern APIs first: + * 1. ClipboardItem, which Safari accepts because the item is created synchronously within + * the user gesture even though the write is async; on failure, writeText. + * 2. navigator.clipboard.writeText (Chrome; may fail in Safari); on failure, execCommand. + * 3. execCommand('copy') from the hidden textarea, for older browsers. + */ +export function copyText(text: string, env: ClipboardEnv): void { + const { clipboard } = env; + if (clipboard && env.ClipboardItem) { + try { + const blob = new Blob([text], { type: 'text/plain' }); + const item = new env.ClipboardItem({ 'text/plain': blob }); + // A missing write() throws here, and the chain falls through + clipboard.write!([item]).catch((err) => { + env.warn('ClipboardItem write failed, trying writeText:', err); + copyWithWriteText(text, env); + }); + return; + } catch { + // ClipboardItem not supported or failed: fall through + } + } + if (clipboard?.writeText) { + writeTextOrExecCommand(text, env); + return; + } + copyWithExecCommand(text, env); +} + +/** writeText when the clipboard has it, else execCommand. */ +export function copyWithWriteText(text: string, env: ClipboardEnv): void { + if (env.clipboard?.writeText) writeTextOrExecCommand(text, env); + else copyWithExecCommand(text, env); +} + +/** writeText, falling back to execCommand when the write is refused. */ +function writeTextOrExecCommand(text: string, env: ClipboardEnv): void { + env.clipboard!.writeText!(text).catch((err) => { + env.warn('Clipboard writeText failed, trying execCommand:', err); + copyWithExecCommand(text, env); + }); +} + +/** + * Copies with the legacy execCommand: the text goes into the hidden textarea, placed + * offscreen so it can be selected, and focus goes back where it was. + */ +export function copyWithExecCommand(text: string, env: ClipboardEnv): void { + const previouslyFocused = env.document.activeElement as HTMLElement | null; + try { + const { textarea } = env; + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.style.width = '1px'; + textarea.style.height = '1px'; + textarea.style.opacity = '0'; + + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + const success = env.document.execCommand('copy'); + if (previouslyFocused) previouslyFocused.focus(); + if (!success) env.warn('execCommand copy failed'); + } catch (err) { + env.warn('execCommand copy threw:', err); + if (previouslyFocused) previouslyFocused.focus(); + } +} diff --git a/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts b/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts new file mode 100644 index 0000000..33d606a --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts @@ -0,0 +1,73 @@ +// Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The browser menu's target (object): the hidden textarea is moved under the pointer, +// holding the selection, so a right-click menu offers Copy and Paste, and is hidden again +// once the menu closes (ADR 051, ADR 061). + +/** How long after showing the menu, in ms, the textarea starts listening for the menu to close. */ +export const CONTEXT_MENU_RESET_DELAY_MS = 10; + +/** The document events that close the menu. */ +export type MenuCloseSource = Pick<Document, 'addEventListener' | 'removeEventListener'>; + +/** Moves the hidden textarea under a right-click and back. */ +export class ContextMenuTextarea { + constructor( + private readonly textarea: HTMLTextAreaElement, + private readonly doc: MenuCloseSource, + private readonly setTimer: (run: () => void, ms: number) => unknown = (run, ms) => + setTimeout(run, ms), + ) {} + + /** + * Puts the textarea at the pointer, one pixel, invisible but taking pointer events, with + * `selectedText` selected in it (empty when nothing is selected: the menu still offers + * Paste), and focuses it so the browser's menu opens on it. The default is not prevented. + */ + show(e: Pick<MouseEvent, 'clientX' | 'clientY'>, selectedText: string | null): void { + const { textarea } = this; + textarea.style.position = 'fixed'; + textarea.style.left = `${e.clientX}px`; + textarea.style.top = `${e.clientY}px`; + textarea.style.width = '1px'; + textarea.style.height = '1px'; + textarea.style.zIndex = '1000'; + textarea.style.opacity = '0'; + // Pointer events on for now, so the context menu targets the textarea + textarea.style.pointerEvents = 'auto'; + + if (selectedText !== null) { + textarea.value = selectedText; + textarea.select(); + textarea.setSelectionRange(0, selectedText.length); + } else { + textarea.value = ''; + } + textarea.focus(); + + // After a short delay, so the menu appears first, hide the textarea again on the first + // click, contextmenu or blur, which is the menu closing. The timer is not cancelled by + // dispose (ADR 065). + this.setTimer(() => this.hideOnMenuClose(), CONTEXT_MENU_RESET_DELAY_MS); + } + + /** Listens once for the menu to close, then hides the textarea and drops the other listeners. */ + private hideOnMenuClose(): void { + const { textarea, doc } = this; + const reset = () => { + textarea.style.pointerEvents = 'none'; + textarea.style.zIndex = '-10'; + textarea.style.width = '0'; + textarea.style.height = '0'; + textarea.style.left = '0'; + textarea.style.top = '0'; + textarea.value = ''; + + doc.removeEventListener('click', reset); + doc.removeEventListener('contextmenu', reset); + textarea.removeEventListener('blur', reset); + }; + doc.addEventListener('click', reset, { once: true }); + doc.addEventListener('contextmenu', reset, { once: true }); + textarea.addEventListener('blur', reset, { once: true }); + } +} diff --git a/src/client/vendor/ghostty-web/selection/selection-coords.ts b/src/client/vendor/ghostty-web/selection/selection-coords.ts new file mode 100644 index 0000000..c717051 --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/selection-coords.ts @@ -0,0 +1,125 @@ +// Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Selection coordinates (util): buffer points that stay put while the view scrolls, their +// conversion to and from viewport rows, and the pixel, drag and canvas-edge maths of the +// selection gestures (ADR 043, ADR 061). + +import type { CellRange } from '../render/cell-range'; + +/** + * A selection end: a column and an ABSOLUTE row, an index into scrollback (0 to len-1) + * followed by the screen (len to len+rows-1), so a selection persists while scrolling. + */ +export interface BufferPoint { + col: number; + absoluteRow: number; +} + +/** A cell in viewport rows, as a pixel position maps to it. */ +export interface ViewportCell { + col: number; + row: number; +} + +/** The canvas grid a pixel position is read against. */ +export interface CellGrid { + cellWidth: number; + cellHeight: number; + /** How far viewport row 0 starts below the canvas top while smooth scrolling (webtty, ADR 043). */ + scrollOffset: number; + cols: number; + rows: number; +} + +/** A canvas's client rectangle, as `getBoundingClientRect` reports it. */ +export type ClientRect = Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom'>; + +/** The terminal's viewport position (lines scrolled into history), as a whole line count. */ +export function wholeViewportY(rawViewportY: number): number { + return Math.max(0, Math.floor(rawViewportY)); +} + +/** Viewport row to absolute buffer row. */ +export function viewportRowToAbsolute( + viewportRow: number, + scrollbackLength: number, + viewportY: number, +): number { + return scrollbackLength + viewportRow - viewportY; +} + +/** Absolute buffer row to viewport row; it may be outside the visible rows. */ +export function absoluteRowToViewport( + absoluteRow: number, + scrollbackLength: number, + viewportY: number, +): number { + return absoluteRow - scrollbackLength + viewportY; +} + +/** The cell under a canvas-relative pixel position, clamped to the terminal's size. */ +export function pixelToCell(x: number, y: number, grid: CellGrid): ViewportCell { + const col = Math.floor(x / grid.cellWidth); + // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) + const row = Math.floor((y - grid.scrollOffset) / grid.cellHeight); + return { + col: Math.max(0, Math.min(col, grid.cols - 1)), + row: Math.max(0, Math.min(row, grid.rows - 1)), + }; +} + +/** + * The range between two buffer points in viewport rows, start first: a selection dragged + * backwards reads the same as forwards. Rows are not clamped to the viewport. + */ +export function orderedViewportRange( + start: BufferPoint, + end: BufferPoint, + toViewport: (absoluteRow: number) => number, +): CellRange { + let { col: startCol, absoluteRow: startAbsRow } = start; + let { col: endCol, absoluteRow: endAbsRow } = end; + if (startAbsRow > endAbsRow || (startAbsRow === endAbsRow && startCol > endCol)) { + [startCol, endCol] = [endCol, startCol]; + [startAbsRow, endAbsRow] = [endAbsRow, startAbsRow]; + } + return { + startCol, + startRow: toViewport(startAbsRow), + endCol, + endRow: toViewport(endAbsRow), + }; +} + +/** + * Whether a press has moved far enough to be a drag: half a cell width, so the threshold + * scales with the font and a plain click never flashes a selection. + */ +export function pastDragThreshold(dx: number, dy: number, cellWidth: number): boolean { + const threshold = cellWidth * 0.5; + return dx * dx + dy * dy >= threshold * threshold; +} + +/** Whether a client position is outside the rectangle (its edges count as inside). */ +export function outsideRect(rect: ClientRect, clientX: number, clientY: number): boolean { + return ( + clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom + ); +} + +/** A client position clamped into the rectangle, relative to its top-left corner. */ +export function clampedOffset( + rect: ClientRect, + clientX: number, + clientY: number, +): { offsetX: number; offsetY: number } { + const x = Math.max(rect.left, Math.min(clientX, rect.right)); + const y = Math.max(rect.top, Math.min(clientY, rect.bottom)); + return { offsetX: x - rect.left, offsetY: y - rect.top }; +} + +/** Which way a pointer is past the rectangle vertically: -1 above, 1 below, 0 level with it. */ +export function verticalOverflow(rect: ClientRect, clientY: number): -1 | 0 | 1 { + if (clientY < rect.top) return -1; + if (clientY > rect.bottom) return 1; + return 0; +} diff --git a/src/client/vendor/ghostty-web/selection/selection-gestures.ts b/src/client/vendor/ghostty-web/selection/selection-gestures.ts new file mode 100644 index 0000000..ed78756 --- /dev/null +++ b/src/client/vendor/ghostty-web/selection/selection-gestures.ts @@ -0,0 +1,237 @@ +// Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Mouse selection gestures (object): a press and drag on the canvas, a drag past its edge +// (auto-scroll), double and triple clicks, and a click outside to clear, turned into +// calls on a GestureHost (ADR 050, ADR 051, ADR 057, ADR 061). + +import type { ScreenRange } from '../engine/engine-types'; +import { DisposableStore, type Disposable, listen } from '../lifecycle/disposable-store'; +import { AUTO_SCROLL_LINES, AutoScroll, type AutoScrollTimers, extendedEnd } from './auto-scroll'; +import { + type BufferPoint, + type ClientRect, + clampedOffset, + outsideRect, + pastDragThreshold, + verticalOverflow, +} from './selection-coords'; + +/** The canvas the gestures listen on. */ +export interface GestureCanvas + extends Pick<HTMLElement, 'addEventListener' | 'contains' | 'clientHeight'> { + getBoundingClientRect(): ClientRect; + readonly parentElement: Pick<HTMLElement, 'focus'> | null; +} + +/** The document the gestures follow a drag and outside clicks on. */ +export type GestureDocument = Pick<Document, 'addEventListener' | 'removeEventListener'>; + +/** What a gesture reads and changes: the grid under the pointer and the selection. */ +export interface GestureHost { + /** The cell under a canvas-relative pixel position, as a buffer point. */ + pointAt(offsetX: number, offsetY: number): BufferPoint; + /** The cell width in CSS pixels; half of it is the drag threshold. */ + cellWidth(): number; + /** Whether the app takes this press: it tracks the mouse and Shift is up (webtty, ADR 051). */ + appTakesPress(e: MouseEvent): boolean; + /** + * The range a multi-click selects at a point: a word for 2 clicks, a line for 3, or with + * Ctrl or Cmd the command's output (Ghostty's rules, ADR 050 and webtty ADR 057). + */ + clickRange(clicks: number, at: BufferPoint, e: MouseEvent): ScreenRange | null; + /** Scrolls the viewport; negative is up into history. */ + scrollLines(amount: number): void; + /** The visible rows as absolute rows, and the last column. */ + visibleRows(): { top: number; bottom: number; lastCol: number }; + hasSelection(): boolean; + clearSelection(): void; + /** Sets both selection ends. */ + select(start: BufferPoint, end: BufferPoint): void; + /** The selection's moving end, or null with no selection. */ + selectionEnd(): BufferPoint | null; + /** Moves the selection's moving end. */ + extendTo(end: BufferPoint): void; + /** A gesture has finished making a selection. */ + selectionMade(): void; +} + +/** + * Listens on the canvas and the document and drives the selection through the host. It + * owns the drag: whether a press is selecting, where it started, whether it moved past + * the threshold, and the auto-scroll. + */ +export class SelectionGestures implements Disposable { + private selecting = false; + private downX = 0; + private downY = 0; + private thresholdMet = false; + private downTarget: EventTarget | null = null; + private readonly autoScroll: AutoScroll; + // Only the listeners dispose() removes today; the others leak until ADR 065 + private readonly store = new DisposableStore(); + + constructor( + private readonly canvas: GestureCanvas, + private readonly doc: GestureDocument, + private readonly host: GestureHost, + timers?: AutoScrollTimers, + ) { + this.autoScroll = new AutoScroll((direction) => this.autoScrollStep(direction), timers); + this.attach(); + } + + /** A press is down but has not moved past the threshold: no selection is shown yet. */ + get pressPending(): boolean { + return this.selecting && !this.thresholdMet; + } + + /** Ends a drag in progress, so later moves and the release change nothing. */ + endDrag(): void { + this.selecting = false; + } + + /** Stops the auto-scroll and removes the document's move, release and click listeners. */ + dispose(): void { + this.autoScroll.stop(); + this.store.dispose(); + } + + /** Adds the listeners in upstream's order, which the listener snapshot pins (ADR 064). */ + private attach(): void { + const { canvas, doc } = this; + // leaks until ADR 065 + canvas.addEventListener('mousedown', (e: MouseEvent) => this.canvasDown(e)); + // leaks until ADR 065 + canvas.addEventListener('mousemove', (e: MouseEvent) => this.canvasMove(e)); + // leaks until ADR 065 + canvas.addEventListener('mouseleave', (e: MouseEvent) => { + // Leaving the canvas mid-drag scrolls toward the side it left by + const direction = this.selecting + ? verticalOverflow(canvas.getBoundingClientRect(), e.clientY) + : 0; + if (direction !== 0) this.autoScroll.start(direction); + }); + // leaks until ADR 065 + canvas.addEventListener('mouseenter', () => { + if (this.selecting) this.autoScroll.stop(); + }); + this.store.add(listen(doc, 'mousemove', (e) => this.documentMove(e as MouseEvent))); + // Where a press started, so a click that began inside the canvas does not clear. + // leaks until ADR 065 + doc.addEventListener('mousedown', (e: MouseEvent) => { + this.downTarget = e.target; + }); + // A release on the document, not the canvas, so one outside the canvas ends the drag + this.store.add(listen(doc, 'mouseup', () => this.release())); + // leaks until ADR 065 + canvas.addEventListener('click', (e: MouseEvent) => this.multiClick(e)); + this.store.add(listen(doc, 'click', (e) => this.documentClick(e as MouseEvent))); + } + + /** A left press starts a selection, clearing the old one; it focuses the terminal first. */ + private canvasDown(e: MouseEvent): void { + if (e.button !== 0) return; + // The canvas has no tabindex; its parent, the container, takes keyboard focus + this.canvas.parentElement?.focus(); + // A press the app takes (it tracks the mouse, Shift is up) starts no selection + // and leaves the current one; Shift selects here instead. (webtty, ADR 051) + if (this.host.appTakesPress(e)) return; + const point = this.host.pointAt(e.offsetX, e.offsetY); + if (this.host.hasSelection()) this.host.clearSelection(); + this.host.select(point, point); + this.selecting = true; + this.downX = e.offsetX; + this.downY = e.offsetY; + this.thresholdMet = false; + } + + /** A drag on the canvas moves the selection's end and scrolls near the edges. */ + private canvasMove(e: MouseEvent): void { + if (!this.selecting) return; + if (!this.passedThreshold(e.offsetX - this.downX, e.offsetY - this.downY)) return; + this.host.extendTo(this.host.pointAt(e.offsetX, e.offsetY)); + this.autoScroll.followEdge(e.offsetY, this.canvas.clientHeight); + } + + /** + * A drag outside the canvas scrolls when above or below it; when not scrolling, the + * selection's end follows the pointer clamped to the canvas edge. While scrolling, the + * scroll step extends the selection instead. + */ + private documentMove(e: MouseEvent): void { + if (!this.selecting) return; + const rect = this.canvas.getBoundingClientRect(); + const dx = e.clientX - (rect.left + this.downX); + const dy = e.clientY - (rect.top + this.downY); + if (!this.passedThreshold(dx, dy)) return; + if (!outsideRect(rect, e.clientX, e.clientY)) return; + const direction = verticalOverflow(rect, e.clientY); + if (direction === 0) this.autoScroll.stop(); + else this.autoScroll.start(direction); + if (this.autoScroll.direction === 0) { + const { offsetX, offsetY } = clampedOffset(rect, e.clientX, e.clientY); + this.host.extendTo(this.host.pointAt(offsetX, offsetY)); + } + } + + /** Whether the press has become a drag, marking it the first time it moves far enough. */ + private passedThreshold(dx: number, dy: number): boolean { + if (this.thresholdMet) return true; + if (!pastDragThreshold(dx, dy, this.host.cellWidth())) return false; + this.thresholdMet = true; + return true; + } + + /** The release ends the drag; a press that never became a drag clears the selection. */ + private release(): void { + if (!this.selecting) return; + this.selecting = false; + this.autoScroll.stop(); + if (!this.thresholdMet) { + this.host.clearSelection(); + return; + } + if (this.host.hasSelection()) this.host.selectionMade(); + } + + /** + * A double click selects a word and a triple click a line, by Ghostty's rules (ADR 050); + * with Ctrl or Cmd, a triple click selects the command's output (webtty, ADR 057). + * `detail` is the browser's click count. Not while the app takes the press (webtty, ADR 051). + */ + private multiClick(e: MouseEvent): void { + if (e.detail < 2) return; + if (this.host.appTakesPress(e)) return; + const range = this.host.clickRange(e.detail, this.host.pointAt(e.offsetX, e.offsetY), e); + if (!range) return; + this.host.select( + { col: range.start.col, absoluteRow: range.start.row }, + { col: range.end.col, absoluteRow: range.end.row }, + ); + this.host.selectionMade(); + } + + /** + * A click with both its press and release outside the canvas clears the selection, so + * clicking anywhere else deselects; one ending a drag that began inside does not. + */ + private documentClick(e: MouseEvent): void { + if (this.selecting) return; + if (this.downTarget && this.canvas.contains(this.downTarget as Node)) return; + if (!this.canvas.contains(e.target as Node) && this.host.hasSelection()) { + this.host.clearSelection(); + } + } + + /** One auto-scroll step: scroll, then extend the selection to the newly visible edge. */ + private autoScrollStep(direction: -1 | 1): void { + if (!this.selecting) { + this.autoScroll.stop(); + return; + } + this.host.scrollLines(AUTO_SCROLL_LINES * direction); + const end = this.host.selectionEnd(); + if (!end) return; + const extended = extendedEnd(end, direction, this.host.visibleRows()); + if (extended) this.host.extendTo(extended); + } +} diff --git a/src/client/vendor/tests/selection-manager.test.ts b/src/client/vendor/tests/selection-manager.test.ts deleted file mode 100644 index f4bdf3b..0000000 --- a/src/client/vendor/tests/selection-manager.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import type { Ghostty, GhosttyTerminal } from '../ghostty-web/ghostty'; -import type { CanvasRenderer } from '../ghostty-web/renderer'; -import { SelectionManager } from '../ghostty-web/selection-manager'; -import type { Terminal } from '../ghostty-web/terminal'; -import { loadGhostty } from './support/wasm'; - -type Listener = (e: object) => void; - -/** An event target that keeps its listeners, so a test can dispatch to them by hand. */ -function target() { - const listeners = new Map<string, Listener[]>(); - return { - listeners, - addEventListener: (type: string, listener: Listener) => - listeners.set(type, [...(listeners.get(type) ?? []), listener]), - removeEventListener: () => {}, - dispatch: (type: string, e: object) => { - for (const listener of listeners.get(type) ?? []) listener(e); - }, - }; -} - -describe('SelectionManager copy on select', () => { - const doc = globalThis.document; - const nav = globalThis.navigator; - let documentTarget: ReturnType<typeof target>; - let writes: string[]; - let wasmTerm: GhosttyTerminal; - - beforeEach(async () => { - documentTarget = target(); - globalThis.document = documentTarget as unknown as Document; - writes = []; - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: { - clipboard: { - writeText: async (text: string) => { - writes.push(text); - }, - }, - }, - }); - const ghostty = await loadGhostty(); - wasmTerm = ghostty.createTerminal(20, 4); - wasmTerm.write('hello world\r\nsecond line'); - }); - - afterEach(() => { - wasmTerm.free(); - globalThis.document = doc; - Object.defineProperty(globalThis, 'navigator', { configurable: true, value: nav }); - }); - - /** A manager over the wasm terminal, counting the times it builds the selected text. */ - function manager(copyOnSelect: boolean, scrolls: number[] = []) { - const canvas = { - ...target(), - getBoundingClientRect: () => ({ left: 0, top: 0, right: 160, bottom: 64 }), - }; - const terminal = { - cols: 20, - rows: 4, - options: { copyOnSelect }, - getViewportY: () => 0, - scrollLines: (amount: number) => scrolls.push(amount), - } as unknown as Terminal; - const renderer = { - getCanvas: () => canvas, - getMetrics: () => ({ width: 8, height: 16 }), - scrollOffset: 0, - } as unknown as CanvasRenderer; - const textarea = { style: {} } as HTMLTextAreaElement; - const m = new SelectionManager(terminal, renderer, wasmTerm, textarea); - let builds = 0; - const getSelection = m.getSelection.bind(m); - m.getSelection = () => { - builds++; - return getSelection(); - }; - let changes = 0; - m.onSelectionChange(() => changes++); - return { m, canvas, builds: () => builds, changes: () => changes }; - } - - /** Drags across "hello" on row 0 and releases. */ - function drag(canvas: ReturnType<typeof target>) { - canvas.dispatch('mousedown', { button: 0, offsetX: 1, offsetY: 4 }); - canvas.dispatch('mousemove', { offsetX: 4 * 8 + 1, offsetY: 4 }); - documentTarget.dispatch('mouseup', {}); - } - - test('a drag copies its text once, building it once', () => { - const { canvas, builds, changes } = manager(true); - drag(canvas); - expect(writes).toEqual(['hello']); - expect(builds()).toBe(1); - expect(changes()).toBe(1); - }); - - test('a double and a triple click copy once each', () => { - const { canvas } = manager(true); - canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); - canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); - expect(writes).toEqual(['world', 'second line']); - }); - - // The terminal's scrollLines is reached only from here, so a call graph from - // src/client/index.ts does not show it. - test('a drag below the terminal scrolls it down through scrollLines', async () => { - const scrolls: number[] = []; - const { canvas } = manager(true, scrolls); - canvas.dispatch('mousedown', { button: 0, offsetX: 1, offsetY: 4 }); - documentTarget.dispatch('mousemove', { clientX: 40, clientY: 100 }); - await Bun.sleep(80); - documentTarget.dispatch('mouseup', {}); - expect(scrolls.length).toBeGreaterThan(0); - expect(scrolls.every((amount) => amount === 3)).toBe(true); - }); - - // While the app tracks the mouse, a press is the app's; Shift selects (ADR 051) - test('with mouse tracking on, a drag or a double click selects and copies nothing', () => { - wasmTerm.write('\x1b[?1000h'); - const { m, canvas, changes } = manager(true); - drag(canvas); - canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); - canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); - expect(m.hasSelection()).toBe(false); - expect(m.getSelectionRange()).toBeNull(); - expect(writes).toEqual([]); - expect(changes()).toBe(0); - }); - - test('with mouse tracking on, Shift drag, double and triple click select and copy', () => { - wasmTerm.write('\x1b[?1002h\x1b[?1006h'); - const { m, canvas } = manager(true); - canvas.dispatch('mousedown', { button: 0, offsetX: 1, offsetY: 4, shiftKey: true }); - canvas.dispatch('mousemove', { offsetX: 4 * 8 + 1, offsetY: 4, shiftKey: true }); - documentTarget.dispatch('mouseup', {}); - canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4, shiftKey: true }); - canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20, shiftKey: true }); - expect(writes).toEqual(['hello', 'world', 'second line']); - expect(m.hasSelection()).toBe(false); - }); - - test('a copied selection is cleared, as Claude Code and pi clear theirs', () => { - const { m, canvas, changes } = manager(true); - drag(canvas); - expect(writes).toEqual(['hello']); - expect(m.hasSelection()).toBe(false); - expect(m.getSelectionRange()).toBeNull(); - expect(changes()).toBe(1); - }); - - test('a press the app takes leaves the current selection', () => { - const { m, canvas } = manager(false); - drag(canvas); - wasmTerm.write('\x1b[?1000h'); - canvas.dispatch('mousedown', { button: 0, offsetX: 8 * 7, offsetY: 20 }); - documentTarget.dispatch('mouseup', {}); - expect(m.getSelection()).toBe('hello'); - }); - - test('nothing is copied or built with copyOnSelect off, but the change is reported', () => { - const { m, canvas, builds, changes } = manager(false); - drag(canvas); - canvas.dispatch('click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); - canvas.dispatch('click', { detail: 3, offsetX: 8, offsetY: 20 }); - expect(writes).toEqual([]); - expect(builds()).toBe(0); - expect(changes()).toBe(3); - // The selection stays, to copy with Cmd+C, Ctrl+Shift+C or the menu. - expect(m.hasSelection()).toBe(true); - }); -}); - -describe('SelectionManager text of wide characters', () => { - const doc = globalThis.document; - let ghostty: Ghostty; - - beforeEach(async () => { - globalThis.document = target() as unknown as Document; - ghostty = await loadGhostty(); - }); - - afterEach(() => { - globalThis.document = doc; - }); - - /** The text a selection from (startCol, startRow) to (endCol, endRow) copies; rows are absolute. */ - function selected( - wasmTerm: GhosttyTerminal, - start: [number, number], - end: [number, number], - ): string { - const m = new SelectionManager( - { cols: wasmTerm.cols, rows: wasmTerm.rows, options: {} } as unknown as Terminal, - { getCanvas: () => target() } as unknown as CanvasRenderer, - wasmTerm, - { style: {} } as HTMLTextAreaElement, - ); - const internal = m as unknown as Record<string, { col: number; absoluteRow: number }>; - internal.selectionStart = { col: start[0], absoluteRow: start[1] }; - internal.selectionEnd = { col: end[0], absoluteRow: end[1] }; - return m.getSelection(); - } - - // Emoji with a skin tone, with VS16 (U+2764 U+FE0F) and a ZWJ family, and combining marks - test('copies no space for the spacer after a CJK character or an emoji', () => { - const t = ghostty.createTerminal(30, 4); - t.write( - '中文字 ok\r\n\u{1f44d}\u{1f3fd} \u2764\ufe0f \u{1f468}\u200d\u{1f469}\u200d\u{1f467} x\r\ne\u0301 a\u20dd', - ); - expect(selected(t, [0, 0], [29, 2])).toBe( - '中文字 ok\n\u{1f44d}\u{1f3fd} \u2764\ufe0f \u{1f468}\u200d\u{1f469}\u200d\u{1f467} x\ne\u0301 a\u20dd', - ); - // A selection that starts or ends on the right half of a wide character - expect(selected(t, [1, 0], [3, 0])).toBe('中文'); - t.free(); - }); - - test('copies no space for a spacer at the last column, and joins the soft wrap', () => { - // A wide character that does not fit wraps, leaving a spacer at the last column. - // Ghostty joins soft-wrapped rows without a newline (ADR 050). - const wrapped = ghostty.createTerminal(5, 4); - wrapped.write('abcd中ef'); - expect(selected(wrapped, [0, 0], [4, 1])).toBe('abcd中ef'); - expect(selected(wrapped, [2, 0], [3, 0])).toBe('cd'); - // Ending on the spacer takes the wide character it stands for (Ghostty's formatter) - expect(selected(wrapped, [2, 0], [4, 0])).toBe('cd中'); - wrapped.free(); - // A wide character ending at the last column - const fits = ghostty.createTerminal(6, 4); - fits.write('abcd中ef'); - expect(selected(fits, [3, 0], [1, 1])).toBe('d中ef'); - fits.free(); - }); - - test('copies scrollback rows without spacers', () => { - const t = ghostty.createTerminal(10, 2); - t.write('中文 1\r\n日本語 2\r\nlast\r\nrow'); - expect(t.getScrollbackLength()).toBe(2); - expect(selected(t, [0, 0], [9, 3])).toBe('中文 1\n日本語 2\nlast\nrow'); - t.free(); - }); -}); - -// Behaviour the selection keeps on any engine API: mouse gestures over rows, absolute -// rows across scrolling and output, and the word and line a click picks. -describe('SelectionManager gestures', () => { - const doc = globalThis.document; - const nav = globalThis.navigator; - let documentTarget: ReturnType<typeof target>; - let ghostty: Ghostty; - let writes: string[]; - - beforeEach(async () => { - documentTarget = target(); - globalThis.document = documentTarget as unknown as Document; - writes = []; - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: { clipboard: { writeText: async (text: string) => void writes.push(text) } }, - }); - ghostty = await loadGhostty(); - }); - - afterEach(() => { - globalThis.document = doc; - Object.defineProperty(globalThis, 'navigator', { configurable: true, value: nav }); - }); - - /** A manager over `wasmTerm` with 8x16 cells, scrolled up `viewportY()` lines. */ - function manager(wasmTerm: GhosttyTerminal, viewportY: () => number = () => 0) { - const canvas = { - ...target(), - getBoundingClientRect: () => ({ - left: 0, - top: 0, - right: wasmTerm.cols * 8, - bottom: wasmTerm.rows * 16, - }), - }; - const terminal = { - cols: wasmTerm.cols, - rows: wasmTerm.rows, - // Off, so the selection a gesture makes stays to be read (on, it is copied and cleared) - options: { copyOnSelect: false }, - getViewportY: viewportY, - scrollLines: () => {}, - } as unknown as Terminal; - const renderer = { - getCanvas: () => canvas, - getMetrics: () => ({ width: 8, height: 16 }), - scrollOffset: 0, - } as unknown as CanvasRenderer; - const m = new SelectionManager(terminal, renderer, wasmTerm, { - style: {}, - } as HTMLTextAreaElement); - return { m, canvas }; - } - - /** Drags from cell (c0, r0) to cell (c1, r1), in viewport rows, and releases. */ - function drag(canvas: ReturnType<typeof target>, c0: number, r0: number, c1: number, r1: number) { - canvas.dispatch('mousedown', { button: 0, offsetX: c0 * 8 + 1, offsetY: r0 * 16 + 4 }); - canvas.dispatch('mousemove', { offsetX: c1 * 8 + 1, offsetY: r1 * 16 + 4 }); - documentTarget.dispatch('mouseup', {}); - } - - const click = (canvas: ReturnType<typeof target>, detail: number, col: number, row: number) => - canvas.dispatch('click', { detail, offsetX: col * 8 + 1, offsetY: row * 16 + 4 }); - - test('a drag backwards selects the same text as forwards', () => { - const t = ghostty.createTerminal(20, 4); - t.write('hello world\r\nsecond line'); - const { m, canvas } = manager(t); - drag(canvas, 5, 1, 6, 0); - expect(m.getSelection()).toBe('world\nsecond'); - expect(m.getSelectionRange()).toEqual({ startCol: 6, startRow: 0, endCol: 5, endRow: 1 }); - t.free(); - }); - - test('a drag while scrolled selects scrollback rows, which stay selected at the bottom', () => { - const t = ghostty.createTerminal(10, 2); - t.write('one\r\ntwo\r\nthree\r\nfour'); - let viewportY = 2; - const { m, canvas } = manager(t, () => viewportY); - drag(canvas, 0, 0, 4, 1); - expect(m.getSelection()).toBe('one\ntwo'); - viewportY = 0; - expect(m.getSelection()).toBe('one\ntwo'); - expect(m.getSelectionRange()).toEqual({ startCol: 0, startRow: -2, endCol: 4, endRow: -1 }); - t.free(); - }); - - test('a selection keeps its text while output arrives and scrolls it into history', () => { - const t = ghostty.createTerminal(10, 3); - t.write('keep me\r\n'); - const { m, canvas } = manager(t); - drag(canvas, 0, 0, 6, 0); - t.write('a\r\nb\r\nc\r\nd'); - expect(t.getScrollbackLength()).toBe(2); - expect(m.getSelection()).toBe('keep me'); - expect(m.getSelectionRange()).toEqual({ startCol: 0, startRow: -2, endCol: 6, endRow: -2 }); - t.free(); - }); - - test('a double click selects a word with path and address characters', () => { - const t = ghostty.createTerminal(70, 2); - t.write('ls /usr/local/bin/foo.txt a@b.c x+y ~/q "quoted" (paren) $HOME'); - const { m, canvas } = manager(t); - const word = (col: number) => { - click(canvas, 2, col, 0); - return m.getSelection(); - }; - expect(word(8)).toBe('/usr/local/bin/foo.txt'); - expect(word(27)).toBe('a@b.c'); - expect(word(33)).toBe('x+y'); - expect(word(37)).toBe('~/q'); - expect(word(43)).toBe('quoted'); - expect(word(52)).toBe('paren'); - expect(word(59)).toBe('HOME'); - t.free(); - }); - - test('a double click on an empty cell selects nothing', () => { - const t = ghostty.createTerminal(20, 2); - t.write('word'); - const { m, canvas } = manager(t); - click(canvas, 2, 15, 0); - expect(m.hasSelection()).toBe(false); - expect(writes).toEqual([]); - t.free(); - }); - - test('a triple click selects the line without trailing spaces; an empty line selects nothing', () => { - const t = ghostty.createTerminal(20, 3); - t.write('one two \r\n\r\nthree'); - const { m, canvas } = manager(t); - click(canvas, 3, 15, 1); - expect(m.hasSelection()).toBe(false); - click(canvas, 3, 15, 0); - expect(m.getSelection()).toBe('one two'); - click(canvas, 3, 0, 2); - expect(m.getSelection()).toBe('three'); - t.free(); - }); - - test('a triple click in scrollback selects that line', () => { - const t = ghostty.createTerminal(10, 2); - t.write('first\r\nsecond\r\nthird'); - const { m, canvas } = manager(t, () => 1); - click(canvas, 3, 2, 0); - expect(m.getSelection()).toBe('first'); - t.free(); - }); - - test('a triple click with Ctrl or Cmd selects the command output; nothing outside it', () => { - const t = ghostty.createTerminal(20, 6); - t.write('\x1b]133;A\x07$ \x1b]133;B\x07ls\r\n\x1b]133;C\x07a.txt\r\nb.txt\r\n'); - t.write('\x1b]133;D;0\x07\x1b]133;A\x07$ '); - const { m, canvas } = manager(t); - const output = (row: number, mods: object) => - canvas.dispatch('click', { detail: 3, offsetX: 1, offsetY: row * 16 + 4, ...mods }); - output(2, { ctrlKey: true }); - expect(m.getSelection()).toBe('a.txt\nb.txt'); - m.clearSelection(); - output(1, { metaKey: true }); - expect(m.getSelection()).toBe('a.txt\nb.txt'); - output(1, {}); - expect(m.getSelection()).toBe('a.txt'); - m.clearSelection(); - output(3, { ctrlKey: true }); - expect(m.hasSelection()).toBe(false); - t.free(); - }); - - // Ghostty's selection rules (ADR 050) - - test('a selection across a soft wrap copies one line; a hard newline stays', () => { - const t = ghostty.createTerminal(10, 4); - t.write('0123456789abcdef\r\nnext'); - const { m, canvas } = manager(t); - drag(canvas, 0, 0, 3, 2); - expect(m.getSelection()).toBe('0123456789abcdef\nnext'); - t.free(); - }); - - test('a double click selects a word that wraps onto the next row', () => { - const t = ghostty.createTerminal(10, 3); - t.write('cd /usr/local/bin'); - const { m, canvas } = manager(t); - click(canvas, 2, 1, 1); - expect(m.getSelection()).toBe('/usr/local/bin'); - expect(m.getSelectionRange()).toEqual({ startCol: 3, startRow: 0, endCol: 6, endRow: 1 }); - t.free(); - }); - - test("a double click selects a URL query and non-ASCII words by Ghostty's word characters", () => { - const t = ghostty.createTerminal(40, 2); - t.write('see //x.io/a?b=c&d#e or café 中文字'); - const { m, canvas } = manager(t); - click(canvas, 2, 8, 0); - expect(m.getSelection()).toBe('//x.io/a?b=c&d#e'); - click(canvas, 2, 24, 0); - expect(m.getSelection()).toBe('café'); - click(canvas, 2, 30, 0); - expect(m.getSelection()).toBe('中文字'); - t.free(); - }); - - test('a triple click selects a soft-wrapped line whole, without its indent', () => { - const t = ghostty.createTerminal(8, 3); - t.write(' wrapped line\r\nx'); - const { m, canvas } = manager(t); - click(canvas, 3, 0, 1); - expect(m.getSelection()).toBe('wrapped line'); - expect(m.getSelectionRange()).toEqual({ startCol: 2, startRow: 0, endCol: 5, endRow: 1 }); - t.free(); - }); -}); diff --git a/src/client/vendor/tests/selection/auto-scroll.test.ts b/src/client/vendor/tests/selection/auto-scroll.test.ts new file mode 100644 index 0000000..7261d0d --- /dev/null +++ b/src/client/vendor/tests/selection/auto-scroll.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { + AUTO_SCROLL_EDGE_PX, + AUTO_SCROLL_INTERVAL_MS, + AutoScroll, + edgeDirection, + extendedEnd, + type ScrollDirection, +} from '../../ghostty-web/selection/auto-scroll'; + +// The drag auto-scroll on a manual clock: its edge, its interval, restarts and teardown. + +/** An AutoScroll on a manual clock, recording each step's direction. */ +function scroller() { + const clock = new ManualClock(); + const steps: ScrollDirection[] = []; + const scroll = new AutoScroll((direction) => steps.push(direction), clock); + return { clock, steps, scroll }; +} + +describe('edgeDirection', () => { + test.each([ + [0, -1], + [AUTO_SCROLL_EDGE_PX - 1, -1], + [AUTO_SCROLL_EDGE_PX, 0], + [100 - AUTO_SCROLL_EDGE_PX, 0], + [100 - AUTO_SCROLL_EDGE_PX + 1, 1], + ])('a drag at y %p of a 100 px canvas scrolls %p', (y, direction) => { + expect(edgeDirection(y, 100)).toBe(direction as ScrollDirection | 0); + }); +}); + +describe('extendedEnd', () => { + const view = { top: 10, bottom: 13, lastCol: 79 }; + + test.each([ + // Up: to the top-left visible cell, only when that is above the end + [{ col: 5, absoluteRow: 12 }, -1, { col: 0, absoluteRow: 10 }], + [{ col: 5, absoluteRow: 10 }, -1, null], + // Down: to the bottom-right visible cell, only when that is below the end + [{ col: 5, absoluteRow: 11 }, 1, { col: 79, absoluteRow: 13 }], + [{ col: 5, absoluteRow: 13 }, 1, null], + ])('the end %j scrolling %p moves to %j', (end, direction, moved) => { + expect(extendedEnd(end, direction as ScrollDirection, view)).toEqual(moved); + }); +}); + +describe('AutoScroll', () => { + test('steps every interval in its direction until stopped', () => { + const { clock, steps, scroll } = scroller(); + scroll.start(1); + expect(scroll.direction).toBe(1); + clock.advance(AUTO_SCROLL_INTERVAL_MS * 3); + expect(steps).toEqual([1, 1, 1]); + scroll.stop(); + expect(scroll.direction).toBe(0); + clock.advance(AUTO_SCROLL_INTERVAL_MS * 3); + expect(steps).toEqual([1, 1, 1]); + expect(clock.pending.timers).toBe(0); + }); + + test('starting in the same direction keeps the interval; another restarts it', () => { + const { clock, steps, scroll } = scroller(); + scroll.start(-1); + clock.advance(30); + scroll.start(-1); + clock.advance(20); + expect(steps).toEqual([-1]); + scroll.start(1); + clock.advance(30); + expect(steps).toEqual([-1]); + clock.advance(20); + expect(steps).toEqual([-1, 1]); + expect(clock.pending.timers).toBe(1); + }); + + test('followEdge scrolls near an edge and stops in the middle', () => { + const { scroll } = scroller(); + scroll.followEdge(5, 200); + expect(scroll.direction).toBe(-1); + scroll.followEdge(195, 200); + expect(scroll.direction).toBe(1); + scroll.followEdge(100, 200); + expect(scroll.direction).toBe(0); + }); + + test('dispose stops the interval, and a second dispose does nothing', () => { + const { clock, steps, scroll } = scroller(); + scroll.start(1); + scroll.dispose(); + expect(clock.pending.timers).toBe(0); + scroll.dispose(); + clock.advance(AUTO_SCROLL_INTERVAL_MS * 2); + expect(steps).toEqual([]); + }); + + test('runs on the global timers by default', async () => { + const steps: ScrollDirection[] = []; + const scroll = new AutoScroll((direction) => steps.push(direction)); + scroll.start(-1); + await Bun.sleep(AUTO_SCROLL_INTERVAL_MS + 20); + scroll.dispose(); + expect(steps[0]).toBe(-1); + }); +}); diff --git a/src/client/vendor/tests/selection/clipboard-copy.test.ts b/src/client/vendor/tests/selection/clipboard-copy.test.ts new file mode 100644 index 0000000..f7ce12a --- /dev/null +++ b/src/client/vendor/tests/selection/clipboard-copy.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'bun:test'; +import { + browserClipboardEnv, + type ClipboardEnv, + copyText, + copyWithExecCommand, + copyWithWriteText, +} from '../../ghostty-web/selection/clipboard-copy'; +import { FakeTextarea } from '../support/selection-fixture'; + +// Each strategy of the copy chain and each fallback between them. + +interface EnvOptions { + /** Whether ClipboardItem exists, and whether constructing one throws. */ + item?: 'ok' | 'throws'; + write?: 'ok' | 'rejects'; + writeText?: 'ok' | 'rejects' | 'missing'; + noClipboard?: boolean; + execCommand?: 'ok' | 'fails' | 'throws'; + /** Whether something has focus before the copy. */ + focused?: boolean; +} + +/** An env whose every call is logged in order. */ +function env(options: EnvOptions) { + const log: string[] = []; + const textarea = new FakeTextarea(); + const focused = { focus: () => void log.push('refocus') }; + class Item { + constructor(readonly items: Record<string, Blob>) { + if (options.item === 'throws') throw new Error('no items'); + log.push(`item ${Object.keys(items).join()}`); + } + } + const settle = (outcome: string | undefined, name: string) => + outcome === 'rejects' ? Promise.reject(new Error(name)) : Promise.resolve(); + const clipboard: ClipboardEnv['clipboard'] = { + write: () => { + log.push('write'); + return settle(options.write, 'write'); + }, + }; + if (options.writeText !== 'missing') { + clipboard.writeText = (text: string) => { + log.push(`writeText ${text}`); + return settle(options.writeText, 'writeText'); + }; + } + const e: ClipboardEnv = { + clipboard: options.noClipboard ? undefined : clipboard, + ClipboardItem: options.item ? (Item as unknown as ClipboardEnv['ClipboardItem']) : undefined, + document: { + activeElement: options.focused ? (focused as unknown as Element) : null, + execCommand: (command: string) => { + log.push(`execCommand ${command} ${textarea.value}`); + if (options.execCommand === 'throws') throw new Error('denied'); + return options.execCommand !== 'fails'; + }, + }, + textarea: textarea.element, + warn: (message: unknown) => void log.push(`warn ${message}`), + }; + return { env: e, log, textarea }; +} + +/** Lets rejected writes run their fallbacks. */ +const settled = () => Bun.sleep(0); + +describe('copyText', () => { + test('writes a ClipboardItem when the browser has one', async () => { + const { env: e, log } = env({ item: 'ok', writeText: 'ok' }); + copyText('hi', e); + await settled(); + expect(log).toEqual(['item text/plain', 'write']); + }); + + test('falls back to writeText when the ClipboardItem write fails', async () => { + const { env: e, log } = env({ item: 'ok', write: 'rejects', writeText: 'ok' }); + copyText('hi', e); + await settled(); + expect(log).toEqual([ + 'item text/plain', + 'write', + 'warn ClipboardItem write failed, trying writeText:', + 'writeText hi', + ]); + }); + + test('falls back to execCommand when the ClipboardItem write fails and there is no writeText', async () => { + const { env: e, log } = env({ item: 'ok', write: 'rejects', writeText: 'missing' }); + copyText('hi', e); + await settled(); + expect(log.slice(-1)).toEqual(['execCommand copy hi']); + }); + + test('uses writeText when a ClipboardItem cannot be made', async () => { + const { env: e, log } = env({ item: 'throws', writeText: 'ok' }); + copyText('hi', e); + await settled(); + expect(log).toEqual(['writeText hi']); + }); + + test('uses writeText without ClipboardItem, then execCommand if it fails', async () => { + const ok = env({ writeText: 'ok' }); + copyText('hi', ok.env); + await settled(); + expect(ok.log).toEqual(['writeText hi']); + const refused = env({ writeText: 'rejects' }); + copyText('hi', refused.env); + await settled(); + expect(refused.log).toEqual([ + 'writeText hi', + 'warn Clipboard writeText failed, trying execCommand:', + 'execCommand copy hi', + ]); + }); + + test('uses execCommand without a clipboard', () => { + const { env: e, log } = env({ noClipboard: true }); + copyText('hi', e); + expect(log).toEqual(['execCommand copy hi']); + }); +}); + +describe('copyWithWriteText', () => { + test('writes the text, or goes to execCommand without writeText', async () => { + const ok = env({ writeText: 'ok' }); + copyWithWriteText('a', ok.env); + await settled(); + expect(ok.log).toEqual(['writeText a']); + const missing = env({ writeText: 'missing' }); + copyWithWriteText('a', missing.env); + expect(missing.log).toEqual(['execCommand copy a']); + }); +}); + +describe('copyWithExecCommand', () => { + test('copies from the textarea placed offscreen, selected, and gives focus back', () => { + const { env: e, log, textarea } = env({ focused: true }); + copyWithExecCommand('text', e); + expect(textarea.style).toEqual({ + position: 'fixed', + left: '-9999px', + top: '0', + width: '1px', + height: '1px', + opacity: '0', + }); + expect(textarea.calls).toEqual(['focus', 'select', 'range 0-4']); + expect(log).toEqual(['execCommand copy text', 'refocus']); + }); + + test('warns when the copy fails or throws, still giving focus back', () => { + const fails = env({ execCommand: 'fails', focused: true }); + copyWithExecCommand('x', fails.env); + expect(fails.log).toEqual(['execCommand copy x', 'refocus', 'warn execCommand copy failed']); + const throws = env({ execCommand: 'throws', focused: true }); + copyWithExecCommand('x', throws.env); + expect(throws.log).toEqual(['execCommand copy x', 'warn execCommand copy threw:', 'refocus']); + }); + + test('gives focus to nothing when nothing had it', () => { + const { env: e, log } = env({ execCommand: 'throws' }); + copyWithExecCommand('x', e); + expect(log).not.toContain('refocus'); + }); +}); + +describe('browserClipboardEnv', () => { + test("reads the page's clipboard; Bun has no ClipboardItem", () => { + const textarea = new FakeTextarea(); + const doc = { activeElement: null, execCommand: () => true }; + const e = browserClipboardEnv(textarea.element, doc); + expect(e.clipboard).toBe(navigator.clipboard); + expect(e.ClipboardItem).toBeUndefined(); + expect(e.document).toBe(doc); + expect(e.textarea).toBe(textarea.element); + }); + + test('warns on the console', () => { + const warn = console.warn; + const seen: unknown[][] = []; + console.warn = (...args: unknown[]) => void seen.push(args); + try { + browserClipboardEnv(new FakeTextarea().element, { + activeElement: null, + execCommand: () => true, + }).warn('a', 1); + } finally { + console.warn = warn; + } + expect(seen).toEqual([['a', 1]]); + }); +}); diff --git a/src/client/vendor/tests/selection/context-menu-textarea.test.ts b/src/client/vendor/tests/selection/context-menu-textarea.test.ts new file mode 100644 index 0000000..4237633 --- /dev/null +++ b/src/client/vendor/tests/selection/context-menu-textarea.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { RecordingTarget } from '../../../test-support/recording-target'; +import { + CONTEXT_MENU_RESET_DELAY_MS, + ContextMenuTextarea, +} from '../../ghostty-web/selection/context-menu-textarea'; +import { FakeTextarea, fire } from '../support/selection-fixture'; + +// The textarea a right-click menu opens on: shown at the pointer with the selection, and +// hidden again by the first click, contextmenu or blur after the menu opens. + +function menu() { + const textarea = new FakeTextarea(); + const doc = new RecordingTarget('document'); + const clock = new ManualClock(); + const shown = new ContextMenuTextarea( + textarea.element, + doc as unknown as Document, + clock.setTimeout, + ); + return { textarea, doc, clock, menu: shown }; +} + +const HIDDEN = { + position: 'fixed', + pointerEvents: 'none', + zIndex: '-10', + width: '0', + height: '0', + left: '0', + top: '0', + opacity: '0', +}; + +describe('ContextMenuTextarea', () => { + test('shows the textarea at the pointer holding the selected text, focused', () => { + const { textarea, menu: m } = menu(); + m.show({ clientX: 12, clientY: 34 }, 'picked'); + expect(textarea.style).toEqual({ + position: 'fixed', + left: '12px', + top: '34px', + width: '1px', + height: '1px', + zIndex: '1000', + opacity: '0', + pointerEvents: 'auto', + }); + expect(textarea.value).toBe('picked'); + expect(textarea.calls).toEqual(['select', 'range 0-6', 'focus']); + }); + + test('with nothing selected, the textarea is empty and the menu still opens on it', () => { + const { textarea, menu: m } = menu(); + textarea.value = 'stale'; + m.show({ clientX: 0, clientY: 0 }, null); + expect(textarea.value).toBe(''); + expect(textarea.calls).toEqual(['focus']); + }); + + test.each(['click', 'contextmenu', 'blur'])( + 'a %s after the delay hides the textarea and drops the other listeners', + (type) => { + const { textarea, doc, clock, menu: m } = menu(); + m.show({ clientX: 12, clientY: 34 }, 'picked'); + // Nothing listens until the menu has had time to open + clock.advance(CONTEXT_MENU_RESET_DELAY_MS - 1); + expect(doc.active).toHaveLength(0); + clock.advance(1); + expect(doc.active.map((r) => [r.type, r.once])).toEqual([ + ['click', true], + ['contextmenu', true], + ]); + expect(textarea.active.map((r) => r.type)).toEqual(['blur']); + fire(type === 'blur' ? textarea : doc, type); + expect(textarea.style).toEqual(HIDDEN); + expect(textarea.value).toBe(''); + expect(doc.active).toHaveLength(0); + expect(textarea.active).toHaveLength(0); + }, + ); + + test('waits on the global setTimeout by default', async () => { + const textarea = new FakeTextarea(); + const doc = new RecordingTarget('document'); + new ContextMenuTextarea(textarea.element, doc as unknown as Document).show( + { clientX: 1, clientY: 1 }, + null, + ); + await Bun.sleep(CONTEXT_MENU_RESET_DELAY_MS + 10); + expect(doc.active).toHaveLength(2); + }); +}); diff --git a/src/client/vendor/tests/selection/selection-coords.test.ts b/src/client/vendor/tests/selection/selection-coords.test.ts new file mode 100644 index 0000000..4c0b37b --- /dev/null +++ b/src/client/vendor/tests/selection/selection-coords.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; +import { + absoluteRowToViewport, + clampedOffset, + orderedViewportRange, + outsideRect, + pastDragThreshold, + pixelToCell, + verticalOverflow, + viewportRowToAbsolute, + wholeViewportY, +} from '../../ghostty-web/selection/selection-coords'; + +// The selection's coordinate maths, table-driven. + +const RECT = { left: 10, top: 20, right: 90, bottom: 100 }; + +describe('selection coordinates', () => { + test.each([ + [0, 0], + [2.7, 2], + [-3, 0], + ])('wholeViewportY(%p) is %p', (raw, whole) => { + expect(wholeViewportY(raw)).toBe(whole); + }); + + // [viewport row, scrollback length, viewportY, absolute row] + test.each([ + [0, 0, 0, 0], + [0, 5, 0, 5], + [3, 5, 2, 6], + [0, 5, 5, 0], + ])( + 'viewport row %p with %p lines of scrollback at viewportY %p is absolute row %p', + (row, len, y, abs) => { + expect(viewportRowToAbsolute(row, len, y)).toBe(abs); + expect(absoluteRowToViewport(abs, len, y)).toBe(row); + }, + ); + + const grid = { cellWidth: 8, cellHeight: 16, scrollOffset: 0, cols: 10, rows: 4 }; + + test.each([ + [0, 0, grid, { col: 0, row: 0 }], + [17, 33, grid, { col: 2, row: 2 }], + // Clamped to the grid on every side + [-5, -5, grid, { col: 0, row: 0 }], + [500, 500, grid, { col: 9, row: 3 }], + // Row 0 starts the scroll offset below the canvas top (ADR 043) + [0, 20, { ...grid, scrollOffset: 6 }, { col: 0, row: 0 }], + [0, 22, { ...grid, scrollOffset: 6 }, { col: 0, row: 1 }], + ])('pixel (%p, %p) is cell %j', (x, y, g, cell) => { + expect(pixelToCell(x, y, g)).toEqual(cell); + }); + + test('orderedViewportRange puts the start first and maps rows to the viewport', () => { + const down = (row: number) => row - 2; + const a = { col: 4, absoluteRow: 5 }; + const b = { col: 1, absoluteRow: 7 }; + const forwards = { startCol: 4, startRow: 3, endCol: 1, endRow: 5 }; + expect(orderedViewportRange(a, b, down)).toEqual(forwards); + expect(orderedViewportRange(b, a, down)).toEqual(forwards); + // On one row, by column + const left = { col: 1, absoluteRow: 5 }; + expect(orderedViewportRange(a, left, down)).toEqual({ + startCol: 1, + startRow: 3, + endCol: 4, + endRow: 3, + }); + }); + + // [dx, dy, cell width, past]: the threshold is half a cell width + test.each([ + [0, 0, 8, false], + [3, 0, 8, false], + [4, 0, 8, true], + [3, 3, 8, true], + [0, -5, 8, true], + ])('a move of (%p, %p) with %p px cells is past the threshold: %p', (dx, dy, w, past) => { + expect(pastDragThreshold(dx, dy, w)).toBe(past); + }); + + test.each([ + [50, 50, false], + [10, 20, false], + [90, 100, false], + [9, 50, true], + [91, 50, true], + [50, 19, true], + [50, 101, true], + ])('(%p, %p) is outside the rectangle: %p', (x, y, outside) => { + expect(outsideRect(RECT, x, y)).toBe(outside); + }); + + test.each([ + [50, 50, { offsetX: 40, offsetY: 30 }], + [0, 0, { offsetX: 0, offsetY: 0 }], + [200, 300, { offsetX: 80, offsetY: 80 }], + ])('(%p, %p) clamped into the rectangle is %j', (x, y, offset) => { + expect(clampedOffset(RECT, x, y)).toEqual(offset); + }); + + test.each([ + [19, -1], + [20, 0], + [100, 0], + [101, 1], + ])('a pointer at y %p is past the rectangle by %p', (y, direction) => { + expect(verticalOverflow(RECT, y)).toBe(direction as -1 | 0 | 1); + }); +}); diff --git a/src/client/vendor/tests/selection/selection-gestures.test.ts b/src/client/vendor/tests/selection/selection-gestures.test.ts new file mode 100644 index 0000000..8bfb892 --- /dev/null +++ b/src/client/vendor/tests/selection/selection-gestures.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { RecordingTarget } from '../../../test-support/recording-target'; +import type { ScreenRange } from '../../ghostty-web/engine/engine-types'; +import { AUTO_SCROLL_INTERVAL_MS } from '../../ghostty-web/selection/auto-scroll'; +import type { BufferPoint } from '../../ghostty-web/selection/selection-coords'; +import { + type GestureHost, + SelectionGestures, +} from '../../ghostty-web/selection/selection-gestures'; +import { CELL, FakeCanvas, fire } from '../support/selection-fixture'; + +// The gestures on a fake host: drags inside and outside the canvas, the auto-scroll that +// extends a drag, multi-clicks, outside clicks and teardown. Rows are absolute; the host +// maps pixel row r to absolute row r + top. + +/** A 10x4 grid at client (100, 50), whose host keeps the selection and logs every call. */ +function gestures(options: { appTakes?: boolean; range?: ScreenRange | null } = {}) { + const canvas = new FakeCanvas({ left: 100, top: 50, right: 180, bottom: 114 }); + const doc = new RecordingTarget('document'); + const clock = new ManualClock(); + const log: string[] = []; + const view = { top: 20, bottom: 23, lastCol: 9 }; + let start: BufferPoint | null = null; + let end: BufferPoint | null = null; + let made = 0; + const g: SelectionGestures = new SelectionGestures( + canvas as unknown as HTMLCanvasElement, + doc as unknown as Document, + { + pointAt: (x, y) => ({ + col: Math.min(9, Math.floor(x / CELL.width)), + absoluteRow: view.top + Math.min(3, Math.floor(y / CELL.height)), + }), + cellWidth: () => CELL.width, + appTakesPress: () => options.appTakes ?? false, + clickRange: (clicks, at) => { + log.push(`clickRange ${clicks} ${at.col},${at.absoluteRow}`); + return options.range === undefined ? null : options.range; + }, + scrollLines: (amount) => { + log.push(`scroll ${amount}`); + view.top += amount; + view.bottom += amount; + }, + visibleRows: () => ({ ...view }), + hasSelection: () => start !== null && !g.pressPending, + clearSelection: () => { + log.push('clear'); + start = end = null; + g.endDrag(); + }, + select: (s, e) => { + start = s; + end = e; + }, + selectionEnd: () => end, + extendTo: (e) => { + end = e; + }, + selectionMade: () => void made++, + } satisfies GestureHost, + clock, + ); + return { + g, + canvas, + doc, + clock, + log, + selection: () => (start && end ? [start, end] : null), + made: () => made, + /** Forgets the selection without ending the drag. */ + drop: () => { + start = end = null; + }, + }; +} + +type Gestures = ReturnType<typeof gestures>; + +/** A press at cell (col, row) of the canvas. */ +const press = (t: Gestures, col: number, row: number, init: object = {}) => + fire(t.canvas, 'mousedown', { button: 0, offsetX: col * 8 + 1, offsetY: row * 16 + 4, ...init }); + +/** A move over the document at a client position. */ +const moveTo = (t: Gestures, clientX: number, clientY: number) => + fire(t.doc, 'mousemove', { clientX, clientY }); + +describe('SelectionGestures press and drag', () => { + test('a left press focuses the container and anchors a pending selection', () => { + const t = gestures(); + press(t, 2, 1); + expect(t.canvas.parentElement.focused).toBe(1); + expect(t.g.pressPending).toBe(true); + expect(t.selection()).toEqual([ + { col: 2, absoluteRow: 21 }, + { col: 2, absoluteRow: 21 }, + ]); + }); + + test('a right press does nothing; a press the app takes focuses but selects nothing', () => { + const right = gestures(); + press(right, 2, 1, { button: 2 }); + expect(right.canvas.parentElement.focused).toBe(0); + expect(right.selection()).toBeNull(); + const app = gestures({ appTakes: true }); + press(app, 2, 1); + expect(app.canvas.parentElement.focused).toBe(1); + expect(app.selection()).toBeNull(); + }); + + test('a new press clears the selection there was', () => { + const t = gestures(); + press(t, 0, 0); + fire(t.canvas, 'mousemove', { offsetX: 40, offsetY: 4 }); + fire(t.doc, 'mouseup'); + press(t, 3, 2); + expect(t.log).toEqual(['clear']); + }); + + test('a canvas move under half a cell is ignored; past it the end follows', () => { + const t = gestures(); + press(t, 0, 0); + fire(t.canvas, 'mousemove', { offsetX: 4, offsetY: 4 }); + expect(t.g.pressPending).toBe(true); + fire(t.canvas, 'mousemove', { offsetX: 33, offsetY: 36 }); + expect(t.g.pressPending).toBe(false); + expect(t.selection()?.[1]).toEqual({ col: 4, absoluteRow: 22 }); + // Once a drag, small moves count + fire(t.canvas, 'mousemove', { offsetX: 34, offsetY: 4 }); + expect(t.selection()?.[1]).toEqual({ col: 4, absoluteRow: 20 }); + }); + + test('a canvas move with no press does nothing', () => { + const t = gestures(); + fire(t.canvas, 'mousemove', { offsetX: 40, offsetY: 40 }); + moveTo(t, 0, 0); + fire(t.doc, 'mouseup'); + expect(t.selection()).toBeNull(); + expect(t.clock.pending.timers).toBe(0); + }); + + test('a release after a drag reports the selection; a click without a drag clears it', () => { + const dragged = gestures(); + press(dragged, 0, 0); + fire(dragged.canvas, 'mousemove', { offsetX: 40, offsetY: 4 }); + fire(dragged.doc, 'mouseup'); + expect(dragged.made()).toBe(1); + const clicked = gestures(); + press(clicked, 0, 0); + fire(clicked.doc, 'mouseup'); + expect(clicked.log).toEqual(['clear']); + expect(clicked.made()).toBe(0); + }); + + test('a drag near the bottom edge scrolls and extends to the last visible cell', () => { + const t = gestures(); + press(t, 0, 0); + fire(t.canvas, 'mousemove', { offsetX: 40, offsetY: 60 }); + t.clock.advance(AUTO_SCROLL_INTERVAL_MS); + expect(t.log).toEqual(['scroll 3']); + expect(t.selection()?.[1]).toEqual({ col: 9, absoluteRow: 26 }); + // Back in the middle the scrolling stops + fire(t.canvas, 'mousemove', { offsetX: 40, offsetY: 32 }); + expect(t.clock.pending.timers).toBe(0); + }); +}); + +describe('SelectionGestures outside the canvas', () => { + test('a drag above the canvas scrolls up and extends to the top-left visible cell', () => { + const t = gestures(); + press(t, 5, 2); + moveTo(t, 140, 10); + t.clock.advance(AUTO_SCROLL_INTERVAL_MS * 2); + expect(t.log).toEqual(['scroll -3', 'scroll -3']); + expect(t.selection()?.[1]).toEqual({ col: 0, absoluteRow: 14 }); + }); + + test('a drag beside the canvas follows the pointer clamped to its edge, without scrolling', () => { + const t = gestures(); + press(t, 5, 2); + moveTo(t, 400, 70); + expect(t.clock.pending.timers).toBe(0); + expect(t.selection()?.[1]).toEqual({ col: 9, absoluteRow: 21 }); + moveTo(t, 0, 70); + expect(t.selection()?.[1]).toEqual({ col: 0, absoluteRow: 21 }); + }); + + test('a document move inside the canvas or under the threshold changes nothing', () => { + const t = gestures(); + press(t, 5, 2); + // Over the press point (client 141, 86): under the threshold + moveTo(t, 142, 86); + expect(t.g.pressPending).toBe(true); + // Inside the canvas: the canvas's own mousemove handles it + moveTo(t, 170, 100); + expect(t.g.pressPending).toBe(false); + expect(t.selection()?.[1]).toEqual({ col: 5, absoluteRow: 22 }); + }); + + test('leaving the canvas mid-drag scrolls toward that side; entering it stops', () => { + const t = gestures(); + fire(t.canvas, 'mouseleave', { clientY: 0 }); + expect(t.clock.pending.timers).toBe(0); + press(t, 5, 2); + fire(t.canvas, 'mouseleave', { clientY: 80 }); + expect(t.clock.pending.timers).toBe(0); + fire(t.canvas, 'mouseleave', { clientY: 200 }); + t.clock.advance(AUTO_SCROLL_INTERVAL_MS); + expect(t.log).toEqual(['scroll 3']); + fire(t.canvas, 'mouseenter'); + expect(t.clock.pending.timers).toBe(0); + fire(t.canvas, 'mouseleave', { clientY: 0 }); + fire(t.doc, 'mouseup'); + expect(t.clock.pending.timers).toBe(0); + }); + + test('a scroll step after the drag ended stops the auto-scroll', () => { + const t = gestures(); + press(t, 5, 2); + moveTo(t, 140, 200); + t.g.endDrag(); + t.clock.advance(AUTO_SCROLL_INTERVAL_MS); + expect(t.log).toEqual([]); + expect(t.clock.pending.timers).toBe(0); + }); + + test('a scroll step with no selection only scrolls', () => { + const t = gestures(); + press(t, 5, 2); + moveTo(t, 140, 200); + // The host lost its selection while the drag goes on + t.drop(); + t.clock.advance(AUTO_SCROLL_INTERVAL_MS); + expect(t.log).toEqual(['scroll 3']); + expect(t.selection()).toBeNull(); + }); +}); + +describe('SelectionGestures clicks', () => { + const word: ScreenRange = { start: { col: 1, row: 20 }, end: { col: 4, row: 20 } }; + + test('a double click selects the range the host gives and reports it', () => { + const t = gestures({ range: word }); + fire(t.canvas, 'click', { detail: 2, offsetX: 17, offsetY: 4 }); + expect(t.log).toEqual(['clickRange 2 2,20']); + expect(t.selection()).toEqual([ + { col: 1, absoluteRow: 20 }, + { col: 4, absoluteRow: 20 }, + ]); + expect(t.made()).toBe(1); + }); + + test('a single click, a click the app takes, or no range selects nothing', () => { + const single = gestures({ range: word }); + fire(single.canvas, 'click', { detail: 1, offsetX: 17, offsetY: 4 }); + const app = gestures({ range: word, appTakes: true }); + fire(app.canvas, 'click', { detail: 3, offsetX: 17, offsetY: 4 }); + const none = gestures({ range: null }); + fire(none.canvas, 'click', { detail: 3, offsetX: 17, offsetY: 4 }); + expect([single, app, none].map((t) => t.made())).toEqual([0, 0, 0]); + expect(none.log).toEqual(['clickRange 3 2,20']); + }); + + test('a click outside the canvas clears the selection', () => { + const t = gestures({ range: word }); + fire(t.canvas, 'click', { detail: 2, offsetX: 17, offsetY: 4 }); + const elsewhere = {}; + fire(t.doc, 'mousedown', { target: elsewhere }); + fire(t.doc, 'click', { target: elsewhere }); + expect(t.selection()).toBeNull(); + }); + + test('a click inside the canvas, or ending a drag begun inside, keeps the selection', () => { + const t = gestures({ range: word }); + fire(t.canvas, 'click', { detail: 2, offsetX: 17, offsetY: 4 }); + fire(t.doc, 'mousedown', { target: t.canvas }); + fire(t.doc, 'click', { target: {} }); + const inner = {}; + t.canvas.inside.add(inner); + fire(t.doc, 'mousedown', { target: {} }); + fire(t.doc, 'click', { target: inner }); + expect(t.log).not.toContain('clear'); + }); + + test('an outside click during a drag, or with nothing selected, clears nothing', () => { + const t = gestures(); + press(t, 0, 0); + fire(t.doc, 'click', { target: {} }); + expect(t.log).toEqual([]); + fire(t.doc, 'mouseup'); + t.log.length = 0; + fire(t.doc, 'click', { target: {} }); + expect(t.log).toEqual([]); + }); +}); + +describe('SelectionGestures dispose', () => { + test('removes the document move, release and click listeners and stops scrolling, once', () => { + const t = gestures(); + expect(t.doc.added.map((r) => r.type)).toEqual(['mousemove', 'mousedown', 'mouseup', 'click']); + press(t, 0, 0); + moveTo(t, 140, 200); + t.g.dispose(); + expect(t.clock.pending.timers).toBe(0); + expect(t.doc.removed.map((r) => r.type).sort()).toEqual(['click', 'mousemove', 'mouseup']); + // The canvas listeners and the document mousedown leak until ADR 065 + expect(t.canvas.active).toHaveLength(5); + t.g.dispose(); + expect(t.doc.removed).toHaveLength(3); + }); +}); diff --git a/src/client/vendor/tests/selection/selection-manager.test.ts b/src/client/vendor/tests/selection/selection-manager.test.ts new file mode 100644 index 0000000..fcc927d --- /dev/null +++ b/src/client/vendor/tests/selection/selection-manager.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { Ghostty, GhosttyTerminal } from '../../ghostty-web/ghostty'; +import { atCell, fire, selectionFixture } from '../support/selection-fixture'; +import { loadGhostty } from '../support/wasm'; + +// SelectionManager over the real engine: copy on select, Ghostty's selected text, the +// gestures' absolute rows across scrolling and output, the context menu and dispose. + +describe('SelectionManager copy on select', () => { + let wasmTerm: GhosttyTerminal; + + beforeEach(async () => { + const ghostty = await loadGhostty(); + wasmTerm = ghostty.createTerminal(20, 4); + wasmTerm.write('hello world\r\nsecond line'); + }); + + afterEach(() => { + wasmTerm.free(); + }); + + /** A manager over the wasm terminal, counting the times it builds the selected text. */ + function manager(copyOnSelect: boolean) { + const f = selectionFixture(wasmTerm, { copyOnSelect }); + let builds = 0; + const getSelection = f.m.getSelection.bind(f.m); + f.m.getSelection = () => { + builds++; + return getSelection(); + }; + return { ...f, builds: () => builds }; + } + + /** Drags across "hello" on row 0 and releases. */ + function drag(f: ReturnType<typeof manager>) { + fire(f.canvas, 'mousedown', { button: 0, offsetX: 1, offsetY: 4 }); + fire(f.canvas, 'mousemove', { offsetX: 4 * 8 + 1, offsetY: 4 }); + fire(f.doc, 'mouseup'); + } + + test('a drag copies its text once, building it once', () => { + const f = manager(true); + drag(f); + expect(f.writes).toEqual(['hello']); + expect(f.builds()).toBe(1); + expect(f.changes()).toBe(1); + }); + + test('a double and a triple click copy once each', () => { + const f = manager(true); + fire(f.canvas, 'click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); + fire(f.canvas, 'click', { detail: 3, offsetX: 8, offsetY: 20 }); + expect(f.writes).toEqual(['world', 'second line']); + }); + + // The terminal's scrollLines is reached only from here, so a call graph from + // src/client/index.ts does not show it. + test('a drag below the terminal scrolls it down through scrollLines', () => { + const f = manager(true); + fire(f.canvas, 'mousedown', { button: 0, offsetX: 1, offsetY: 4 }); + fire(f.doc, 'mousemove', { clientX: 40, clientY: 100 }); + f.clock.advance(80); + fire(f.doc, 'mouseup'); + expect(f.scrolls).toEqual([3]); + // The release stopped the interval + expect(f.clock.pending.timers).toBe(0); + }); + + // While the app tracks the mouse, a press is the app's; Shift selects (ADR 051) + test('with mouse tracking on, a drag or a double click selects and copies nothing', () => { + wasmTerm.write('\x1b[?1000h'); + const f = manager(true); + drag(f); + fire(f.canvas, 'click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); + fire(f.canvas, 'click', { detail: 3, offsetX: 8, offsetY: 20 }); + expect(f.m.hasSelection()).toBe(false); + expect(f.m.getSelectionRange()).toBeNull(); + expect(f.writes).toEqual([]); + expect(f.changes()).toBe(0); + }); + + test('with mouse tracking on, Shift drag, double and triple click select and copy', () => { + wasmTerm.write('\x1b[?1002h\x1b[?1006h'); + const f = manager(true); + fire(f.canvas, 'mousedown', { button: 0, offsetX: 1, offsetY: 4, shiftKey: true }); + fire(f.canvas, 'mousemove', { offsetX: 4 * 8 + 1, offsetY: 4, shiftKey: true }); + fire(f.doc, 'mouseup'); + fire(f.canvas, 'click', { detail: 2, offsetX: 8 * 7, offsetY: 4, shiftKey: true }); + fire(f.canvas, 'click', { detail: 3, offsetX: 8, offsetY: 20, shiftKey: true }); + expect(f.writes).toEqual(['hello', 'world', 'second line']); + expect(f.m.hasSelection()).toBe(false); + }); + + test('a copied selection is cleared, as Claude Code and pi clear theirs', () => { + const f = manager(true); + drag(f); + expect(f.writes).toEqual(['hello']); + expect(f.m.hasSelection()).toBe(false); + expect(f.m.getSelectionRange()).toBeNull(); + expect(f.changes()).toBe(1); + }); + + test('a press the app takes leaves the current selection', () => { + const f = manager(false); + drag(f); + wasmTerm.write('\x1b[?1000h'); + fire(f.canvas, 'mousedown', { button: 0, offsetX: 8 * 7, offsetY: 20 }); + fire(f.doc, 'mouseup'); + expect(f.m.getSelection()).toBe('hello'); + }); + + test('nothing is copied or built with copyOnSelect off, but the change is reported', () => { + const f = manager(false); + drag(f); + fire(f.canvas, 'click', { detail: 2, offsetX: 8 * 7, offsetY: 4 }); + fire(f.canvas, 'click', { detail: 3, offsetX: 8, offsetY: 20 }); + expect(f.writes).toEqual([]); + expect(f.builds()).toBe(0); + expect(f.changes()).toBe(3); + // The selection stays, to copy with Cmd+C, Ctrl+Shift+C or the menu. + expect(f.m.hasSelection()).toBe(true); + }); + + test('copySelection copies the selection and reports whether there was one', () => { + const f = manager(false); + expect(f.m.copySelection()).toBe(false); + drag(f); + expect(f.m.copySelection()).toBe(true); + expect(f.writes).toEqual(['hello']); + // A selection of blank cells has no text to copy + fire(f.canvas, 'mousedown', { button: 0, ...atCell(15, 3) }); + fire(f.canvas, 'mousemove', atCell(18, 3)); + fire(f.doc, 'mouseup'); + expect(f.m.hasSelection()).toBe(true); + expect(f.m.copySelection()).toBe(false); + }); + + test('a right-click menu holds the selected text, or none', () => { + const f = manager(false); + f.m.prepareContextMenu({ clientX: 5, clientY: 6 } as MouseEvent); + expect(f.textarea.value).toBe(''); + drag(f); + f.m.prepareContextMenu({ clientX: 5, clientY: 6 } as MouseEvent); + expect(f.textarea.value).toBe('hello'); + expect(f.textarea.style.left).toBe('5px'); + }); +}); + +describe('SelectionManager dispose', () => { + test('removes the document listeners it added, stops the auto-scroll, and runs once', async () => { + const ghostty = await loadGhostty(); + const t = ghostty.createTerminal(10, 2); + const f = selectionFixture(t); + fire(f.canvas, 'mousedown', { button: 0, offsetX: 1, offsetY: 4 }); + fire(f.doc, 'mousemove', { clientX: 40, clientY: 100 }); + expect(f.clock.pending.timers).toBe(1); + f.m.dispose(); + expect(f.clock.pending.timers).toBe(0); + // The document mousedown and the canvas listeners leak until ADR 065 + expect(f.doc.active.map((r) => r.type)).toEqual(['mousedown']); + expect(f.canvas.active.map((r) => r.type)).toEqual([ + 'mousedown', + 'mousemove', + 'mouseleave', + 'mouseenter', + 'click', + ]); + const removed = f.doc.removed.length; + f.m.dispose(); + expect(f.doc.removed.length).toBe(removed); + t.free(); + }); +}); + +describe("The selected text of wide characters (Ghostty's selectionText)", () => { + let ghostty: Ghostty; + + beforeEach(async () => { + ghostty = await loadGhostty(); + }); + + /** The text a selection from (startCol, startRow) to (endCol, endRow) copies; rows are absolute. */ + function selected( + wasmTerm: GhosttyTerminal, + start: [number, number], + end: [number, number], + ): string { + // getSelection passes its absolute rows to Ghostty's selectionText as they are + return wasmTerm.selectionText({ col: start[0], row: start[1] }, { col: end[0], row: end[1] }); + } + + // Emoji with a skin tone, with VS16 (U+2764 U+FE0F) and a ZWJ family, and combining marks + test('copies no space for the spacer after a CJK character or an emoji', () => { + const t = ghostty.createTerminal(30, 4); + t.write( + '中文字 ok\r\n\u{1f44d}\u{1f3fd} \u2764\ufe0f \u{1f468}\u200d\u{1f469}\u200d\u{1f467} x\r\ne\u0301 a\u20dd', + ); + expect(selected(t, [0, 0], [29, 2])).toBe( + '中文字 ok\n\u{1f44d}\u{1f3fd} \u2764\ufe0f \u{1f468}\u200d\u{1f469}\u200d\u{1f467} x\ne\u0301 a\u20dd', + ); + // A selection that starts or ends on the right half of a wide character + expect(selected(t, [1, 0], [3, 0])).toBe('中文'); + t.free(); + }); + + test('copies no space for a spacer at the last column, and joins the soft wrap', () => { + // A wide character that does not fit wraps, leaving a spacer at the last column. + // Ghostty joins soft-wrapped rows without a newline (ADR 050). + const wrapped = ghostty.createTerminal(5, 4); + wrapped.write('abcd中ef'); + expect(selected(wrapped, [0, 0], [4, 1])).toBe('abcd中ef'); + expect(selected(wrapped, [2, 0], [3, 0])).toBe('cd'); + // Ending on the spacer takes the wide character it stands for (Ghostty's formatter) + expect(selected(wrapped, [2, 0], [4, 0])).toBe('cd中'); + wrapped.free(); + // A wide character ending at the last column + const fits = ghostty.createTerminal(6, 4); + fits.write('abcd中ef'); + expect(selected(fits, [3, 0], [1, 1])).toBe('d中ef'); + fits.free(); + }); + + test('copies scrollback rows without spacers', () => { + const t = ghostty.createTerminal(10, 2); + t.write('中文 1\r\n日本語 2\r\nlast\r\nrow'); + expect(t.getScrollbackLength()).toBe(2); + expect(selected(t, [0, 0], [9, 3])).toBe('中文 1\n日本語 2\nlast\nrow'); + t.free(); + }); +}); + +// Behaviour the selection keeps on any engine API: mouse gestures over rows, absolute +// rows across scrolling and output, and the word and line a click picks. +describe('SelectionManager gestures', () => { + let ghostty: Ghostty; + + beforeEach(async () => { + ghostty = await loadGhostty(); + }); + + /** + * A manager over `wasmTerm` with 8x16 cells, scrolled up `viewportY()` lines. copyOnSelect + * is off, so the selection a gesture makes stays to be read (on, it is copied and cleared). + */ + function manager(wasmTerm: GhosttyTerminal, viewportY: () => number = () => 0) { + return selectionFixture(wasmTerm, { viewportY }); + } + + type Fixture = ReturnType<typeof manager>; + + /** Drags from cell (c0, r0) to cell (c1, r1), in viewport rows, and releases. */ + function drag(f: Fixture, c0: number, r0: number, c1: number, r1: number) { + fire(f.canvas, 'mousedown', { button: 0, ...atCell(c0, r0) }); + fire(f.canvas, 'mousemove', atCell(c1, r1)); + fire(f.doc, 'mouseup'); + } + + const click = (f: Fixture, detail: number, col: number, row: number) => + fire(f.canvas, 'click', { detail, ...atCell(col, row) }); + + test('a drag backwards selects the same text as forwards', () => { + const t = ghostty.createTerminal(20, 4); + t.write('hello world\r\nsecond line'); + const f = manager(t); + drag(f, 5, 1, 6, 0); + expect(f.m.getSelection()).toBe('world\nsecond'); + expect(f.m.getSelectionRange()).toEqual({ startCol: 6, startRow: 0, endCol: 5, endRow: 1 }); + t.free(); + }); + + test('a drag while scrolled selects scrollback rows, which stay selected at the bottom', () => { + const t = ghostty.createTerminal(10, 2); + t.write('one\r\ntwo\r\nthree\r\nfour'); + let viewportY = 2; + const f = manager(t, () => viewportY); + drag(f, 0, 0, 4, 1); + expect(f.m.getSelection()).toBe('one\ntwo'); + viewportY = 0; + expect(f.m.getSelection()).toBe('one\ntwo'); + expect(f.m.getSelectionRange()).toEqual({ startCol: 0, startRow: -2, endCol: 4, endRow: -1 }); + t.free(); + }); + + test('a selection keeps its text while output arrives and scrolls it into history', () => { + const t = ghostty.createTerminal(10, 3); + t.write('keep me\r\n'); + const f = manager(t); + drag(f, 0, 0, 6, 0); + t.write('a\r\nb\r\nc\r\nd'); + expect(t.getScrollbackLength()).toBe(2); + expect(f.m.getSelection()).toBe('keep me'); + expect(f.m.getSelectionRange()).toEqual({ startCol: 0, startRow: -2, endCol: 6, endRow: -2 }); + t.free(); + }); + + test('a double click selects a word with path and address characters', () => { + const t = ghostty.createTerminal(70, 2); + t.write('ls /usr/local/bin/foo.txt a@b.c x+y ~/q "quoted" (paren) $HOME'); + const f = manager(t); + const word = (col: number) => { + click(f, 2, col, 0); + return f.m.getSelection(); + }; + expect(word(8)).toBe('/usr/local/bin/foo.txt'); + expect(word(27)).toBe('a@b.c'); + expect(word(33)).toBe('x+y'); + expect(word(37)).toBe('~/q'); + expect(word(43)).toBe('quoted'); + expect(word(52)).toBe('paren'); + expect(word(59)).toBe('HOME'); + t.free(); + }); + + test('a double click on an empty cell selects nothing', () => { + const t = ghostty.createTerminal(20, 2); + t.write('word'); + const f = manager(t); + click(f, 2, 15, 0); + expect(f.m.hasSelection()).toBe(false); + expect(f.writes).toEqual([]); + t.free(); + }); + + test('a triple click selects the line without trailing spaces; an empty line selects nothing', () => { + const t = ghostty.createTerminal(20, 3); + t.write('one two \r\n\r\nthree'); + const f = manager(t); + click(f, 3, 15, 1); + expect(f.m.hasSelection()).toBe(false); + click(f, 3, 15, 0); + expect(f.m.getSelection()).toBe('one two'); + click(f, 3, 0, 2); + expect(f.m.getSelection()).toBe('three'); + t.free(); + }); + + test('a triple click in scrollback selects that line', () => { + const t = ghostty.createTerminal(10, 2); + t.write('first\r\nsecond\r\nthird'); + const f = manager(t, () => 1); + click(f, 3, 2, 0); + expect(f.m.getSelection()).toBe('first'); + t.free(); + }); + + test('a triple click with Ctrl or Cmd selects the command output; nothing outside it', () => { + const t = ghostty.createTerminal(20, 6); + t.write('\x1b]133;A\x07$ \x1b]133;B\x07ls\r\n\x1b]133;C\x07a.txt\r\nb.txt\r\n'); + t.write('\x1b]133;D;0\x07\x1b]133;A\x07$ '); + const f = manager(t); + const output = (row: number, mods: object) => + fire(f.canvas, 'click', { detail: 3, offsetX: 1, offsetY: row * 16 + 4, ...mods }); + output(2, { ctrlKey: true }); + expect(f.m.getSelection()).toBe('a.txt\nb.txt'); + f.m.clearSelection(); + output(1, { metaKey: true }); + expect(f.m.getSelection()).toBe('a.txt\nb.txt'); + output(1, {}); + expect(f.m.getSelection()).toBe('a.txt'); + f.m.clearSelection(); + output(3, { ctrlKey: true }); + expect(f.m.hasSelection()).toBe(false); + t.free(); + }); + + // Ghostty's selection rules (ADR 050) + + test('a selection across a soft wrap copies one line; a hard newline stays', () => { + const t = ghostty.createTerminal(10, 4); + t.write('0123456789abcdef\r\nnext'); + const f = manager(t); + drag(f, 0, 0, 3, 2); + expect(f.m.getSelection()).toBe('0123456789abcdef\nnext'); + t.free(); + }); + + test('a double click selects a word that wraps onto the next row', () => { + const t = ghostty.createTerminal(10, 3); + t.write('cd /usr/local/bin'); + const f = manager(t); + click(f, 2, 1, 1); + expect(f.m.getSelection()).toBe('/usr/local/bin'); + expect(f.m.getSelectionRange()).toEqual({ startCol: 3, startRow: 0, endCol: 6, endRow: 1 }); + t.free(); + }); + + test("a double click selects a URL query and non-ASCII words by Ghostty's word characters", () => { + const t = ghostty.createTerminal(40, 2); + t.write('see //x.io/a?b=c&d#e or café 中文字'); + const f = manager(t); + click(f, 2, 8, 0); + expect(f.m.getSelection()).toBe('//x.io/a?b=c&d#e'); + click(f, 2, 24, 0); + expect(f.m.getSelection()).toBe('café'); + click(f, 2, 30, 0); + expect(f.m.getSelection()).toBe('中文字'); + t.free(); + }); + + test('a triple click selects a soft-wrapped line whole, without its indent', () => { + const t = ghostty.createTerminal(8, 3); + t.write(' wrapped line\r\nx'); + const f = manager(t); + click(f, 3, 0, 1); + expect(f.m.getSelection()).toBe('wrapped line'); + expect(f.m.getSelectionRange()).toEqual({ startCol: 2, startRow: 0, endCol: 5, endRow: 1 }); + t.free(); + }); +}); diff --git a/src/client/vendor/tests/support/selection-fixture.ts b/src/client/vendor/tests/support/selection-fixture.ts new file mode 100644 index 0000000..9a6b3e6 --- /dev/null +++ b/src/client/vendor/tests/support/selection-fixture.ts @@ -0,0 +1,133 @@ +// A SelectionManager on fakes (test fake, ADR 064): a recording canvas and document, a +// clipboard that keeps what is written, and a manual clock for the auto-scroll, all +// injected, so selection tests dispatch mouse events by hand and stub no globals. + +import { ManualClock } from '../../../test-support/manual-clock'; +import { RecordingTarget } from '../../../test-support/recording-target'; +import type { SelectionEngineSource, SelectionHost } from '../../ghostty-web/core/host-types'; +import type { ClipboardEnv } from '../../ghostty-web/selection/clipboard-copy'; +import type { ClientRect } from '../../ghostty-web/selection/selection-coords'; +import { SelectionManager } from '../../ghostty-web/selection-manager'; + +/** Cell size of every fixture canvas, in CSS pixels. */ +export const CELL = { width: 8, height: 16 }; + +/** A canvas: a recording target with a client rectangle and a focusable parent. */ +export class FakeCanvas extends RecordingTarget { + readonly parentElement = { focused: 0, focus: () => void this.parentElement.focused++ }; + /** Nodes `contains` says are inside the canvas, besides itself. */ + readonly inside = new Set<unknown>(); + + constructor( + readonly rect: ClientRect, + name = 'canvas', + ) { + super(name); + } + + get clientHeight(): number { + return this.rect.bottom - this.rect.top; + } + + getBoundingClientRect(): ClientRect { + return this.rect; + } + + contains(node: unknown): boolean { + return node === this || this.inside.has(node); + } +} + +/** A textarea that records its listeners and the focus and selection calls made on it. */ +export class FakeTextarea extends RecordingTarget { + value = ''; + readonly style: Record<string, string> = {}; + readonly calls: string[] = []; + + constructor() { + super('textarea'); + } + + focus(): void { + this.calls.push('focus'); + } + + select(): void { + this.calls.push('select'); + } + + setSelectionRange(start: number, end: number): void { + this.calls.push(`range ${start}-${end}`); + } + + /** Typed as the element the code under test takes. */ + get element(): HTMLTextAreaElement { + return this as unknown as HTMLTextAreaElement; + } +} + +export interface SelectionFixtureOptions { + copyOnSelect?: boolean; + /** Lines scrolled into history; 0 by default. */ + viewportY?: () => number; +} + +/** A manager over `engine`, sized to it, with every collaborator a fake the test reads. */ +export function selectionFixture( + engine: SelectionEngineSource & { cols: number; rows: number }, + options: SelectionFixtureOptions = {}, +) { + const canvas = new FakeCanvas({ + left: 0, + top: 0, + right: engine.cols * CELL.width, + bottom: engine.rows * CELL.height, + }); + const doc = new RecordingTarget('document'); + const clock = new ManualClock(); + const writes: string[] = []; + const scrolls: number[] = []; + const host: SelectionHost = { + cols: engine.cols, + rows: engine.rows, + options: { copyOnSelect: options.copyOnSelect ?? false }, + getViewportY: options.viewportY ?? (() => 0), + scrollLines: (amount) => void scrolls.push(amount), + }; + const textarea = new FakeTextarea(); + const clipboard: ClipboardEnv = { + clipboard: { writeText: async (text: string) => void writes.push(text) }, + document: { activeElement: null, execCommand: () => true }, + textarea: textarea.element, + warn: () => {}, + }; + const m = new SelectionManager( + host, + { + getCanvas: () => canvas as unknown as HTMLCanvasElement, + getMetrics: () => CELL, + scrollOffset: 0, + }, + engine, + textarea.element, + { + document: doc as unknown as Document, + timers: clock, + setTimeout: clock.setTimeout, + clipboard: () => clipboard, + }, + ); + let changes = 0; + m.onSelectionChange(() => changes++); + return { m, canvas, doc, textarea, clock, writes, scrolls, changes: () => changes }; +} + +/** Dispatches a mouse event of `type` with `init` on a recording target. */ +export function fire(target: RecordingTarget, type: string, init: object = {}): void { + target.dispatch({ type, ...init }); +} + +/** The event fields of a press or move at cell (col, row), a few pixels into the cell. */ +export function atCell(col: number, row: number): { offsetX: number; offsetY: number } { + return { offsetX: col * CELL.width + 1, offsetY: row * CELL.height + 4 }; +} From db7fbd36b1f10d10148cb92f14a1e640b9f37565 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:25:26 -0400 Subject: [PATCH 247/295] refactor(client): one link activation helper for both link providers The OSC 8 and URL regex providers each opened an activated link with the same Ctrl/Cmd check and window.open call; both now call providers/open-link.ts openLinkOnModifier, with the opener injected (window.open by default, read at activation). The providers import the link contract from link-types.ts instead of the types.ts shim, and their headers name their origin; the OSC 8 header no longer claims multi-row links (each link covers one row). Vendor link tests move to vendor/tests/links/ and gain the providers' tests: every scheme, trailing punctuation, the minimum length, the regex lastIndex between rows, control characters, the screen and scrollback URI lookups, and activation. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/providers/open-link.ts | 21 +++ .../providers/osc8-link-provider.ts | 41 +++--- .../providers/url-regex-provider.ts | 30 ++-- .../tests/{ => links}/link-detector.test.ts | 4 +- .../vendor/tests/links/open-link.test.ts | 36 +++++ .../tests/links/osc8-link-provider.test.ts | 115 ++++++++++++++++ .../tests/links/url-regex-provider.test.ts | 128 ++++++++++++++++++ 8 files changed, 332 insertions(+), 44 deletions(-) create mode 100644 src/client/vendor/ghostty-web/providers/open-link.ts rename src/client/vendor/tests/{ => links}/link-detector.test.ts (96%) create mode 100644 src/client/vendor/tests/links/open-link.test.ts create mode 100644 src/client/vendor/tests/links/osc8-link-provider.test.ts create mode 100644 src/client/vendor/tests/links/url-regex-provider.test.ts diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index d6575d7..82c3c40 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -133,3 +133,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `selection-manager.ts`: the body of `prepareContextMenu` (now `show`) and its reset closure (now `hideOnMenuClose`), the 10 ms delay (now `CONTEXT_MENU_RESET_DELAY_MS`); the unused `rect` is dropped | `selection/context-menu-textarea.ts` (`ContextMenuTextarea`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | | `selection-manager.ts`: `attachEventListeners` (one method per listener: `canvasDown`, `canvasMove`, `documentMove`, `release`, `multiClick`, `documentClick`), `pressIsApps` (now the host's `appTakesPress`), the drag fields (`isSelecting`, `mouseDownX`/`Y`, `dragThresholdMet`, `mouseDownTarget`) and the listener removal of `dispose`; the no-op `requestRender` is dropped | `selection/selection-gestures.ts` (`SelectionGestures`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | | `selection-manager.ts`: what it reads of `Terminal`, `CanvasRenderer` and the engine through `any` (webtty) | `core/host-types.ts` (`SelectionHost`, `SelectionCanvasSource`, `SelectionEngineSource`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | +| `providers/osc8-link-provider.ts`, `providers/url-regex-provider.ts`: the Ctrl/Cmd check and `window.open` of each link's `activate` (now one `openLinkOnModifier`, with the opener injected) | `providers/open-link.ts` | refactor(client): one link activation helper for both link providers | diff --git a/src/client/vendor/ghostty-web/providers/open-link.ts b/src/client/vendor/ghostty-web/providers/open-link.ts new file mode 100644 index 0000000..df8e8e9 --- /dev/null +++ b/src/client/vendor/ghostty-web/providers/open-link.ts @@ -0,0 +1,21 @@ +// Derived from coder/ghostty-web lib/providers/osc8-link-provider.ts and lib/providers/url-regex-provider.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Link activation (util): the one rule both link providers open a link by, a click with +// Ctrl or Cmd held, in a new tab without an opener (ADR 061). + +/** Opens a URL as `window.open` does; injected so tests see what would open. */ +export type LinkOpener = (url: string, target: string, features: string) => unknown; + +/** The page's `window.open`, read when a link is activated. */ +export const windowOpen: LinkOpener = (url, target, features) => + window.open(url, target, features); + +/** Opens `url` in a new tab when Ctrl or Cmd is held; a plain click opens nothing. */ +export function openLinkOnModifier( + url: string, + event: Pick<MouseEvent, 'ctrlKey' | 'metaKey'>, + open: LinkOpener = windowOpen, +): void { + if (event.ctrlKey || event.metaKey) { + open(url, '_blank', 'noopener,noreferrer'); + } +} diff --git a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts index 4a0d945..a94ba81 100644 --- a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts +++ b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts @@ -1,30 +1,25 @@ -/** - * OSC 8 Hyperlink Provider - * - * Detects hyperlinks created with OSC 8 escape sequences. - * Supports multi-line links that wrap across lines. - * - * OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07 - * - * The Ghostty WASM automatically assigns hyperlink_id to cells, - * so we just need to scan for contiguous regions with the same ID. - */ +// Derived from coder/ghostty-web lib/providers/osc8-link-provider.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// OSC 8 hyperlinks (strategy: an ILinkProvider): runs of cells on a row that carry the +// same hyperlink URI, written as \x1b]8;;URL\x07TEXT\x1b]8;;\x07, read from the screen +// or the scrollback (ADR 061). -import type { IBufferRange, ILink, ILinkProvider } from '../types'; +import type { IBufferRange, ILink, ILinkProvider } from '../link-types'; +import { type LinkOpener, openLinkOnModifier, windowOpen } from './open-link'; /** - * OSC 8 Hyperlink Provider - * - * Detects OSC 8 hyperlinks by scanning for hyperlink_id in cells. - * Automatically handles multi-line links since Ghostty WASM preserves - * hyperlink_id across wrapped lines. + * Detects OSC 8 hyperlinks by scanning a row for cells with a hyperlink and grouping + * neighbours with the same URI. Each link covers one row: a link wrapped onto the next + * row is found again, as its own link, when that row is scanned. */ export class OSC8LinkProvider implements ILinkProvider { - constructor(private terminal: ITerminalForOSC8Provider) {} + /** `open` opens an activated link; the page's `window.open` by default. */ + constructor( + private terminal: ITerminalForOSC8Provider, + private readonly open: LinkOpener = windowOpen + ) {} /** * Provide all OSC 8 links on the given row - * Note: This may return links that span multiple rows */ provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { const links: ILink[] = []; @@ -99,12 +94,8 @@ export class OSC8LinkProvider implements ILinkProvider { links.push({ text: uri, range, - activate: (event) => { - // Open link if Ctrl/Cmd is pressed - if (event.ctrlKey || event.metaKey) { - window.open(uri, '_blank', 'noopener,noreferrer'); - } - }, + // Opens with Ctrl/Cmd held (ADR 061: one rule for both providers) + activate: (event) => openLinkOnModifier(uri, event, this.open), }); } } diff --git a/src/client/vendor/ghostty-web/providers/url-regex-provider.ts b/src/client/vendor/ghostty-web/providers/url-regex-provider.ts index 82dad11..7835634 100644 --- a/src/client/vendor/ghostty-web/providers/url-regex-provider.ts +++ b/src/client/vendor/ghostty-web/providers/url-regex-provider.ts @@ -1,14 +1,10 @@ -/** - * URL Regex Link Provider - * - * Detects plain text URLs using regex pattern matching. - * Supports common protocols but excludes file paths. - * - * This provider runs after OSC8LinkProvider, so explicit hyperlinks - * take precedence over regex-detected URLs. - */ +// Derived from coder/ghostty-web lib/providers/url-regex-provider.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Plain-text URLs (strategy: an ILinkProvider): URLs with a known scheme found on one row +// by a regex, trailing punctuation dropped. It runs after the OSC 8 provider, so explicit +// hyperlinks win (ADR 061). -import type { IBufferRange, ILink, ILinkProvider } from '../types'; +import type { IBufferRange, ILink, ILinkProvider } from '../link-types'; +import { type LinkOpener, openLinkOnModifier, windowOpen } from './open-link'; /** * URL Regex Provider @@ -38,7 +34,11 @@ export class UrlRegexProvider implements ILinkProvider { */ private static readonly TRAILING_PUNCTUATION = /[.,;!?)\]]+$/; - constructor(private terminal: ITerminalForUrlProvider) {} + /** `open` opens an activated link; the page's `window.open` by default. */ + constructor( + private terminal: ITerminalForUrlProvider, + private readonly open: LinkOpener = windowOpen + ) {} /** * Provide all regex-detected URLs on the given row @@ -80,12 +80,8 @@ export class UrlRegexProvider implements ILinkProvider { start: { x: startX, y }, end: { x: endX, y }, }, - activate: (event) => { - // Open link if Ctrl/Cmd is pressed - if (event.ctrlKey || event.metaKey) { - window.open(url, '_blank', 'noopener,noreferrer'); - } - }, + // Opens with Ctrl/Cmd held (ADR 061: one rule for both providers) + activate: (event) => openLinkOnModifier(url, event, this.open), }); } diff --git a/src/client/vendor/tests/link-detector.test.ts b/src/client/vendor/tests/links/link-detector.test.ts similarity index 96% rename from src/client/vendor/tests/link-detector.test.ts rename to src/client/vendor/tests/links/link-detector.test.ts index 4117f02..368467c 100644 --- a/src/client/vendor/tests/link-detector.test.ts +++ b/src/client/vendor/tests/links/link-detector.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { LinkDetector } from '../ghostty-web/link-detector'; -import type { ILink, ILinkProvider } from '../ghostty-web/link-types'; +import { LinkDetector } from '../../ghostty-web/link-detector'; +import type { ILink, ILinkProvider } from '../../ghostty-web/link-types'; // LinkDetector on a fake buffer: the inclusive range hit test, provider precedence, the // row scan cache and dispose. diff --git a/src/client/vendor/tests/links/open-link.test.ts b/src/client/vendor/tests/links/open-link.test.ts new file mode 100644 index 0000000..bd6d2e5 --- /dev/null +++ b/src/client/vendor/tests/links/open-link.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { openLinkOnModifier, windowOpen } from '../../ghostty-web/providers/open-link'; + +// The one link activation rule: Ctrl or Cmd opens a new tab without an opener. + +describe('openLinkOnModifier', () => { + test.each([ + [{ ctrlKey: false, metaKey: false }, []], + [{ ctrlKey: true, metaKey: false }, [['u', '_blank', 'noopener,noreferrer']]], + [{ ctrlKey: false, metaKey: true }, [['u', '_blank', 'noopener,noreferrer']]], + ])('a click with %j opens %j', (event, expected) => { + const opened: string[][] = []; + openLinkOnModifier('u', event, (...args) => opened.push(args)); + expect(opened).toEqual(expected); + }); +}); + +describe('windowOpen', () => { + const g = globalThis as Record<string, unknown>; + const saved = g.window; + + afterEach(() => { + g.window = saved; + }); + + test("calls the page's window.open, read when a link opens", () => { + const calls: unknown[][] = []; + g.window = { open: (...args: unknown[]) => calls.push(args) }; + windowOpen('u', '_blank', 'f'); + openLinkOnModifier('v', { ctrlKey: true, metaKey: false }); + expect(calls).toEqual([ + ['u', '_blank', 'f'], + ['v', '_blank', 'noopener,noreferrer'], + ]); + }); +}); diff --git a/src/client/vendor/tests/links/osc8-link-provider.test.ts b/src/client/vendor/tests/links/osc8-link-provider.test.ts new file mode 100644 index 0000000..55d1bcf --- /dev/null +++ b/src/client/vendor/tests/links/osc8-link-provider.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'bun:test'; +import type { ILink } from '../../ghostty-web/link-types'; +import { + type ITerminalForOSC8Provider, + OSC8LinkProvider, +} from '../../ghostty-web/providers/osc8-link-provider'; + +// OSC8LinkProvider on a fake buffer: hyperlinked runs grouped by URI, the screen and +// scrollback lookups, and activation. + +/** + * A buffer of `uris` rows, one URI (or null for no link) per cell, whose first + * `scrollback` rows are scrollback; lookups are logged. + */ +function terminal(uris: (string | null)[][], scrollback: number, engine = true) { + const log: string[] = []; + const t: ITerminalForOSC8Provider = { + buffer: { + active: { + length: uris.length, + getLine: (y) => + uris[y] && { + length: uris[y].length, + getCell: (x) => + x < uris[y].length ? { getHyperlinkId: () => (uris[y][x] ? 1 : 0) } : undefined, + }, + }, + }, + }; + if (engine) { + t.wasmTerm = { + getScrollbackLength: () => scrollback, + getHyperlinkUri: (row, col) => { + log.push(`screen ${row},${col}`); + return uris[row + scrollback][col]; + }, + getScrollbackHyperlinkUri: (offset, col) => { + log.push(`scrollback ${offset},${col}`); + return uris[offset][col]; + }, + }; + } + return { t, log }; +} + +function provide(t: ITerminalForOSC8Provider, y: number, opened: string[] = []) { + const provider = new OSC8LinkProvider(t, (url) => opened.push(url)); + let found: ILink[] | undefined; + provider.provideLinks(y, (links) => { + found = links; + }); + return found; +} + +const A = 'https://a.example'; +const B = 'https://b.example'; + +describe('OSC8LinkProvider', () => { + test('groups neighbouring cells with the same URI into one link per run', () => { + const { t } = terminal([[null, A, A, B, B, null, A]], 0); + expect(provide(t, 0)?.map((link) => [link.text, link.range.start.x, link.range.end.x])).toEqual( + [ + [A, 1, 2], + [B, 3, 4], + [A, 6, 6], + ], + ); + }); + + test('reads a screen row through the screen lookup, by its viewport row', () => { + const { t, log } = terminal([[null], [A, A]], 1); + expect(provide(t, 1)?.[0].range).toEqual({ start: { x: 0, y: 1 }, end: { x: 1, y: 1 } }); + expect(log).toEqual(['screen 0,0', 'screen 0,1']); + }); + + test('reads a scrollback row through the scrollback lookup, by its buffer row', () => { + const { t, log } = terminal([[B, B], [null]], 1); + expect(provide(t, 0)?.map((link) => link.text)).toEqual([B]); + expect(log).toEqual(['scrollback 0,0', 'scrollback 0,1']); + }); + + test('a cell marked as a link with no URI, a row without links, or a missing row has none', () => { + const marked = terminal([[A]], 0); + if (marked.t.wasmTerm) marked.t.wasmTerm.getHyperlinkUri = () => null; + expect(provide(marked.t, 0)).toBeUndefined(); + expect(provide(terminal([[null, null]], 0).t, 0)).toBeUndefined(); + expect(provide(terminal([], 0).t, 2)).toBeUndefined(); + }); + + test('without the engine nothing is found', () => { + expect(provide(terminal([[A]], 0, false).t, 0)).toBeUndefined(); + }); + + test('a run ends at a missing cell', () => { + const { t } = terminal([[A, A, A]], 0); + const line = t.buffer.active.getLine(0); + t.buffer.active.getLine = () => ({ + length: 3, + getCell: (x) => (x === 1 ? undefined : line?.getCell(x)), + }); + expect(provide(t, 0)?.map((link) => [link.range.start.x, link.range.end.x])).toEqual([ + [0, 0], + [2, 2], + ]); + }); + + test('activating a link opens it only with Ctrl or Cmd held', () => { + const opened: string[] = []; + const [link] = provide(terminal([[A]], 0).t, 0, opened) ?? []; + link.activate({ ctrlKey: false, metaKey: false } as MouseEvent); + link.activate({ ctrlKey: false, metaKey: true } as MouseEvent); + expect(opened).toEqual([A]); + new OSC8LinkProvider(terminal([], 0).t).dispose(); + }); +}); diff --git a/src/client/vendor/tests/links/url-regex-provider.test.ts b/src/client/vendor/tests/links/url-regex-provider.test.ts new file mode 100644 index 0000000..2b698b7 --- /dev/null +++ b/src/client/vendor/tests/links/url-regex-provider.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test'; +import type { ILink } from '../../ghostty-web/link-types'; +import { UrlRegexProvider } from '../../ghostty-web/providers/url-regex-provider'; + +// UrlRegexProvider on a fake buffer of text rows: the schemes it knows, the punctuation +// it drops, the minimum length, the regex state between rows, and activation. + +/** The code points of `text`. */ +const codePoints = (text: string) => [...text].map((c) => c.codePointAt(0) ?? 0); + +/** A buffer whose rows are `lines`; a code point of -1 is a missing cell. */ +function buffer(lines: (string | number[])[]) { + return { + buffer: { + active: { + getLine: (y: number) => { + const line = lines[y]; + if (line === undefined) return undefined; + const points = typeof line === 'string' ? codePoints(line) : line; + return { + length: points.length, + getCell: (x: number) => + points[x] === -1 ? undefined : { getCodepoint: () => points[x] }, + }; + }, + }, + }, + }; +} + +/** The links found on row `y` of `lines`, and what activating them opened. */ +function linksOn(lines: (string | number[])[], y = 0) { + const opened: string[][] = []; + const provider = new UrlRegexProvider(buffer(lines), (url, target, features) => + opened.push([url, target, features]), + ); + let found: ILink[] | undefined; + provider.provideLinks(y, (links) => { + found = links; + }); + return { found, opened, provider }; +} + +const texts = (lines: string[], y = 0) => linksOn(lines, y).found?.map((link) => link.text); + +describe('UrlRegexProvider', () => { + test.each([ + 'https://example.com/a?b=c#d', + 'http://example.com', + 'mailto:me@example.com', + 'ftp://files.example.com', + 'ssh://host.example.com', + 'git://host.example.com/r', + 'tel:+15551234567', + 'magnet:?xt=urn:btih:abc', + 'gemini://capsule.example', + 'gopher://hole.example', + 'news:comp.lang.c', + ])('finds %s', (url) => { + expect(texts([`see ${url} here`])).toEqual([url]); + }); + + test('ignores file paths and unknown schemes', () => { + expect(texts(['./a/b ../c /usr/bin file://x/y javascript:alert(1)'])).toBeUndefined(); + }); + + test.each([ + ['(https://example.com/x).', 'https://example.com/x'], + ['https://example.com/x, next', 'https://example.com/x'], + ['https://example.com/x!?;', 'https://example.com/x'], + ['[https://example.com/x]', 'https://example.com/x'], + ])('drops the trailing punctuation of %p', (line, url) => { + expect(texts([line])).toEqual([url]); + }); + + test('skips a URL of 8 characters or fewer, as a bare scheme', () => { + expect(texts(['http://a https://'])).toBeUndefined(); + expect(texts(['http://ab'])).toEqual(['http://ab']); + }); + + test('ranges cover the URL on its row, without the dropped punctuation', () => { + const { found } = linksOn(['', 'go https://x.io/p. then http://y.io'], 1); + expect(found?.map((link) => link.range)).toEqual([ + { start: { x: 3, y: 1 }, end: { x: 16, y: 1 } }, + { start: { x: 24, y: 1 }, end: { x: 34, y: 1 } }, + ]); + }); + + test('every row is scanned from its start, whatever the last scan left in the regex', () => { + const lines = ['https://first.example/long/path https://b.example', 'https://c.example']; + const { provider } = linksOn(lines); + const seen: (string[] | undefined)[] = []; + for (const y of [0, 1, 1]) { + provider.provideLinks(y, (links) => seen.push(links?.map((link) => link.text))); + } + expect(seen).toEqual([ + ['https://first.example/long/path', 'https://b.example'], + ['https://c.example'], + ['https://c.example'], + ]); + }); + + test('control characters and missing cells break a URL like spaces', () => { + const points = codePoints('https://a.example'); + const withNul = [...points, 0, ...points]; + expect(linksOn([withNul]).found?.map((link) => link.range.end.x)).toEqual([16, 34]); + const withGap = [...points, -1, 7, ...points]; + expect(linksOn([withGap]).found).toHaveLength(2); + }); + + test('a missing row has no links', () => { + expect(linksOn([], 3).found).toBeUndefined(); + }); + + test('activating a link opens it in a new tab only with Ctrl or Cmd held', () => { + const { found, opened, provider } = linksOn(['https://example.com']); + const [link] = found ?? []; + link.activate({ ctrlKey: false, metaKey: false } as MouseEvent); + expect(opened).toEqual([]); + link.activate({ ctrlKey: true, metaKey: false } as MouseEvent); + link.activate({ ctrlKey: false, metaKey: true } as MouseEvent); + expect(opened).toEqual([ + ['https://example.com', '_blank', 'noopener,noreferrer'], + ['https://example.com', '_blank', 'noopener,noreferrer'], + ]); + provider.dispose(); + }); +}); From 701a1da1245a17fd10b283d6fd17db6e2b012a91 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:25:41 -0400 Subject: [PATCH 248/295] docs(arch): the vendor tree lists providers/ and the selection test fixture --- docs/specs/architecture.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 981f94b..9c6d495 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -162,7 +162,8 @@ src/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/) support/ wasm.ts (the one WASM path, a cached loadGhostty(), countedGhostty()), fake-canvas.ts, fake-event-target.ts, - engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours) + engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours), + selection-fixture.ts (a SelectionManager on a recording canvas and document) fixtures/ PTY captures (was vendor/fixtures/) ghostty-web/ Biome-excluded; see "The vendored layer" scripts/ @@ -192,7 +193,8 @@ ghostty-web/ selection-manager.ts facade: SelectionManager state + API (SelectionHost) (≤ 300) interfaces.ts buffer.ts (IBufferSource) event-emitter.ts types.ts (re-export shim until removed) search.ts glide.ts wheel.ts mouse-route.ts webtty-original, kept in place - link-detector.ts link-types.ts providers/ addons/fit.ts fn-table.ts + link-detector.ts link-types.ts addons/fit.ts fn-table.ts + providers/ osc8-link-provider.ts url-regex-provider.ts open-link.ts (openLinkOnModifier, the one activation rule) lifecycle/ disposable-store.ts (DisposableStore, listen, toDisposable) – webtty-original engine/ every wasm call; no DOM engine-types.ts cell-types.ts wasm-exports.ts vt-layout.ts official-layout.ts key-tables.ts scratch.ts From 51a51ae3469d4a74af246309bde8fc2bc745d1b2 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:39:43 -0400 Subject: [PATCH 249/295] refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces Terminal's pure and state parts move into core/ (ADR 061); terminal.ts goes from 2139 to 1822 lines and re-exports what moved: - terminal-constants.ts: FOCUS_EVENT_MODE, FIND_STEP_MS, the scrollbar hide and fade timings (were instance fields), and the literals for application cursor keys (mode 1), the find re-read backoff (10x) and the 16 ms hover throttle - terminal-geometry.ts: mouseCursorFor, mouseSurface, deviceCellPixels, cursorCellOffset, setStylePx, plus pointerCell, viewportRowToBufferRow (the hover and click buffer row mapping, one formula for its three equal branches) and linkViewportRange (the hover underline's rows) - options-proxy.ts: TERMINAL_DEFAULTS, withDefaults, a typed createOptionsProxy, parseThemeColor and toEngineConfig (was buildWasmConfig) - viewport.ts: Viewport, the clamped scrollLines / scrollToLine / scrollToBottom with their onScroll and scrollbar calls; viewportY is an accessor over it, read and set as the field was - host-types.ts: ViewportHost mouseReportText joins the mouse reports in input/mouse-report.ts. The type cycles and `any` reach-ins go: buffer.ts reads an IBufferSource ({ wasmTerm?: BufferEngine }) instead of importing Terminal, ITerminalCore gains the optional renderer FitAddon reads, and FitAddon takes an injected computed-style reader (window.getComputedStyle by default). No `as any` is left in buffer.ts, selection-manager.ts or fit.ts. Terminal's stale usage header, its section banners and "Phase" notes are fixed. Listener targets, order and flags are unchanged; the listener snapshot passes as is. Tests: the pure terminal tests move to vendor/tests/core/terminal-geometry.test.ts (with new pointerCell, buffer row, link range and setStylePx cases); the buildWasmConfig casts become options-proxy.test.ts on toEngineConfig; new viewport.test.ts; fit.test.ts moves to vendor/tests/addons/ on an injected happy-dom window with its guards covered; buffer.test.ts drops its Terminal cast and covers the unopened and short-row cases; terminal.test.ts covers options before and after open(). The ratchet entries for terminal.ts and terminal.test.ts are lowered to 1822 and 1003. --- docs/specs/architecture.md | 2 +- scripts/architecture/ratchet-allowlist.ts | 4 +- .../features/kitty/protocol/cell-geometry.ts | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 9 + src/client/vendor/ghostty-web/addons/fit.ts | 49 +- src/client/vendor/ghostty-web/buffer.ts | 73 +-- .../vendor/ghostty-web/core/host-types.ts | 11 + .../vendor/ghostty-web/core/options-proxy.ts | 151 +++++ .../ghostty-web/core/terminal-constants.ts | 27 + .../ghostty-web/core/terminal-geometry.ts | 170 ++++++ .../vendor/ghostty-web/core/viewport.ts | 43 ++ .../vendor/ghostty-web/input/mouse-report.ts | 16 + src/client/vendor/ghostty-web/interfaces.ts | 2 + src/client/vendor/ghostty-web/terminal.ts | 563 ++++-------------- src/client/vendor/tests/addons/fit.test.ts | 120 ++++ src/client/vendor/tests/buffer.test.ts | 41 +- .../vendor/tests/core/options-proxy.test.ts | 156 +++++ .../tests/core/terminal-geometry.test.ts | 242 ++++++++ src/client/vendor/tests/core/viewport.test.ts | 84 +++ src/client/vendor/tests/fit.test.ts | 60 -- .../vendor/tests/input/mouse-report.test.ts | 14 + src/client/vendor/tests/terminal.test.ts | 211 +------ 22 files changed, 1292 insertions(+), 758 deletions(-) create mode 100644 src/client/vendor/ghostty-web/core/options-proxy.ts create mode 100644 src/client/vendor/ghostty-web/core/terminal-constants.ts create mode 100644 src/client/vendor/ghostty-web/core/terminal-geometry.ts create mode 100644 src/client/vendor/ghostty-web/core/viewport.ts create mode 100644 src/client/vendor/tests/addons/fit.test.ts create mode 100644 src/client/vendor/tests/core/options-proxy.test.ts create mode 100644 src/client/vendor/tests/core/terminal-geometry.test.ts create mode 100644 src/client/vendor/tests/core/viewport.test.ts delete mode 100644 src/client/vendor/tests/fit.test.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 9c6d495..1351efd 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -160,7 +160,7 @@ src/ test-support.test.ts the fakes' self-tests (a test-support/ folder holds no *.test.ts) vendor/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md - tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/) + tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/, addons/) support/ wasm.ts (the one WASM path, a cached loadGhostty(), countedGhostty()), fake-canvas.ts, fake-event-target.ts, engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours), selection-fixture.ts (a SelectionManager on a recording canvas and document) diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 54b444f..d5174ac 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -17,10 +17,10 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, 'src/client/vendor/ghostty-web/terminal.ts': { - lines: 2139, + lines: 1822, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/terminal.test.ts': { lines: 1159, step: VENDOR_TESTS }, + 'src/client/vendor/tests/terminal.test.ts': { lines: 1003, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, }, diff --git a/src/client/features/kitty/protocol/cell-geometry.ts b/src/client/features/kitty/protocol/cell-geometry.ts index ee495ae..403ae07 100644 --- a/src/client/features/kitty/protocol/cell-geometry.ts +++ b/src/client/features/kitty/protocol/cell-geometry.ts @@ -6,7 +6,7 @@ import { type KittyKeys, num } from './apc-command'; /** * A cell's size in whole device pixels. Ghostty reports it to programs (CSI 16t, - * from terminal.ts `deviceCellPixels`, which computes the same) and they size + * from the vendored `deviceCellPixels`, which computes the same) and they size * frames as cols x this, so it has to be an integer even though the CSS cell size * (and devicePixelRatio, e.g. 1.25 or 1.5 on Windows) is not. */ diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 82c3c40..7555a3d 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -51,6 +51,8 @@ webtty edits to these files are tracked in git from the commit that added them; - `index.ts`: the barrel's header names its origin and its role as the only path webtty's app code imports (ADR 060, ADR 061), and it exports the webtty-original `Glide`, `AT_ONCE`, `FRAME_MS`, `linear`, `easeOutCubic`, `Pace` and `Ease` (`glide.ts`), `watchDevicePixelRatio` (`renderer.ts`) and `DisposableStore`, `listen`, `toDisposable`, `Disposable` and `ListenerTarget` (`lifecycle/disposable-store.ts`), which the app imported by deep path or did not have. - `input-handler.ts`: the listeners go into one `DisposableStore` (`listen`), so `dispose()` removes them all as before; the per-handler `isDisposed` checks, which no removed listener could reach, and the `keypressListener` field, which nothing ever set, are removed (ADR 061). The `EchoGuard` (`input/text-input.ts`) reads an injected clock, `performance.now()` by default as before. - `input-handler.ts`: `InputHandler` takes one `InputHandlerOptions` object instead of seven positional arguments (`ghostty` narrowed to its `createKeyEncoder`, `container`, `onData`, `getMode`, `onCopy`, `inputElement`, `mouseConfig`), plus `now`, the echo guard's clock (ADR 061). +- `buffer.ts`, `addons/fit.ts`, `interfaces.ts`: the buffer API and `FitAddon` stop reaching into `Terminal` through `any` (ADR 061). `BufferNamespace` reads the engine of an `IBufferSource` (`{ wasmTerm? }`, the engine narrowed to `BufferEngine`), and `ITerminalCore` gains the optional `renderer` (its `getMetrics`) that `FitAddon` reads. `FitAddon` takes an optional computed-style reader, `window.getComputedStyle` by default, so tests inject one. +- `terminal.ts`: `viewportY` is an accessor over the new `Viewport` (`core/viewport.ts`), reading and setting its `y` as the field was read and set; `options` is typed as the stored values (`TerminalOptionValues`: every option but `ghostty`, `scrollbackLines` optional) instead of `Required<ITerminalOptions>`, and its proxy no longer goes through `any` (ADR 061). ## webtty-original files @@ -68,6 +70,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. +- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`) and the view's place in the scrollback (`viewport.ts`). `terminal.ts` is the facade over them and re-exports what moved. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -134,3 +137,9 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `selection-manager.ts`: `attachEventListeners` (one method per listener: `canvasDown`, `canvasMove`, `documentMove`, `release`, `multiClick`, `documentClick`), `pressIsApps` (now the host's `appTakesPress`), the drag fields (`isSelecting`, `mouseDownX`/`Y`, `dragThresholdMet`, `mouseDownTarget`) and the listener removal of `dispose`; the no-op `requestRender` is dropped | `selection/selection-gestures.ts` (`SelectionGestures`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | | `selection-manager.ts`: what it reads of `Terminal`, `CanvasRenderer` and the engine through `any` (webtty) | `core/host-types.ts` (`SelectionHost`, `SelectionCanvasSource`, `SelectionEngineSource`) | refactor(client): selection split: coords, gestures, auto-scroll, context menu, clipboard copy | | `providers/osc8-link-provider.ts`, `providers/url-regex-provider.ts`: the Ctrl/Cmd check and `window.open` of each link's `activate` (now one `openLinkOnModifier`, with the opener injected) | `providers/open-link.ts` | refactor(client): one link activation helper for both link providers | +| `terminal.ts`: `FOCUS_EVENT_MODE`, `FIND_STEP_MS`, `SCROLLBAR_HIDE_DELAY_MS`, `SCROLLBAR_FADE_DURATION_MS` (instance fields, now constants), and the literals 1 (application cursor keys, now `APP_CURSOR_KEYS_MODE`), 10 (the find re-read backoff, now `FIND_READ_BACKOFF`) and 16 (the hover throttle, now `MOUSE_MOVE_THROTTLE_MS`) | `core/terminal-constants.ts` | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | +| `terminal.ts`: `mouseCursorFor`, `mouseSurface`, `deviceCellPixels`, `cursorCellOffset`, `setStylePx`; the pixel-to-cell maths of `processMouseMove` and `handleClick` (now `pointerCell`), their buffer row mapping (now `viewportRowToBufferRow`, one formula for its three branches), and the hover underline's viewport rows (now `linkViewportRange`); `terminal.ts` re-exports the first four | `core/terminal-geometry.ts` | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | +| `terminal.ts`: `mouseReportText` and its decoder (webtty, ADR 049); `terminal.ts` re-exports it | `input/mouse-report.ts` | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | +| `terminal.ts`: the constructor's option defaults (now `TERMINAL_DEFAULTS`, `withDefaults`) and options `Proxy` (now `createOptionsProxy`), `parseColor` (now `parseThemeColor`) and `buildWasmConfig` (now `toEngineConfig`); `terminal.ts` re-exports `TERMINAL_DEFAULTS` and `toEngineConfig` | `core/options-proxy.ts` | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | +| `terminal.ts`: the `viewportY` field and the clamping, `onScroll` and scrollbar calls of `scrollLines`, `scrollToBottom` and `scrollToLine` | `core/viewport.ts` (`Viewport`) | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | +| `buffer.ts`, `addons/fit.ts`: what they read of `Terminal` through `any` (webtty) | `buffer.ts` (`IBufferSource`, `BufferEngine`), `interfaces.ts` (`ITerminalCore.renderer`), `core/host-types.ts` (`ViewportHost`) | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | diff --git a/src/client/vendor/ghostty-web/addons/fit.ts b/src/client/vendor/ghostty-web/addons/fit.ts index 71e69df..8c8a0f7 100644 --- a/src/client/vendor/ghostty-web/addons/fit.ts +++ b/src/client/vendor/ghostty-web/addons/fit.ts @@ -1,36 +1,21 @@ -/** - * FitAddon - Proposes the grid that fits the terminal's container - * - * Usage: - * ```typescript - * const fitAddon = new FitAddon(); - * term.loadAddon(fitAddon); - * const dims = fitAddon.proposeDimensions(); - * if (dims) term.resize(dims.cols, dims.rows); - * ``` - * - * (webtty: upstream's fit() and observeResize() are removed; webtty resizes the - * terminal itself from proposeDimensions, ADR 033.) - */ +// Derived from coder/ghostty-web lib/addons/fit.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// FitAddon (object): proposes the grid that fits the terminal's container, from its client +// size less its CSS padding and the renderer's cell. (webtty: upstream's fit() and +// observeResize() are removed; webtty resizes the terminal itself from proposeDimensions, +// ADR 033, ADR 061.) import type { ITerminalAddon, ITerminalCore } from '../interfaces'; -// ============================================================================ -// Constants -// ============================================================================ - const MINIMUM_COLS = 2; const MINIMUM_ROWS = 1; -// ============================================================================ -// Types -// ============================================================================ - +/** A grid size in cells. */ export interface ITerminalDimensions { cols: number; rows: number; } +/** An element's CSS padding, in pixels. */ export interface IFitPadding { top: number; right: number; @@ -58,13 +43,20 @@ export function fitDimensions( }; } -// ============================================================================ -// FitAddon Class -// ============================================================================ +/** Reads an element's computed style; the window's own by default. */ +export type ComputedStyleReader = ( + element: HTMLElement +) => Pick<CSSStyleDeclaration, 'getPropertyValue'>; +/** The grid size source webtty's Fitter asks on every fit. */ export class FitAddon implements ITerminalAddon { private _terminal?: ITerminalCore; + constructor( + private readonly computedStyle: ComputedStyleReader = (element) => + window.getComputedStyle(element) + ) {} + /** * Activate the addon (called by Terminal.loadAddon) */ @@ -95,10 +87,7 @@ export class FitAddon implements ITerminalAddon { return undefined; } - // Access terminal internals to get renderer - const terminal = this._terminal as any; - const renderer = terminal.renderer; - + const renderer = this._terminal.renderer; if (!renderer || typeof renderer.getMetrics !== 'function') { return undefined; } @@ -118,7 +107,7 @@ export class FitAddon implements ITerminalAddon { return undefined; } - const elementStyle = window.getComputedStyle(terminalElement); + const elementStyle = this.computedStyle(terminalElement); // Get the actual content area (inside padding) const paddingTop = Number.parseInt(elementStyle.getPropertyValue('padding-top')) || 0; diff --git a/src/client/vendor/ghostty-web/buffer.ts b/src/client/vendor/ghostty-web/buffer.ts index 24cd72f..dec2663 100644 --- a/src/client/vendor/ghostty-web/buffer.ts +++ b/src/client/vendor/ghostty-web/buffer.ts @@ -1,47 +1,50 @@ -/** - * Buffer API - xterm.js-compatible buffer access - * - * Provides read-only access to terminal buffer contents, cursor state, - * and viewport position. Wraps Ghostty WASM terminal state. - * - * Usage: - * ```typescript - * const cell = term.buffer.active.getLine(0)?.getCell(5); - * console.log(cell?.getCodepoint(), cell?.getHyperlinkId()); - * - * if (term.buffer.active.type === 'alternate') { - * console.log('Full-screen app running'); - * } - * ``` - */ +// Derived from coder/ghostty-web lib/buffer.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The xterm.js-compatible buffer API (objects): read-only views of the normal and alternate +// screens, their lines and cells, over the engine of an IBufferSource. The link providers +// read links through it, e.g. `term.buffer.active.getLine(y)?.getCell(x)` (ADR 061). -import type { GhosttyTerminal } from './ghostty'; +import type { GhosttyCell } from './engine/cell-types'; +import type { GhosttyTerminal } from './engine/engine-types'; import type { IBuffer, IBufferCell, IBufferLine, IBufferNamespace } from './interfaces'; -import type { Terminal } from './terminal'; -import type { GhosttyCell } from './types'; -// ============================================================================ -// BufferNamespace - Top-level buffer API -// ============================================================================ +/** The engine calls the buffer API reads: the screen in use, the cursor and the lines. */ +export type BufferEngine = Pick< + GhosttyTerminal, + | 'cols' + | 'rows' + | 'isAlternateScreen' + | 'getCursor' + | 'getLine' + | 'getScrollbackLength' + | 'getScrollbackLine' +>; + +/** + * What the buffer API reads from its terminal: the engine, unset before open() and after + * dispose(). (webtty, ADR 061: replaces reaching into Terminal through `any`) + */ +export interface IBufferSource { + readonly wasmTerm?: BufferEngine; +} /** * Top-level buffer API namespace * Provides access to active, normal, and alternate screen buffers */ export class BufferNamespace implements IBufferNamespace { - private terminal: Terminal; + private terminal: IBufferSource; // Lazy-initialized buffer wrappers (stateless, so we can cache them) private _normalBuffer?: Buffer; private _alternateBuffer?: Buffer; - constructor(terminal: Terminal) { + constructor(terminal: IBufferSource) { this.terminal = terminal; } get active(): IBuffer { // Query WASM to determine which buffer is active - const wasmTerm = (this.terminal as any).wasmTerm as GhosttyTerminal | undefined; + const wasmTerm = this.terminal.wasmTerm; if (!wasmTerm) { return this.normal; // Default to normal if not initialized } @@ -64,18 +67,14 @@ export class BufferNamespace implements IBufferNamespace { } } -// ============================================================================ -// Buffer - Represents a terminal buffer (normal or alternate) -// ============================================================================ - /** * A terminal buffer (normal or alternate screen) */ export class Buffer implements IBuffer { - private terminal: Terminal; + private terminal: IBufferSource; private bufferType: 'normal' | 'alternate'; - constructor(terminal: Terminal, type: 'normal' | 'alternate') { + constructor(terminal: IBufferSource, type: 'normal' | 'alternate') { this.terminal = terminal; this.bufferType = type; } @@ -142,15 +141,11 @@ export class Buffer implements IBuffer { return new BufferLine(cells, wasmTerm.cols); } - private getWasmTerm(): GhosttyTerminal | undefined { - return (this.terminal as any).wasmTerm as GhosttyTerminal | undefined; + private getWasmTerm(): BufferEngine | undefined { + return this.terminal.wasmTerm; } } -// ============================================================================ -// BufferLine - Represents a single line in the buffer -// ============================================================================ - /** * A single line in the buffer */ @@ -196,10 +191,6 @@ export class BufferLine implements IBufferLine { } } -// ============================================================================ -// BufferCell - Represents a single cell in the buffer -// ============================================================================ - /** * A single cell in the buffer */ diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index ceab536..82a934d 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -42,3 +42,14 @@ export type SelectionEngineSource = Pick< | 'selectOutput' | 'selectionText' >; + +/** + * What the Viewport needs from the terminal: the scrollback length it clamps to, where a + * move is announced (onScroll), and the scrollbar it shows while scrolling. + */ +export interface ViewportHost { + getScrollbackLength(): number; + /** The view moved to `viewportY`. */ + scrolled(viewportY: number): void; + showScrollbar(): void; +} diff --git a/src/client/vendor/ghostty-web/core/options-proxy.ts b/src/client/vendor/ghostty-web/core/options-proxy.ts new file mode 100644 index 0000000..d61eabf --- /dev/null +++ b/src/client/vendor/ghostty-web/core/options-proxy.ts @@ -0,0 +1,151 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Terminal's options store (util + constants): the defaults, the proxy that reports a +// change made after construction (xterm.js lets options change at runtime), and the +// engine config built from the options (ADR 061). + +import type { GhosttyTerminalConfig } from '../engine/engine-types'; +import type { ITerminalOptions } from '../interfaces'; + +/** The options a Terminal keeps: every option but the engine, defaults filled in. */ +export type TerminalOptionValues = Required< + Omit<ITerminalOptions, 'ghostty' | 'scrollbackLines'> +> & + Pick<ITerminalOptions, 'scrollbackLines'>; + +/** + * The value of each option that is not given. `theme` is `{}` and `scrollbackLines` + * stays unset (the engine's own default), so neither is listed. + */ +export const TERMINAL_DEFAULTS = { + cols: 80, + rows: 24, + cursorBlink: false, + cursorStyle: 'block', + fontSize: 15, + fontFamily: 'monospace', + allowTransparency: false, + convertEol: false, + disableStdin: false, + mouseScrollSpeed: 1, + copyOnSelect: true, + rightClickBehavior: 'contextMenu', + clipboardWrite: true, + desktopNotifications: true, +} as const satisfies Omit<TerminalOptionValues, 'theme' | 'scrollbackLines'>; + +/** The options with every unset one at its default, in the order Terminal has always kept them. */ +export function withDefaults(options: ITerminalOptions): TerminalOptionValues { + const d = TERMINAL_DEFAULTS; + return { + cols: options.cols ?? d.cols, + rows: options.rows ?? d.rows, + cursorBlink: options.cursorBlink ?? d.cursorBlink, + cursorStyle: options.cursorStyle ?? d.cursorStyle, + theme: options.theme ?? {}, + scrollbackLines: options.scrollbackLines, + fontSize: options.fontSize ?? d.fontSize, + fontFamily: options.fontFamily ?? d.fontFamily, + allowTransparency: options.allowTransparency ?? d.allowTransparency, + convertEol: options.convertEol ?? d.convertEol, + disableStdin: options.disableStdin ?? d.disableStdin, + mouseScrollSpeed: options.mouseScrollSpeed ?? d.mouseScrollSpeed, + copyOnSelect: options.copyOnSelect ?? d.copyOnSelect, + rightClickBehavior: options.rightClickBehavior ?? d.rightClickBehavior, + clipboardWrite: options.clipboardWrite ?? d.clipboardWrite, + desktopNotifications: options.desktopNotifications ?? d.desktopNotifications, + }; +} + +/** Called after an option is set, with the key, the new value and the one it replaced. */ +export type OptionChange<T> = (key: keyof T, value: unknown, oldValue: unknown) => void; + +/** + * Wraps `values` so every assignment is stored and then reported to `onChange`, which + * applies it (a font size, a resize). Reads go straight to `values`. + */ +export function createOptionsProxy<T extends object>(values: T, onChange: OptionChange<T>): T { + return new Proxy(values, { + set: (target, prop, value) => { + const key = prop as keyof T; + const oldValue = target[key]; + target[key] = value; + onChange(key, value, oldValue); + return true; + }, + }); +} + +/** + * A CSS colour as 0xRRGGBB (`#rgb`, `#rrggbb` or `rgb(r, g, b)`), or undefined when it is + * unset or invalid. (webtty: replaces `parseColorToHex`, which returned 0 for both; tells + * `#000000` apart from "not given", ADR 042) + */ +export function parseThemeColor(color?: string): number | undefined { + if (!color) return undefined; + + if (color.startsWith('#')) { + let hex = color.slice(1); + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + const value = Number.parseInt(hex, 16); + return Number.isNaN(value) ? undefined : value; + } + + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (match) { + const r = Number.parseInt(match[1], 10); + const g = Number.parseInt(match[2], 10); + const b = Number.parseInt(match[3], 10); + return (r << 16) | (g << 8) | b; + } + + return undefined; +} + +/** The options the engine is created with. */ +export type EngineOptions = Pick< + TerminalOptionValues, + | 'theme' + | 'scrollbackLines' + | 'cursorStyle' + | 'cursorBlink' + | 'clipboardWrite' + | 'desktopNotifications' +>; + +/** The engine's config for these options: the theme's colours, history, cursor and effects. */ +export function toEngineConfig(options: EngineOptions): GhosttyTerminalConfig { + const theme = options.theme; + // The 16 ANSI colours in palette order: the 8 normal, then the 8 bright + const palette = [ + theme?.black, + theme?.red, + theme?.green, + theme?.yellow, + theme?.blue, + theme?.magenta, + theme?.cyan, + theme?.white, + theme?.brightBlack, + theme?.brightRed, + theme?.brightGreen, + theme?.brightYellow, + theme?.brightBlue, + theme?.brightMagenta, + theme?.brightCyan, + theme?.brightWhite, + ].map((color) => parseThemeColor(color)); + + return { + scrollbackLines: options.scrollbackLines, + fgColor: parseThemeColor(theme?.foreground), + bgColor: parseThemeColor(theme?.background), + cursorColor: parseThemeColor(theme?.cursor), + palette, + cursorStyle: options.cursorStyle, + cursorBlink: options.cursorBlink, + clipboardWrite: options.clipboardWrite, + desktopNotifications: options.desktopNotifications, + }; +} diff --git a/src/client/vendor/ghostty-web/core/terminal-constants.ts b/src/client/vendor/ghostty-web/core/terminal-constants.ts new file mode 100644 index 0000000..65f97a5 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-constants.ts @@ -0,0 +1,27 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Terminal's named values (constants): the modes it reads, the find step budget and the +// scrollbar and mouse timings, each with the ADR that set it (ADR 061). + +/** DEC private mode 1: application cursor keys, which the wheel's arrow keys follow. */ +export const APP_CURSOR_KEYS_MODE = 1; + +/** DEC private mode 1004: the app hears `CSI I` / `CSI O` on focus changes. (webtty, ADR 053) */ +export const FOCUS_EVENT_MODE = 1004; + +/** The time the search may take in a frame, in milliseconds. (webtty, ADR 055) */ +export const FIND_STEP_MS = 4; + +/** + * While output keeps changing the matches, they are read again at most every this many + * times the last read took. (webtty, ADR 055) + */ +export const FIND_READ_BACKOFF = 10; + +/** The scrollbar hides this long after the last scroll. */ +export const SCROLLBAR_HIDE_DELAY_MS = 1500; + +/** The scrollbar's fade in and fade out. */ +export const SCROLLBAR_FADE_DURATION_MS = 200; + +/** Link hover checks run at most once per this many milliseconds (about 60 fps). */ +export const MOUSE_MOVE_THROTTLE_MS = 16; diff --git a/src/client/vendor/ghostty-web/core/terminal-geometry.ts b/src/client/vendor/ghostty-web/core/terminal-geometry.ts new file mode 100644 index 0000000..07d8421 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-geometry.ts @@ -0,0 +1,170 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Terminal's pixel and row maths (util): pointer cells, viewport rows to buffer rows, the +// hovered link's rows in view, the mouse encoder's surface, the cursor cell and the device +// cell size. Pure functions, so the mouse, link and focus code agree on one mapping (ADR 061). + +import type { MouseGeometry } from '../engine/engine-types'; +import type { ILink, IBufferRange } from '../link-types'; + +/** + * Mouse cursor for the terminal surface: 'pointer' over a link, otherwise the + * platform default arrow. (webtty: upstream used the 'text' I-beam, which reads + * as editable text over TUI menus and widgets.) + */ +export function mouseCursorFor(link: ILink | undefined): 'pointer' | 'default' { + return link ? 'pointer' : 'default'; +} + +/** + * The viewport cell under a client position: the column from the canvas's left edge, the + * row from where viewport row 0 starts, the scroll offset below the canvas top (ADR 043). + * The row can be outside the grid when the pointer is in the padding. + */ +export function pointerCell( + clientX: number, + clientY: number, + canvasRect: { left: number; top: number }, + cell: { charWidth: number; charHeight: number; scrollOffset: number } +): { x: number; y: number } { + return { + x: Math.floor((clientX - canvasRect.left) / cell.charWidth), + y: Math.floor((clientY - canvasRect.top - cell.scrollOffset) / cell.charHeight), + }; +} + +/** + * The absolute buffer row (0 = the oldest scrollback row) shown on `viewportRow`, with the + * view `viewportY` lines back from the bottom. The view is floored to whole lines, as the + * renderer and the selection floor it, so a smooth-scroll offset never moves a row. + */ +export function viewportRowToBufferRow( + viewportRow: number, + scrollbackLength: number, + viewportY: number +): number { + return scrollbackLength - Math.max(0, Math.floor(viewportY)) + viewportRow; +} + +/** A hovered link's cells in viewport rows, as the renderer underlines them. */ +export interface ViewportLinkRange { + startX: number; + startY: number; + endX: number; + endY: number; +} + +/** + * The part of a link (absolute buffer rows) that is in view, clamped to the rows on + * screen, or null when none of it is. (webtty: the link's own range, OSC 8 or regex) + */ +export function linkViewportRange( + range: IBufferRange, + scrollbackLength: number, + viewportY: number, + rows: number +): ViewportLinkRange | null { + const view = Math.max(0, Math.floor(viewportY)); + const startY = range.start.y - scrollbackLength + view; + const endY = range.end.y - scrollbackLength + view; + if (startY >= rows || endY < 0) return null; + return { + startX: range.start.x, + startY: Math.max(0, startY), + endX: range.end.x, + endY: Math.min(rows - 1, endY), + }; +} + +/** + * The mouse encoder's geometry and a client position in it (ADR 049). Ghostty needs a + * whole cell size, and webtty's CSS cell is fractional, so the units are device-pixel + * cells: the cell is `cellPx` (the size Ghostty's size reports give, so SGR-pixels + * positions agree with them), and CSS lengths are scaled by `cellPx` over the CSS cell. + * The screen is the container `box`, so the padding around the grid is part of it and + * Ghostty maps it to the nearest edge cell. The position is measured from the grid's + * origin and then offset by the rounded padding, so rounding never moves a cell + * boundary. (webtty) + */ +export function mouseSurface( + clientX: number, + clientY: number, + grid: { + left: number; + top: number; + cols: number; + rows: number; + cellWidth: number; + cellHeight: number; + }, + box: { left: number; top: number; right: number; bottom: number }, + cellPx: { width: number; height: number } +): { geometry: MouseGeometry; x: number; y: number } { + const sx = cellPx.width / grid.cellWidth; + const sy = cellPx.height / grid.cellHeight; + const left = Math.max(0, Math.round((grid.left - box.left) * sx)); + const top = Math.max(0, Math.round((grid.top - box.top) * sy)); + const gridRight = grid.left + grid.cols * grid.cellWidth; + const gridBottom = grid.top + grid.rows * grid.cellHeight; + const right = Math.max(0, Math.round((box.right - gridRight) * sx)); + const bottom = Math.max(0, Math.round((box.bottom - gridBottom) * sy)); + return { + geometry: { + screenWidth: left + grid.cols * cellPx.width + right, + screenHeight: top + grid.rows * cellPx.height + bottom, + cellWidth: cellPx.width, + cellHeight: cellPx.height, + paddingTop: top, + paddingRight: right, + paddingBottom: bottom, + paddingLeft: left, + }, + x: (clientX - grid.left) * sx + left, + y: (clientY - grid.top) * sy + top, + }; +} + +/** + * A cell's size in whole device pixels, for Ghostty's size reports + * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as the kitty feature's + * `deviceCellSize` (features/kitty/protocol/cell-geometry.ts) sizes image frames: the CSS + * cell times `devicePixelRatio`, rounded, at least 1. (webtty, ADR 042) + */ +export function deviceCellPixels(cssCell: number, dpr: number): number { + return Math.max(1, Math.round(cssCell * dpr)); +} + +/** + * The cursor cell's top-left corner, in CSS pixels relative to the container's + * padding box. `cursor.y` is relative to the bottom screen; when scrolled back by + * `viewportY` lines the cursor moves down by that many rows, and it is kept on the + * last visible row once it scrolls out of view. A pending-wrap cursor is kept on + * the last column. (webtty: the input textarea is anchored here so the OS places + * IME and dictation panels at the cursor.) + */ +export function cursorCellOffset( + cursor: { x: number; y: number }, + viewportY: number, + grid: { cols: number; rows: number }, + cell: { width: number; height: number }, + canvas: { left: number; top: number } +): { left: number; top: number } { + const col = Math.min(Math.max(0, cursor.x), Math.max(0, grid.cols - 1)); + const row = Math.min(Math.max(0, cursor.y + Math.floor(viewportY)), Math.max(0, grid.rows - 1)); + return { + left: canvas.left + col * cell.width, + top: canvas.top + row * cell.height, + }; +} + +/** The box styles setStylePx writes. */ +export type BoxStyleProp = 'left' | 'top' | 'width' | 'height'; + +/** Sets a pixel style only when it changed, so a per-frame update writes nothing. (webtty) */ +export function setStylePx( + el: { readonly style: Record<BoxStyleProp, string> }, + prop: BoxStyleProp, + value: number +): void { + const px = `${value}px`; + if (el.style[prop] !== px) el.style[prop] = px; +} diff --git a/src/client/vendor/ghostty-web/core/viewport.ts b/src/client/vendor/ghostty-web/core/viewport.ts new file mode 100644 index 0000000..55174be --- /dev/null +++ b/src/client/vendor/ghostty-web/core/viewport.ts @@ -0,0 +1,43 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The view's position in the scrollback (object): `y` lines back from the bottom, moved +// only within the scrollback, each move announced and the scrollbar shown (ADR 061). + +import type { ViewportHost } from './host-types'; + +/** Where the view is: `y` = 0 shows the bottom screen, `y` > 0 is scrolled into history. */ +export class Viewport { + /** + * Lines scrolled back from the bottom; fractional while smooth scrolling. Set directly, + * it moves the view without an announcement, as Terminal's `viewportY` always has. + */ + y = 0; + + constructor(private readonly host: ViewportHost) {} + + /** Scrolls by `amount` lines: negative is up into history, positive down. */ + scrollLines(amount: number): void { + const scrollbackLength = this.host.getScrollbackLength(); + this.moveTo(Math.max(0, Math.min(scrollbackLength, this.y - amount)), scrollbackLength); + } + + /** Scrolls to `line` lines back from the bottom, clamped to the scrollback. */ + scrollToLine(line: number): void { + const scrollbackLength = this.host.getScrollbackLength(); + this.moveTo(Math.max(0, Math.min(scrollbackLength, line)), scrollbackLength); + } + + /** Scrolls to the bottom, the current output. */ + scrollToBottom(): void { + if (this.y === 0) return; + this.y = 0; + this.host.scrolled(0); + if (this.host.getScrollbackLength() > 0) this.host.showScrollbar(); + } + + private moveTo(y: number, scrollbackLength: number): void { + if (y === this.y) return; + this.y = y; + this.host.scrolled(y); + if (scrollbackLength > 0) this.host.showScrollbar(); + } +} diff --git a/src/client/vendor/ghostty-web/input/mouse-report.ts b/src/client/vendor/ghostty-web/input/mouse-report.ts index 6cd96ce..2f469b1 100644 --- a/src/client/vendor/ghostty-web/input/mouse-report.ts +++ b/src/client/vendor/ghostty-web/input/mouse-report.ts @@ -151,3 +151,19 @@ export class MouseReporter { if (data) this.send(data, true); } } + +const MOUSE_REPORT_DECODER = new TextDecoder('utf-8', { fatal: true }); + +/** + * A mouse report as text for the PTY, or '' when it is not valid UTF-8. Input travels as + * text frames that the server writes as UTF-8, so a report with raw bytes above 0x7f + * (X10 format past column or row 95, X10 buttons 8/9) would reach the app as different + * bytes; it is dropped instead (ADR 049). (webtty) + */ +export function mouseReportText(bytes: Uint8Array): string { + try { + return MOUSE_REPORT_DECODER.decode(bytes); + } catch { + return ''; + } +} diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index f0e8853..16d8443 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -83,6 +83,8 @@ export interface ITerminalAddon { export interface ITerminalCore { element?: HTMLElement; + /** The cell size, for FitAddon; set while the terminal is open (webtty, ADR 061) */ + readonly renderer?: { getMetrics(): { width: number; height: number } }; } // ============================================================================ diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 6e6482e..dc1b91f 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -1,28 +1,41 @@ -/** - * Terminal - Main terminal emulator class - * - * Provides an xterm.js-compatible API wrapping Ghostty's WASM terminal emulator. - * - * Usage: - * ```typescript - * import { Ghostty, Terminal } from 'ghostty-web'; - * - * const term = new Terminal({ ghostty: await Ghostty.load() }); - * term.open(document.getElementById('container')); - * term.write('Hello, World!\n'); - * term.onData(data => console.log('User typed:', data)); - * ``` - */ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Terminal (facade): the xterm.js-compatible terminal over Ghostty's libghostty-vt engine, +// its emitters and the parts it composes in open(); webtty creates it with +// `new Terminal({ ghostty: await Ghostty.load(url), ... })` and `open(container)` (ADR 061). import { BufferNamespace } from './buffer'; +import { + APP_CURSOR_KEYS_MODE, + FIND_READ_BACKOFF, + FIND_STEP_MS, + FOCUS_EVENT_MODE, + MOUSE_MOVE_THROTTLE_MS, + SCROLLBAR_FADE_DURATION_MS, + SCROLLBAR_HIDE_DELAY_MS, +} from './core/terminal-constants'; +import { + createOptionsProxy, + type TerminalOptionValues, + toEngineConfig, + withDefaults, +} from './core/options-proxy'; +import { + cursorCellOffset, + deviceCellPixels, + linkViewportRange, + mouseCursorFor, + mouseSurface, + pointerCell, + setStylePx, + viewportRowToBufferRow, +} from './core/terminal-geometry'; +import { Viewport } from './core/viewport'; import { EventEmitter } from './event-emitter'; import type { DesktopNotification, Ghostty, GhosttyCell, GhosttyTerminal, - GhosttyTerminalConfig, - MouseGeometry, SearchState, } from './ghostty'; import { AT_ONCE, type Pace } from './glide'; @@ -32,9 +45,9 @@ import { type MouseTrackingConfig, mouseMods, } from './input-handler'; +import { mouseReportText } from './input/mouse-report'; import type { IBufferNamespace, - IDisposable, IEvent, ITerminalAddon, ITerminalCore, @@ -63,132 +76,17 @@ import { wheelTicks, } from './wheel'; -// ============================================================================ -// Terminal Class -// ============================================================================ - -/** - * Mouse cursor for the terminal surface: 'pointer' over a link, otherwise the - * platform default arrow. (webtty: upstream used the 'text' I-beam, which reads - * as editable text over TUI menus and widgets.) - */ -export function mouseCursorFor(link: ILink | undefined): 'pointer' | 'default' { - return link ? 'pointer' : 'default'; -} - -/** - * The mouse encoder's geometry and a client position in it (ADR 049). Ghostty needs a - * whole cell size, and webtty's CSS cell is fractional, so the units are device-pixel - * cells: the cell is `cellPx` (the size Ghostty's size reports give, so SGR-pixels - * positions agree with them), and CSS lengths are scaled by `cellPx` over the CSS cell. - * The screen is the container `box`, so the padding around the grid is part of it and - * Ghostty maps it to the nearest edge cell. The position is measured from the grid's - * origin and then offset by the rounded padding, so rounding never moves a cell - * boundary. (webtty) - */ -export function mouseSurface( - clientX: number, - clientY: number, - grid: { - left: number; - top: number; - cols: number; - rows: number; - cellWidth: number; - cellHeight: number; - }, - box: { left: number; top: number; right: number; bottom: number }, - cellPx: { width: number; height: number } -): { geometry: MouseGeometry; x: number; y: number } { - const sx = cellPx.width / grid.cellWidth; - const sy = cellPx.height / grid.cellHeight; - const left = Math.max(0, Math.round((grid.left - box.left) * sx)); - const top = Math.max(0, Math.round((grid.top - box.top) * sy)); - const gridRight = grid.left + grid.cols * grid.cellWidth; - const gridBottom = grid.top + grid.rows * grid.cellHeight; - const right = Math.max(0, Math.round((box.right - gridRight) * sx)); - const bottom = Math.max(0, Math.round((box.bottom - gridBottom) * sy)); - return { - geometry: { - screenWidth: left + grid.cols * cellPx.width + right, - screenHeight: top + grid.rows * cellPx.height + bottom, - cellWidth: cellPx.width, - cellHeight: cellPx.height, - paddingTop: top, - paddingRight: right, - paddingBottom: bottom, - paddingLeft: left, - }, - x: (clientX - grid.left) * sx + left, - y: (clientY - grid.top) * sy + top, - }; -} - -const MOUSE_REPORT_DECODER = new TextDecoder('utf-8', { fatal: true }); - -/** DEC private mode 1004: the app hears `CSI I` / `CSI O` on focus changes. (webtty, ADR 053) */ -const FOCUS_EVENT_MODE = 1004; - -/** The time the search may take in a frame (webtty, ADR 055) */ -const FIND_STEP_MS = 4; - -/** - * A mouse report as text for the PTY, or '' when it is not valid UTF-8. Input travels as - * text frames that the server writes as UTF-8, so a report with raw bytes above 0x7f - * (X10 format past column or row 95, X10 buttons 8/9) would reach the app as different - * bytes; it is dropped instead (ADR 049). (webtty) - */ -export function mouseReportText(bytes: Uint8Array): string { - try { - return MOUSE_REPORT_DECODER.decode(bytes); - } catch { - return ''; - } -} - -/** - * A cell's size in whole device pixels, for Ghostty's size reports - * (XTWINOPS 14/16/18/19 t, mode 2048). Computed as the kitty feature's - * `deviceCellSize` (features/kitty/protocol/cell-geometry.ts) sizes image frames: the CSS cell times `devicePixelRatio`, rounded, at least 1. - * (webtty, ADR 042) - */ -export function deviceCellPixels(cssCell: number, dpr: number): number { - return Math.max(1, Math.round(cssCell * dpr)); -} - -/** - * The cursor cell's top-left corner, in CSS pixels relative to the container's - * padding box. `cursor.y` is relative to the bottom screen; when scrolled back by - * `viewportY` lines the cursor moves down by that many rows, and it is kept on the - * last visible row once it scrolls out of view. A pending-wrap cursor is kept on - * the last column. (webtty: the input textarea is anchored here so the OS places - * IME and dictation panels at the cursor.) - */ -export function cursorCellOffset( - cursor: { x: number; y: number }, - viewportY: number, - grid: { cols: number; rows: number }, - cell: { width: number; height: number }, - canvas: { left: number; top: number } -): { left: number; top: number } { - const col = Math.min(Math.max(0, cursor.x), Math.max(0, grid.cols - 1)); - const row = Math.min(Math.max(0, cursor.y + Math.floor(viewportY)), Math.max(0, grid.rows - 1)); - return { - left: canvas.left + col * cell.width, - top: canvas.top + row * cell.height, - }; -} - -/** Sets a pixel style only when it changed, so a per-frame update writes nothing. */ -function setStylePx( - el: HTMLElement, - prop: 'left' | 'top' | 'width' | 'height', - value: number -): void { - const px = `${value}px`; - if (el.style[prop] !== px) el.style[prop] = px; -} - +// The facade's former contents, re-exported so deep imports keep working (ADR 061) +export { TERMINAL_DEFAULTS, toEngineConfig } from './core/options-proxy'; +export { + cursorCellOffset, + deviceCellPixels, + mouseCursorFor, + mouseSurface, +} from './core/terminal-geometry'; +export { mouseReportText } from './input/mouse-report'; + +/** The xterm.js-compatible terminal: webtty's one terminal per page. */ export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; @@ -199,8 +97,8 @@ export class Terminal implements ITerminalCore { // Buffer API (xterm.js compatibility) public readonly buffer: IBufferNamespace; - // Options (public for xterm.js compatibility) - public readonly options!: Required<ITerminalOptions>; + // Options (public for xterm.js compatibility); a change after open() is applied + public readonly options: TerminalOptionValues; // Components (created on open()) private ghostty?: Ghostty; @@ -263,7 +161,7 @@ export class Terminal implements ITerminalCore { // Addons private addons: ITerminalAddon[] = []; - // Phase 1: Title tracking + // The title last reported through onTitleChange private currentTitle: string = ''; // Focus reports (webtty, ADR 053): keyboard focus inside the container in a focused @@ -283,8 +181,12 @@ export class Terminal implements ITerminalCore { private findAnchor: FindAnchor | null = null; private findState: SearchState = { total: 0, selected: null }; - // Phase 2: Viewport and scrolling state - public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) + // Where the view is in the scrollback; each move fires onScroll and shows the scrollbar + private readonly viewport = new Viewport({ + getScrollbackLength: () => this.getScrollbackLength(), + scrolled: (viewportY) => this.scrollEmitter.fire(viewportY), + showScrollbar: () => this.showScrollbar(), + }); private customWheelEventHandler?: (event: WheelEvent) => boolean; private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; // Whether a contextmenu came from a button or the keyboard (webtty, ADR 051) @@ -303,45 +205,12 @@ export class Terminal implements ITerminalCore { private scrollbarVisible: boolean = false; private scrollbarOpacity: number = 0; private scrollbarHideTimeout?: number; - private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds - private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation constructor(options: ITerminalOptions) { this.ghostty = options.ghostty; - - // Create base options object with all defaults (excluding ghostty) - const baseOptions = { - cols: options.cols ?? 80, - rows: options.rows ?? 24, - cursorBlink: options.cursorBlink ?? false, - cursorStyle: options.cursorStyle ?? 'block', - theme: options.theme ?? {}, - scrollbackLines: options.scrollbackLines, - fontSize: options.fontSize ?? 15, - fontFamily: options.fontFamily ?? 'monospace', - allowTransparency: options.allowTransparency ?? false, - convertEol: options.convertEol ?? false, - disableStdin: options.disableStdin ?? false, - mouseScrollSpeed: options.mouseScrollSpeed ?? 1, - copyOnSelect: options.copyOnSelect ?? true, - rightClickBehavior: options.rightClickBehavior ?? 'contextMenu', - clipboardWrite: options.clipboardWrite ?? true, - desktopNotifications: options.desktopNotifications ?? true, - }; - - // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) - (this.options as any) = new Proxy(baseOptions, { - set: (target: any, prop: string, value: any) => { - const oldValue = target[prop]; - target[prop] = value; - - // Apply runtime changes if terminal is open - if (this.isOpen) { - this.handleOptionChange(prop, value, oldValue); - } - - return true; - }, + // Options set after open() are applied (xterm.js compatibility) + this.options = createOptionsProxy(withDefaults(options), (key, value, oldValue) => { + if (this.isOpen) this.handleOptionChange(key, value, oldValue); }); this.cols = this.options.cols; @@ -351,15 +220,24 @@ export class Terminal implements ITerminalCore { this.buffer = new BufferNamespace(this); } - // ========================================================================== - // Option Change Handling (for mutable options) - // ========================================================================== - /** - * Handle runtime option changes (called when options are modified after terminal is open) - * This enables xterm.js compatibility where options can be changed at runtime + * Lines scrolled back from the bottom of the scrollback (0 = at the bottom); fractional + * while smooth scrolling. Setting it moves the view without firing onScroll. */ - private handleOptionChange(key: string, newValue: any, oldValue: any): void { + get viewportY(): number { + return this.viewport.y; + } + + set viewportY(y: number) { + this.viewport.y = y; + } + + /** Applies an option set after open(): the cursor, the font, or the grid size. */ + private handleOptionChange( + key: keyof TerminalOptionValues, + newValue: unknown, + oldValue: unknown + ): void { if (newValue === oldValue) return; switch (key) { @@ -428,85 +306,6 @@ export class Terminal implements ITerminalCore { this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); } - /** - * Parse a CSS color string to 0xRRGGBB format, or undefined if the color is - * undefined or invalid. (webtty: replaces `parseColorToHex`, which returned 0 - * for both; tells `#000000` apart from "not given", ADR 042) - */ - private parseColor(color?: string): number | undefined { - if (!color) return undefined; - - // Handle hex colors (#RGB, #RRGGBB) - if (color.startsWith('#')) { - let hex = color.slice(1); - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? undefined : value; - } - - // Handle rgb(r, g, b) format - const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (match) { - const r = Number.parseInt(match[1], 10); - const g = Number.parseInt(match[2], 10); - const b = Number.parseInt(match[3], 10); - return (r << 16) | (g << 8) | b; - } - - return undefined; - } - - /** - * Convert terminal options to WASM terminal config. - */ - private buildWasmConfig(): GhosttyTerminalConfig { - const theme = this.options.theme; - const scrollbackLines = this.options.scrollbackLines; - - // Build palette array from theme colors - // Order: black, red, green, yellow, blue, magenta, cyan, white, - // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite - const paletteColors = [ - theme?.black, - theme?.red, - theme?.green, - theme?.yellow, - theme?.blue, - theme?.magenta, - theme?.cyan, - theme?.white, - theme?.brightBlack, - theme?.brightRed, - theme?.brightGreen, - theme?.brightYellow, - theme?.brightBlue, - theme?.brightMagenta, - theme?.brightCyan, - theme?.brightWhite, - ].map((color) => this.parseColor(color)); - const fg = this.parseColor(theme?.foreground); - const bg = this.parseColor(theme?.background); - const cursor = this.parseColor(theme?.cursor); - - return { - scrollbackLines, - fgColor: fg, - bgColor: bg, - cursorColor: cursor, - palette: paletteColors, - cursorStyle: this.options.cursorStyle, - cursorBlink: this.options.cursorBlink, - clipboardWrite: this.options.clipboardWrite, - desktopNotifications: this.options.desktopNotifications, - }; - } - - // ========================================================================== - // Lifecycle Methods - // ========================================================================== - /** * Open terminal in a parent element * @@ -561,7 +360,7 @@ export class Terminal implements ITerminalCore { parent.setAttribute('aria-multiline', 'true'); // Create WASM terminal with current dimensions and config - const config = this.buildWasmConfig(); + const config = toEngineConfig(this.options); this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); // Create canvas element @@ -677,7 +476,7 @@ export class Terminal implements ITerminalCore { // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) document.addEventListener('mouseup', this.handleMouseUp); - // Setup wheel event handling for scrolling (Phase 2) + // Setup wheel event handling for scrolling // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); @@ -784,7 +583,7 @@ export class Terminal implements ITerminalCore { const selected = wasmTerm.searchSelectedMatch(); this.renderer?.setSearchHighlights({ matches: wasmTerm.searchMatches(), selected }); this.findStale = false; - this.findReadAt = now + (performance.now() - started) * 10; + this.findReadAt = now + (performance.now() - started) * FIND_READ_BACKOFF; const scrollback = this.getScrollbackLength(); if (selected && this.findAnchor && this.viewportY > 0) { this.scrollToLine(keptViewportY(this.viewportY, this.findAnchor, selected.start.row, scrollback)); @@ -903,7 +702,7 @@ export class Terminal implements ITerminalCore { // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); - // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior), except while the + // Auto-scroll to bottom on new output (xterm.js behavior), except while the // view is on a selected match: stepFind keeps it on its rows. The alternate screen // has no scrollback to keep a view in, and its matches are its own (webtty, ADR 055) if ( @@ -1017,7 +816,7 @@ export class Terminal implements ITerminalCore { if (this.wasmTerm) { this.wasmTerm.free(); } - const config = this.buildWasmConfig(); + const config = toEngineConfig(this.options); this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); this.syncCellPixelSize(); @@ -1058,10 +857,6 @@ export class Terminal implements ITerminalCore { this.addons.push(addon); } - // ========================================================================== - // Selection API (xterm.js compatible) - // ========================================================================== - /** * Get the selected text as a string */ @@ -1084,7 +879,6 @@ export class Terminal implements ITerminalCore { return this.selectionManager?.copySelection() || false; } - /** /** * Get the current viewport Y position. * @@ -1095,12 +889,8 @@ export class Terminal implements ITerminalCore { return this.viewportY; } - // ========================================================================== - // Phase 1: Custom Event Handlers - // ========================================================================== - /** - * Attach a custom wheel event handler (Phase 2) + * Attach a custom wheel event handler * Returns true to prevent default handling */ public attachCustomWheelEventHandler( @@ -1126,10 +916,6 @@ export class Terminal implements ITerminalCore { this.beforeRender = callback; } - // ========================================================================== - // Phase 2: Scrolling Methods - // ========================================================================== - /** * Scroll viewport by a number of lines * @param amount Number of lines to scroll (positive = down, negative = up) @@ -1138,66 +924,22 @@ export class Terminal implements ITerminalCore { if (!this.wasmTerm) { throw new Error('Terminal not open'); } - - const scrollbackLength = this.getScrollbackLength(); - const maxScroll = scrollbackLength; - - // Calculate new viewport position - // viewportY = 0 means at bottom (no scroll) - // viewportY > 0 means scrolled up into history - // amount < 0 (scroll up) should INCREASE viewportY - // amount > 0 (scroll down) should DECREASE viewportY - // So we SUBTRACT amount (negative amount becomes positive change) - const newViewportY = Math.max(0, Math.min(maxScroll, this.viewportY - amount)); - - if (newViewportY !== this.viewportY) { - this.viewportY = newViewportY; - this.scrollEmitter.fire(this.viewportY); - - // Show scrollbar when scrolling (with auto-hide) - if (scrollbackLength > 0) { - this.showScrollbar(); - } - } + this.viewport.scrollLines(amount); } - /** - * Scroll viewport to the bottom (current output) - */ + /** Scroll viewport to the bottom (current output) */ public scrollToBottom(): void { - if (this.viewportY !== 0) { - this.viewportY = 0; - this.scrollEmitter.fire(this.viewportY); - // Show scrollbar briefly when scrolling to bottom - if (this.getScrollbackLength() > 0) { - this.showScrollbar(); - } - } + this.viewport.scrollToBottom(); } /** * Scroll viewport to a specific line in the buffer - * @param line Line number (0 = top of scrollback, scrollbackLength = bottom) + * @param line Lines back from the bottom (0 = the bottom, scrollbackLength = the top) */ public scrollToLine(line: number): void { - const scrollbackLength = this.getScrollbackLength(); - const newViewportY = Math.max(0, Math.min(scrollbackLength, line)); - - if (newViewportY !== this.viewportY) { - this.viewportY = newViewportY; - this.scrollEmitter.fire(this.viewportY); - - // Show scrollbar when scrolling to specific line - if (scrollbackLength > 0) { - this.showScrollbar(); - } - } + this.viewport.scrollToLine(line); } - // ========================================================================== - // Lifecycle - // ========================================================================== - /** * Dispose terminal and clean up resources */ @@ -1243,10 +985,6 @@ export class Terminal implements ITerminalCore { this.findChangeEmitter.dispose(); } - // ========================================================================== - // Private Methods - // ========================================================================== - /** * Sends focus that lands on the container itself to the textarea. (webtty) */ @@ -1551,7 +1289,7 @@ export class Terminal implements ITerminalCore { this.pendingMouseMove = undefined; this.processMouseMove(pending); } - }, 16); + }, MOUSE_MOVE_THROTTLE_MS); }; /** @@ -1560,40 +1298,19 @@ export class Terminal implements ITerminalCore { private processMouseMove(e: MouseEvent): void { if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; - // Convert mouse coordinates to terminal cell position - const rect = this.canvas.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); - // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) - const y = Math.floor( - (e.clientY - rect.top - this.renderer.scrollOffset) / this.renderer.charHeight + // The cell under the pointer, as an absolute buffer row (scrollback included), which + // the link detector works in + const { x, y } = pointerCell( + e.clientX, + e.clientY, + this.canvas.getBoundingClientRect(), + this.renderer + ); + const bufferRow = viewportRowToBufferRow( + y, + this.wasmTerm.getScrollbackLength(), + this.getViewportY() ); - - const viewportRow = y; // Row in the viewport (0 to rows-1) - - // Check if there's a link at this position (for click handling and cursor) - // Buffer API expects absolute buffer coordinates (including scrollback) - // When scrolled, we need to adjust the buffer row based on viewportY - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - let bufferRow: number; - - // Use floored viewportY for buffer mapping (must match renderer & selection) - const rawViewportYForBuffer = this.getViewportY(); - const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer)); - - if (viewportYForBuffer > 0) { - // When scrolled, the buffer row depends on where in the viewport we are - if (viewportRow < viewportYForBuffer) { - // Mouse is over scrollback content - bufferRow = scrollbackLength - viewportYForBuffer + viewportRow; - } else { - // Mouse is over screen content (bottom part of viewport) - const screenRow = viewportRow - viewportYForBuffer; - bufferRow = scrollbackLength + screenRow; - } - } else { - // At bottom - buffer row is scrollback + screen row - bufferRow = scrollbackLength + viewportRow; - } // Make async call non-blocking - don't await this.linkDetector @@ -1619,33 +1336,19 @@ export class Terminal implements ITerminalCore { this.canvas.style.cursor = cursorStyle; } - // Update renderer for underline: the link's own range, OSC 8 or regex (webtty) + // Update renderer for underline: the link's own range, OSC 8 or regex, while + // any of it is in view (webtty) if (this.renderer) { - if (link) { - // Convert buffer coordinates to viewport coordinates - const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0; - - // Calculate viewport Y for start and end positions - // Use floored viewportY so overlay rows match renderer & selection - const rawViewportYForLinks = this.getViewportY(); - const viewportYForLinks = Math.max(0, Math.floor(rawViewportYForLinks)); - const startViewportY = link.range.start.y - scrollbackLength + viewportYForLinks; - const endViewportY = link.range.end.y - scrollbackLength + viewportYForLinks; - - // Only show underline if link is visible in viewport - if (startViewportY < this.rows && endViewportY >= 0) { - this.renderer.setHoveredLinkRange({ - startX: link.range.start.x, - startY: Math.max(0, startViewportY), - endX: link.range.end.x, - endY: Math.min(this.rows - 1, endViewportY), - }); - } else { - this.renderer.setHoveredLinkRange(null); - } - } else { - this.renderer.setHoveredLinkRange(null); - } + this.renderer.setHoveredLinkRange( + link + ? linkViewportRange( + link.range, + this.wasmTerm?.getScrollbackLength() || 0, + this.getViewportY(), + this.rows + ) + : null + ); } } }) @@ -1688,33 +1391,18 @@ export class Terminal implements ITerminalCore { // rather than relying on cached hover state (avoids async races) if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; - // Get click position - const rect = this.canvas.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); - // Viewport row 0 starts the scroll offset below the canvas top (webtty, ADR 043) - const y = Math.floor( - (e.clientY - rect.top - this.renderer.scrollOffset) / this.renderer.charHeight + // The clicked cell as an absolute buffer row, as for a hover + const { x, y } = pointerCell( + e.clientX, + e.clientY, + this.canvas.getBoundingClientRect(), + this.renderer + ); + const bufferRow = viewportRowToBufferRow( + y, + this.wasmTerm.getScrollbackLength(), + this.getViewportY() ); - - // Calculate buffer row (same logic as processMouseMove) - const viewportRow = y; - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - let bufferRow: number; - - // Use floored viewportY for buffer mapping (must match renderer & selection) - const rawViewportYForClick = this.getViewportY(); - const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick)); - - if (viewportYForClick > 0) { - if (viewportRow < viewportYForClick) { - bufferRow = scrollbackLength - viewportYForClick + viewportRow; - } else { - const screenRow = viewportRow - viewportYForClick; - bufferRow = scrollbackLength + screenRow; - } - } else { - bufferRow = scrollbackLength + viewportRow; - } // Get the link at this position const link = await this.linkDetector.getLinkAt(x, bufferRow); @@ -1731,7 +1419,7 @@ export class Terminal implements ITerminalCore { }; /** - * Handle wheel events for scrolling (Phase 2) + * Handle wheel events for scrolling */ private handleWheel = (e: WheelEvent): void => { // Cancel only pinch and ctrl+wheel, which would zoom the page. A cancelled wheel @@ -1788,7 +1476,10 @@ export class Terminal implements ITerminalCore { ); if (lines !== 0) { const dir = Math.sign(lines); - const key = wheelArrowKey(dir, this.wasmTerm?.getMode(1, false) ?? false); + const key = wheelArrowKey( + dir, + this.wasmTerm?.getMode(APP_CURSOR_KEYS_MODE, false) ?? false + ); this.tickPacer.push(key, dir, Math.abs(lines), pace); } } @@ -2003,7 +1694,7 @@ export class Terminal implements ITerminalCore { if (!this.isDraggingScrollbar) { this.scrollbarHideTimeout = window.setTimeout(() => { this.hideScrollbar(); - }, this.SCROLLBAR_HIDE_DELAY_MS); + }, SCROLLBAR_HIDE_DELAY_MS); } } @@ -2028,7 +1719,7 @@ export class Terminal implements ITerminalCore { const startTime = Date.now(); const animate = () => { const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); + const progress = Math.min(elapsed / SCROLLBAR_FADE_DURATION_MS, 1); this.scrollbarOpacity = progress; // Trigger render to show updated opacity @@ -2051,7 +1742,7 @@ export class Terminal implements ITerminalCore { const startOpacity = this.scrollbarOpacity; const animate = () => { const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); + const progress = Math.min(elapsed / SCROLLBAR_FADE_DURATION_MS, 1); this.scrollbarOpacity = startOpacity * (1 - progress); // Trigger render to show updated opacity @@ -2109,10 +1800,6 @@ export class Terminal implements ITerminalCore { this.titleChangeEmitter.fire(title); } - // ============================================================================ - // Terminal Modes - // ============================================================================ - /** * Query terminal mode state * diff --git a/src/client/vendor/tests/addons/fit.test.ts b/src/client/vendor/tests/addons/fit.test.ts new file mode 100644 index 0000000..8ea542e --- /dev/null +++ b/src/client/vendor/tests/addons/fit.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { FitAddon, fitDimensions } from '../../ghostty-web/addons/fit'; + +const NONE = { top: 0, right: 0, bottom: 0, left: 0 }; +const pad = (p: number) => ({ top: p, right: p, bottom: p, left: p }); + +describe('fitDimensions', () => { + test('uses the whole container width, with no scrollbar reserve', () => { + // 800px / 10px = exactly 80 columns; a 15px reserve would give 78. + expect(fitDimensions(800, 400, NONE, 10, 20)).toEqual({ cols: 80, rows: 20 }); + }); + + test('floors to whole cells', () => { + expect(fitDimensions(809, 419, NONE, 10, 20)).toEqual({ cols: 80, rows: 20 }); + expect(fitDimensions(799, 399, NONE, 10, 20)).toEqual({ cols: 79, rows: 19 }); + }); + + test('handles fractional cell sizes', () => { + expect(fitDimensions(1000, 500, NONE, 8.4, 17)).toEqual({ cols: 119, rows: 29 }); + }); + + test('subtracts the padding on each side', () => { + // 800px - 2 x 10px = 780px -> 78 cols; 400px - 2 x 10px = 380px -> 19 rows. + expect(fitDimensions(800, 400, pad(10), 10, 20)).toEqual({ cols: 78, rows: 19 }); + // Sides are independent: 800 - 5 - 15 = 780 -> 78; 400 - 0 - 40 = 360 -> 18. + expect(fitDimensions(800, 400, { top: 0, right: 15, bottom: 40, left: 5 }, 10, 20)).toEqual({ + cols: 78, + rows: 18, + }); + }); + + test('never goes below the minimum grid', () => { + expect(fitDimensions(5, 5, NONE, 10, 20)).toEqual({ cols: 2, rows: 1 }); + expect(fitDimensions(100, 100, pad(60), 10, 20)).toEqual({ cols: 2, rows: 1 }); + }); +}); + +// webtty sizes the grid from proposeDimensions alone (ADR 033). +describe('FitAddon.proposeDimensions', () => { + let dom: DomWindow; + beforeEach(() => { + dom = createDomWindow(); + }); + afterEach(() => dom.close()); + + /** A container with a client size, its padding read from its computed style. */ + function container(width: number, height: number, padding = '10px') { + const element = dom.document.createElement('div'); + Object.defineProperty(element, 'clientWidth', { value: width }); + Object.defineProperty(element, 'clientHeight', { value: height }); + element.style.padding = padding; + // Computed styles are read for elements in the document + dom.document.body.appendChild(element); + return element; + } + const renderer = (width: number, height: number) => ({ + getMetrics: () => ({ width, height }), + }); + const addon = () => new FitAddon((element) => dom.window.getComputedStyle(element)); + + test("fits the element's client size, less its padding, in renderer cells", () => { + const fit = addon(); + expect(fit.proposeDimensions()).toBeUndefined(); + fit.activate({ element: container(820, 420), renderer: renderer(10, 20) }); + expect(fit.proposeDimensions()).toEqual({ cols: 80, rows: 20 }); + fit.dispose(); + expect(fit.proposeDimensions()).toBeUndefined(); + }); + + test('reads each side of the padding, and none as 0', () => { + const fit = addon(); + fit.activate({ element: container(800, 400, '0 15px 40px 5px'), renderer: renderer(10, 20) }); + expect(fit.proposeDimensions()).toEqual({ cols: 78, rows: 18 }); + fit.activate({ element: container(800, 400, ''), renderer: renderer(10, 20) }); + expect(fit.proposeDimensions()).toEqual({ cols: 80, rows: 20 }); + }); + + test.each([ + ['no element', () => ({ renderer: renderer(10, 20) })], + ['no renderer (before open)', () => ({ element: container(800, 400) })], + [ + 'a renderer with no cell yet', + () => ({ element: container(800, 400), renderer: renderer(0, 20) }), + ], + ['a zero-height cell', () => ({ element: container(800, 400), renderer: renderer(10, 0) })], + ])('proposes nothing with %s', (_name, terminal) => { + const fit = addon(); + fit.activate(terminal()); + expect(fit.proposeDimensions()).toBeUndefined(); + }); + + test('proposes nothing for a container not laid out (zero size)', () => { + const fit = addon(); + fit.activate({ element: container(0, 400), renderer: renderer(10, 20) }); + expect(fit.proposeDimensions()).toBeUndefined(); + fit.activate({ element: container(800, 0), renderer: renderer(10, 20) }); + expect(fit.proposeDimensions()).toBeUndefined(); + }); + + test("reads the window's computed style by default", () => { + const win = globalThis.window; + const read: unknown[] = []; + globalThis.window = { + getComputedStyle: (element: unknown) => { + read.push(element); + return { getPropertyValue: () => '10px' }; + }, + } as unknown as Window & typeof globalThis; + try { + const element = container(820, 420); + const fit = new FitAddon(); + fit.activate({ element, renderer: renderer(10, 20) }); + expect(fit.proposeDimensions()).toEqual({ cols: 80, rows: 20 }); + expect(read).toEqual([element]); + } finally { + globalThis.window = win; + } + }); +}); diff --git a/src/client/vendor/tests/buffer.test.ts b/src/client/vendor/tests/buffer.test.ts index 90d62f5..e9c22ec 100644 --- a/src/client/vendor/tests/buffer.test.ts +++ b/src/client/vendor/tests/buffer.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { BufferNamespace } from '../ghostty-web/buffer'; -import type { Terminal } from '../ghostty-web/terminal'; +import { type BufferEngine, BufferNamespace } from '../ghostty-web/buffer'; import { loadGhostty } from './support/wasm'; // The buffer API is what the link providers and the kitty graphics read: the @@ -10,7 +9,7 @@ describe('BufferNamespace', () => { const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(10, 2); wasmTerm.write('old\r\nmid\r\n\x1b]8;;https://a.b\x1b\\ln\x1b]8;;\x1b\\k'); - const buffer = new BufferNamespace({ wasmTerm } as unknown as Terminal); + const buffer = new BufferNamespace({ wasmTerm }); const active = buffer.active; expect(active.type).toBe('normal'); expect(active.length).toBe(3); @@ -33,4 +32,40 @@ describe('BufferNamespace', () => { expect(buffer.active.length).toBe(2); wasmTerm.free(); }); + + test('reads an empty normal screen before open() and after dispose()', () => { + const buffer = new BufferNamespace({}); + const active = buffer.active; + expect(active.type).toBe('normal'); + expect([active.cursorX, active.cursorY, active.length]).toEqual([0, 0, 0]); + expect(active.getLine(0)).toBeUndefined(); + // The two buffers are made once and kept + expect(buffer.normal).toBe(active); + expect(buffer.alternate).toBe(buffer.alternate); + }); + + test('a cell past the end of the decoded row is blank, inside the line length', async () => { + const ghostty = await loadGhostty(); + const wasmTerm = ghostty.createTerminal(10, 2); + wasmTerm.write('ab'); + // An engine that decodes only the first two cells of the row + const engine: BufferEngine = { + cols: 10, + rows: 2, + isAlternateScreen: () => false, + getCursor: () => wasmTerm.getCursor(), + getScrollbackLength: () => 0, + getScrollbackLine: () => null, + getLine: (y) => wasmTerm.getLine(y)?.slice(0, 2) ?? null, + }; + const buffer = new BufferNamespace({ wasmTerm: engine }); + const line = buffer.active.getLine(0); + expect(line?.length).toBe(10); + expect([line?.getCell(1)?.getCodepoint(), line?.getCell(5)?.getCodepoint()]).toEqual([ + 'b'.codePointAt(0), + 0, + ]); + expect([line?.getCell(-1), line?.getCell(10)]).toEqual([undefined, undefined]); + wasmTerm.free(); + }); }); diff --git a/src/client/vendor/tests/core/options-proxy.test.ts b/src/client/vendor/tests/core/options-proxy.test.ts new file mode 100644 index 0000000..4c0d9cc --- /dev/null +++ b/src/client/vendor/tests/core/options-proxy.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from 'bun:test'; +import { + createOptionsProxy, + parseThemeColor, + TERMINAL_DEFAULTS, + toEngineConfig, + withDefaults, +} from '../../ghostty-web/core/options-proxy'; +import type { ITerminalOptions } from '../../ghostty-web/interfaces'; + +// Terminal's options: the defaults, the runtime-change proxy and the engine config. + +describe('withDefaults', () => { + test('fills every option not given with its default, the engine dropped', () => { + const options = withDefaults({ ghostty: {} as never }); + expect(options).toEqual({ ...TERMINAL_DEFAULTS, theme: {}, scrollbackLines: undefined }); + expect('ghostty' in options).toBe(false); + }); + + test('keeps the options given, falsy ones included', () => { + const theme = { background: '#000000' }; + expect( + withDefaults({ + ghostty: {} as never, + cols: 100, + cursorStyle: 'bar', + theme, + scrollbackLines: 0, + copyOnSelect: false, + mouseScrollSpeed: 0, + rightClickBehavior: 'paste', + }), + ).toMatchObject({ + cols: 100, + rows: 24, + cursorStyle: 'bar', + theme, + scrollbackLines: 0, + copyOnSelect: false, + mouseScrollSpeed: 0, + rightClickBehavior: 'paste', + }); + }); + + test('gives each terminal its own theme object', () => { + expect(withDefaults({ ghostty: {} as never }).theme).not.toBe( + withDefaults({ ghostty: {} as never }).theme, + ); + }); + + test('lists the keys in the order Terminal has always kept them', () => { + expect(Object.keys(withDefaults({ ghostty: {} as never }))).toEqual([ + 'cols', + 'rows', + 'cursorBlink', + 'cursorStyle', + 'theme', + 'scrollbackLines', + 'fontSize', + 'fontFamily', + 'allowTransparency', + 'convertEol', + 'disableStdin', + 'mouseScrollSpeed', + 'copyOnSelect', + 'rightClickBehavior', + 'clipboardWrite', + 'desktopNotifications', + ]); + }); +}); + +describe('createOptionsProxy', () => { + test('stores each assignment, then reports it with the value it replaced', () => { + const changes: unknown[] = []; + const values = { fontSize: 15, fontFamily: 'monospace' }; + const options = createOptionsProxy(values, (key, value, old) => { + // The new value is already stored when the change is reported + changes.push([key, value, old, options[key]]); + }); + options.fontSize = 18; + options.fontSize = 18; + expect(changes).toEqual([ + ['fontSize', 18, 15, 18], + ['fontSize', 18, 18, 18], + ]); + expect(values.fontSize).toBe(18); + }); + + test('reads go straight to the values', () => { + const values = { cols: 80 }; + const options = createOptionsProxy(values, () => {}); + values.cols = 120; + expect(options.cols).toBe(120); + }); +}); + +describe('parseThemeColor', () => { + test.each([ + ['#d4d4d4', 0xd4d4d4], + ['#000000', 0], + ['#fff', 0xffffff], + ['#0a1', 0x00aa11], + ['rgb(1, 2, 3)', 0x010203], + ['rgb(255,0,128)', 0xff0080], + ['#zzzzzz', undefined], + ['red', undefined], + ['', undefined], + [undefined, undefined], + ])('%p is %p', (color, expected) => { + expect(parseThemeColor(color)).toBe(expected); + }); +}); + +describe('toEngineConfig', () => { + const config = (options: Partial<ITerminalOptions>) => + toEngineConfig(withDefaults({ ghostty: {} as never, ...options })); + + test('passes scrollbackLines through to the engine, unset when not given (ADR 046)', () => { + expect(config({ scrollbackLines: 10000 }).scrollbackLines).toBe(10000); + expect(config({ scrollbackLines: 0 }).scrollbackLines).toBe(0); + expect(config({ theme: {} }).scrollbackLines).toBeUndefined(); + }); + + test('leaves unset and invalid colours undefined, so #000000 is black (ADR 042)', () => { + const engine = config({ + theme: { foreground: '#d4d4d4', background: '#000000', red: 'nope', green: '#0dbc79' }, + }); + expect(engine).toMatchObject({ fgColor: 0xd4d4d4, bgColor: 0, cursorColor: undefined }); + expect(engine.palette?.slice(0, 3)).toEqual([undefined, undefined, 0x0dbc79]); + }); + + test('puts the 16 ANSI colours in palette order, the bright ones last', () => { + const engine = config({ + theme: { black: '#000001', white: '#000008', brightWhite: '#000010' }, + }); + expect(engine.palette).toHaveLength(16); + expect([engine.palette?.[0], engine.palette?.[7], engine.palette?.[15]]).toEqual([1, 8, 16]); + }); + + test('passes cursorStyle and cursorBlink as the engine default cursor (ADR 049)', () => { + expect(config({ cursorStyle: 'bar', cursorBlink: true })).toMatchObject({ + cursorStyle: 'bar', + cursorBlink: true, + }); + expect(config({})).toMatchObject({ cursorStyle: 'block', cursorBlink: false }); + }); + + test('passes clipboardWrite and desktopNotifications, on by default (ADR 052, ADR 056)', () => { + expect(config({})).toMatchObject({ clipboardWrite: true, desktopNotifications: true }); + expect(config({ clipboardWrite: false, desktopNotifications: false })).toMatchObject({ + clipboardWrite: false, + desktopNotifications: false, + }); + }); +}); diff --git a/src/client/vendor/tests/core/terminal-geometry.test.ts b/src/client/vendor/tests/core/terminal-geometry.test.ts new file mode 100644 index 0000000..0e53d99 --- /dev/null +++ b/src/client/vendor/tests/core/terminal-geometry.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from 'bun:test'; +import { + cursorCellOffset, + deviceCellPixels, + linkViewportRange, + mouseCursorFor, + mouseSurface, + pointerCell, + setStylePx, + viewportRowToBufferRow, +} from '../../ghostty-web/core/terminal-geometry'; +import type { ILink } from '../../ghostty-web/link-types'; + +// Terminal's pixel and row maths: one mapping for the mouse, the links and the textarea. + +describe('mouseCursorFor', () => { + const link: ILink = { + text: 'https://example.com', + range: { start: { x: 0, y: 0 }, end: { x: 18, y: 0 } }, + activate: () => {}, + }; + + test('uses the default arrow when no link is hovered', () => { + expect(mouseCursorFor(undefined)).toBe('default'); + }); + + test('uses the pointer over a link', () => { + expect(mouseCursorFor(link)).toBe('pointer'); + }); +}); + +describe('pointerCell', () => { + const cell = { charWidth: 8, charHeight: 16, scrollOffset: 0 }; + + test.each([ + [0, 0, { x: 0, y: 0 }], + [7.9, 15.9, { x: 0, y: 0 }], + [8, 16, { x: 1, y: 1 }], + [41, 17, { x: 5, y: 1 }], + ])('(%p, %p) on a canvas at the origin is cell %o', (clientX, clientY, expected) => { + expect(pointerCell(clientX, clientY, { left: 0, top: 0 }, cell)).toEqual(expected); + }); + + test('measures from the canvas corner', () => { + expect(pointerCell(110, 66, { left: 100, top: 50 }, cell)).toEqual({ x: 1, y: 1 }); + }); + + test('rows start the scroll offset below the canvas top (ADR 043)', () => { + expect(pointerCell(0, 20, { left: 0, top: 0 }, { ...cell, scrollOffset: 6 })).toEqual({ + x: 0, + y: 0, + }); + }); + + test('a position left of or above the grid is a negative cell', () => { + expect(pointerCell(-1, -1, { left: 0, top: 0 }, cell)).toEqual({ x: -1, y: -1 }); + }); +}); + +describe('viewportRowToBufferRow', () => { + test.each([ + // At the bottom: the screen follows the scrollback + [0, 100, 0, 100], + [5, 100, 0, 105], + // Scrolled back: rows above the view's depth are scrollback, below it the screen + [0, 100, 10, 90], + [9, 100, 10, 99], + [10, 100, 10, 100], + [12, 100, 10, 102], + // A smooth-scroll offset is floored, as the renderer floors it + [3, 100, 10.7, 93], + // A negative view never shows rows past the screen + [3, 100, -2, 103], + ])( + 'row %p with %p of scrollback, %p back, is buffer row %p', + (row, scrollback, view, expected) => { + expect(viewportRowToBufferRow(row, scrollback, view)).toBe(expected); + }, + ); +}); + +describe('linkViewportRange', () => { + const range = (startY: number, endY: number) => ({ + start: { x: 3, y: startY }, + end: { x: 8, y: endY }, + }); + + test('maps a link on screen to its viewport rows', () => { + expect(linkViewportRange(range(101, 101), 100, 0, 24)).toEqual({ + startX: 3, + startY: 1, + endX: 8, + endY: 1, + }); + }); + + test('follows the view into the scrollback, floored', () => { + expect(linkViewportRange(range(95, 96), 100, 10.5, 24)).toEqual({ + startX: 3, + startY: 5, + endX: 8, + endY: 6, + }); + }); + + test('clamps a link that runs off the top or the bottom of the view', () => { + expect(linkViewportRange(range(99, 101), 100, 0, 24)).toMatchObject({ startY: 0, endY: 1 }); + expect(linkViewportRange(range(122, 130), 100, 0, 24)).toMatchObject({ + startY: 22, + endY: 23, + }); + }); + + test('is null when none of the link is in view', () => { + expect(linkViewportRange(range(90, 99), 100, 0, 24)).toBeNull(); + expect(linkViewportRange(range(124, 125), 100, 0, 24)).toBeNull(); + }); +}); + +describe('cursorCellOffset', () => { + const grid = { cols: 80, rows: 24 }; + const cell = { width: 9, height: 18 }; + const canvas = { left: 0, top: 0 }; + + test('puts the spot at the cursor cell', () => { + expect(cursorCellOffset({ x: 10, y: 5 }, 0, grid, cell, canvas)).toEqual({ + left: 90, + top: 90, + }); + }); + + test('offsets by the canvas position inside the padding', () => { + expect(cursorCellOffset({ x: 0, y: 0 }, 0, grid, cell, { left: 12, top: 8 })).toEqual({ + left: 12, + top: 8, + }); + }); + + test('moves down with the scrollback offset', () => { + expect(cursorCellOffset({ x: 0, y: 5 }, 3, grid, cell, canvas).top).toBe(8 * 18); + }); + + test('stays on the last row once the cursor scrolls out of view', () => { + expect(cursorCellOffset({ x: 0, y: 20 }, 100, grid, cell, canvas).top).toBe(23 * 18); + }); + + test('clamps a pending-wrap cursor to the last column', () => { + expect(cursorCellOffset({ x: 80, y: 0 }, 0, grid, cell, canvas).left).toBe(79 * 9); + }); +}); + +describe('deviceCellPixels', () => { + test('is the CSS cell at the device pixel ratio, in whole pixels', () => { + expect(deviceCellPixels(9, 2)).toBe(18); + expect(deviceCellPixels(8.4, 1.25)).toBe(11); + expect(deviceCellPixels(19.2, 1.5)).toBe(29); + }); + + test('never reports less than one pixel', () => { + expect(deviceCellPixels(0, 2)).toBe(1); + expect(deviceCellPixels(0.2, 1)).toBe(1); + }); +}); + +describe('mouseSurface', () => { + // A 9.6 x 19.2 px CSS cell at DPR 1.25 is 12 x 24 device pixels. + const grid = { left: 110, top: 57, cols: 100, rows: 30, cellWidth: 9.6, cellHeight: 19.2 }; + const box = { left: 100, top: 50, right: 110 + 960 + 7, bottom: 57 + 576 + 3 }; + const cellPx = { width: 12, height: 24 }; + const surface = (x: number, y: number) => mouseSurface(x, y, grid, box, cellPx); + + test('is the container in device-pixel cells, the padding around the grid', () => { + expect(surface(0, 0).geometry).toEqual({ + screenWidth: 13 + 1200 + 9, + screenHeight: 9 + 720 + 4, + cellWidth: 12, + cellHeight: 24, + paddingTop: 9, + paddingRight: 9, + paddingBottom: 4, + paddingLeft: 13, + }); + }); + + test('puts cell boundaries exactly where the CSS grid has them, at any column', () => { + // Column 99 (0-based) starts at 110 + 99 * 9.6 = 1060.4 CSS px + const cell = (x: number) => Math.floor((surface(x, 60).x - 13) / 12); + expect(cell(1060.3)).toBe(98); + expect(cell(1060.5)).toBe(99); + expect(cell(110)).toBe(0); + const row = (y: number) => Math.floor((surface(120, y).y - 9) / 24); + expect(row(57 + 29 * 19.2 - 0.1)).toBe(28); + expect(row(57 + 29 * 19.2 + 0.1)).toBe(29); + }); + + test('positions in the padding fall before or after the grid', () => { + expect(surface(101, 51).x).toBeLessThan(13); + expect(surface(101, 51).y).toBeLessThan(9); + expect(surface(1075, 60).x).toBeGreaterThan(13 + 1200); + }); + + test('a grid past the container edge has no negative padding', () => { + const tight = mouseSurface( + 0, + 0, + grid, + { left: 120, top: 60, right: 1000, bottom: 600 }, + cellPx, + ); + expect(tight.geometry).toMatchObject({ + paddingTop: 0, + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, + }); + }); +}); + +describe('setStylePx', () => { + /** An element whose style records each write. */ + function element() { + const writes: string[] = []; + const style = new Proxy( + { left: '', top: '', width: '', height: '' }, + { + set: (target, prop, value) => { + writes.push(`${String(prop)}=${value}`); + return Reflect.set(target, prop, value); + }, + }, + ); + return { el: { style }, writes }; + } + + test('writes a changed value in pixels, and an unchanged one not at all', () => { + const { el, writes } = element(); + setStylePx(el, 'left', 12); + setStylePx(el, 'left', 12); + setStylePx(el, 'height', 18.5); + expect(writes).toEqual(['left=12px', 'height=18.5px']); + }); +}); diff --git a/src/client/vendor/tests/core/viewport.test.ts b/src/client/vendor/tests/core/viewport.test.ts new file mode 100644 index 0000000..a408fd3 --- /dev/null +++ b/src/client/vendor/tests/core/viewport.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; +import { Viewport } from '../../ghostty-web/core/viewport'; + +// The view's place in the scrollback: clamped moves, each announced, the scrollbar shown. + +function viewport(scrollback = 100) { + const calls: string[] = []; + const host = { + length: scrollback, + getScrollbackLength: () => host.length, + scrolled: (y: number) => calls.push(`scroll ${y}`), + showScrollbar: () => calls.push('show'), + }; + return { view: new Viewport(host), host, calls }; +} + +describe('Viewport', () => { + test('starts at the bottom', () => { + expect(viewport().view.y).toBe(0); + }); + + test('scrollLines moves up for negative amounts and down for positive ones', () => { + const { view, calls } = viewport(); + view.scrollLines(-5); + view.scrollLines(2); + expect(view.y).toBe(3); + expect(calls).toEqual(['scroll 5', 'show', 'scroll 3', 'show']); + }); + + test.each([ + [-500, 100], + [500, 0], + ])('scrollLines(%p) stops at the end of the scrollback (%p)', (amount, expected) => { + const { view } = viewport(); + view.y = 50; + view.scrollLines(amount); + expect(view.y).toBe(expected); + }); + + test.each([ + [40, 40], + [-3, 0], + [1000, 100], + [2.5, 2.5], + ])('scrollToLine(%p) shows line %p', (line, expected) => { + const { view } = viewport(); + view.scrollToLine(line); + expect(view.y).toBe(expected); + }); + + test('a move that goes nowhere announces nothing', () => { + const { view, calls } = viewport(); + view.scrollLines(1); + view.scrollToLine(0); + view.scrollToBottom(); + expect(calls).toEqual([]); + }); + + test('scrollToBottom returns to the bottom and announces it', () => { + const { view, calls } = viewport(); + view.scrollToLine(30); + view.scrollToBottom(); + expect(view.y).toBe(0); + expect(calls).toEqual(['scroll 30', 'show', 'scroll 0', 'show']); + }); + + test('with no scrollback the scrollbar is not shown', () => { + const { view, host, calls } = viewport(0); + // A view left over from before a reset is still brought back + view.y = 4; + view.scrollToBottom(); + view.y = 4; + view.scrollLines(1); + expect(calls).toEqual(['scroll 0', 'scroll 0']); + expect(host.length).toBe(0); + }); + + test('setting y moves the view without an announcement', () => { + const { view, calls } = viewport(); + view.y = 7; + expect(view.y).toBe(7); + expect(calls).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/fit.test.ts b/src/client/vendor/tests/fit.test.ts deleted file mode 100644 index 58892a6..0000000 --- a/src/client/vendor/tests/fit.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { FitAddon, fitDimensions } from '../ghostty-web/addons/fit'; -import type { ITerminalCore } from '../ghostty-web/interfaces'; - -const NONE = { top: 0, right: 0, bottom: 0, left: 0 }; -const pad = (p: number) => ({ top: p, right: p, bottom: p, left: p }); - -describe('fitDimensions', () => { - test('uses the whole container width, with no scrollbar reserve', () => { - // 800px / 10px = exactly 80 columns; a 15px reserve would give 78. - expect(fitDimensions(800, 400, NONE, 10, 20)).toEqual({ cols: 80, rows: 20 }); - }); - - test('floors to whole cells', () => { - expect(fitDimensions(809, 419, NONE, 10, 20)).toEqual({ cols: 80, rows: 20 }); - expect(fitDimensions(799, 399, NONE, 10, 20)).toEqual({ cols: 79, rows: 19 }); - }); - - test('handles fractional cell sizes', () => { - expect(fitDimensions(1000, 500, NONE, 8.4, 17)).toEqual({ cols: 119, rows: 29 }); - }); - - test('subtracts the padding on each side', () => { - // 800px - 2 x 10px = 780px -> 78 cols; 400px - 2 x 10px = 380px -> 19 rows. - expect(fitDimensions(800, 400, pad(10), 10, 20)).toEqual({ cols: 78, rows: 19 }); - // Sides are independent: 800 - 5 - 15 = 780 -> 78; 400 - 0 - 40 = 360 -> 18. - expect(fitDimensions(800, 400, { top: 0, right: 15, bottom: 40, left: 5 }, 10, 20)).toEqual({ - cols: 78, - rows: 18, - }); - }); - - test('never goes below the minimum grid', () => { - expect(fitDimensions(5, 5, NONE, 10, 20)).toEqual({ cols: 2, rows: 1 }); - expect(fitDimensions(100, 100, pad(60), 10, 20)).toEqual({ cols: 2, rows: 1 }); - }); -}); - -// webtty sizes the grid from proposeDimensions alone (ADR 033). -describe('FitAddon.proposeDimensions', () => { - test("fits the element's client size, less its padding, in renderer cells", () => { - const win = globalThis.window; - globalThis.window = { - getComputedStyle: () => ({ getPropertyValue: () => '10px' }), - } as unknown as Window & typeof globalThis; - try { - const addon = new FitAddon(); - expect(addon.proposeDimensions()).toBeUndefined(); - addon.activate({ - element: { clientWidth: 820, clientHeight: 420 }, - renderer: { getMetrics: () => ({ width: 10, height: 20 }) }, - } as unknown as ITerminalCore); - expect(addon.proposeDimensions()).toEqual({ cols: 80, rows: 20 }); - addon.dispose(); - expect(addon.proposeDimensions()).toBeUndefined(); - } finally { - globalThis.window = win; - } - }); -}); diff --git a/src/client/vendor/tests/input/mouse-report.test.ts b/src/client/vendor/tests/input/mouse-report.test.ts index 1e2e248..a145620 100644 --- a/src/client/vendor/tests/input/mouse-report.test.ts +++ b/src/client/vendor/tests/input/mouse-report.test.ts @@ -8,6 +8,7 @@ import { type MouseTrackingConfig, motionButton, mouseMods, + mouseReportText, } from '../../ghostty-web/input/mouse-report'; // Mouse reports to the app (ADR 049, ADR 051): which presses, moves and releases reach @@ -220,3 +221,16 @@ describe('MouseReporter', () => { expect(sent).toEqual([]); }); }); + +describe('mouseReportText', () => { + test('passes a UTF-8 report through as text', () => { + expect(mouseReportText(new TextEncoder().encode('\x1b[<0;1;1M'))).toBe('\x1b[<0;1;1M'); + expect(mouseReportText(new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0xc3, 0xa8, 0x23]))).toBe( + '\x1b[M è#', + ); + }); + + test('drops a report with raw bytes above 0x7f, which text frames cannot carry', () => { + expect(mouseReportText(new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0x21]))).toBe(''); + }); +}); diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index ac0a526..dab49b0 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -3,129 +3,9 @@ import type { TerminalEffects } from '../ghostty-web/ghostty'; import { InputHandler } from '../ghostty-web/input-handler'; import { LinkDetector } from '../ghostty-web/link-detector'; import { OSC8LinkProvider } from '../ghostty-web/providers/osc8-link-provider'; -import { - cursorCellOffset, - deviceCellPixels, - mouseCursorFor, - mouseReportText, - mouseSurface, - Terminal, -} from '../ghostty-web/terminal'; -import type { GhosttyTerminalConfig, ILink } from '../ghostty-web/types'; +import { Terminal } from '../ghostty-web/terminal'; import { loadGhostty } from './support/wasm'; -describe('mouseCursorFor', () => { - const link: ILink = { - text: 'https://example.com', - range: { start: { x: 0, y: 0 }, end: { x: 18, y: 0 } }, - activate: () => {}, - }; - - test('uses the default arrow when no link is hovered', () => { - expect(mouseCursorFor(undefined)).toBe('default'); - }); - - test('uses the pointer over a link', () => { - expect(mouseCursorFor(link)).toBe('pointer'); - }); -}); - -describe('cursorCellOffset', () => { - const grid = { cols: 80, rows: 24 }; - const cell = { width: 9, height: 18 }; - const canvas = { left: 0, top: 0 }; - - test('puts the spot at the cursor cell', () => { - expect(cursorCellOffset({ x: 10, y: 5 }, 0, grid, cell, canvas)).toEqual({ - left: 90, - top: 90, - }); - }); - - test('offsets by the canvas position inside the padding', () => { - expect(cursorCellOffset({ x: 0, y: 0 }, 0, grid, cell, { left: 12, top: 8 })).toEqual({ - left: 12, - top: 8, - }); - }); - - test('moves down with the scrollback offset', () => { - expect(cursorCellOffset({ x: 0, y: 5 }, 3, grid, cell, canvas).top).toBe(8 * 18); - }); - - test('stays on the last row once the cursor scrolls out of view', () => { - expect(cursorCellOffset({ x: 0, y: 20 }, 100, grid, cell, canvas).top).toBe(23 * 18); - }); - - test('clamps a pending-wrap cursor to the last column', () => { - expect(cursorCellOffset({ x: 80, y: 0 }, 0, grid, cell, canvas).left).toBe(79 * 9); - }); -}); - -describe('deviceCellPixels', () => { - test('is the CSS cell at the device pixel ratio, in whole pixels', () => { - expect(deviceCellPixels(9, 2)).toBe(18); - expect(deviceCellPixels(8.4, 1.25)).toBe(11); - expect(deviceCellPixels(19.2, 1.5)).toBe(29); - }); - - test('never reports less than one pixel', () => { - expect(deviceCellPixels(0, 2)).toBe(1); - expect(deviceCellPixels(0.2, 1)).toBe(1); - }); -}); - -describe('mouseSurface', () => { - // A 9.6 x 19.2 px CSS cell at DPR 1.25 is 12 x 24 device pixels. - const grid = { left: 110, top: 57, cols: 100, rows: 30, cellWidth: 9.6, cellHeight: 19.2 }; - const box = { left: 100, top: 50, right: 110 + 960 + 7, bottom: 57 + 576 + 3 }; - const cellPx = { width: 12, height: 24 }; - const surface = (x: number, y: number) => mouseSurface(x, y, grid, box, cellPx); - - test('is the container in device-pixel cells, the padding around the grid', () => { - expect(surface(0, 0).geometry).toEqual({ - screenWidth: 13 + 1200 + 9, - screenHeight: 9 + 720 + 4, - cellWidth: 12, - cellHeight: 24, - paddingTop: 9, - paddingRight: 9, - paddingBottom: 4, - paddingLeft: 13, - }); - }); - - test('puts cell boundaries exactly where the CSS grid has them, at any column', () => { - // Column 99 (0-based) starts at 110 + 99 * 9.6 = 1060.4 CSS px - const cell = (x: number) => Math.floor((surface(x, 60).x - 13) / 12); - expect(cell(1060.3)).toBe(98); - expect(cell(1060.5)).toBe(99); - expect(cell(110)).toBe(0); - const row = (y: number) => Math.floor((surface(120, y).y - 9) / 24); - expect(row(57 + 29 * 19.2 - 0.1)).toBe(28); - expect(row(57 + 29 * 19.2 + 0.1)).toBe(29); - }); - - test('positions in the padding fall before or after the grid', () => { - expect(surface(101, 51).x).toBeLessThan(13); - expect(surface(101, 51).y).toBeLessThan(9); - expect(surface(1075, 60).x).toBeGreaterThan(13 + 1200); - }); -}); - -describe('mouseReportText', () => { - test('passes a UTF-8 report through as text', () => { - expect(mouseReportText(new TextEncoder().encode('\x1b[<0;1;1M'))).toBe('\x1b[<0;1;1M'); - expect(mouseReportText(new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0xc3, 0xa8, 0x23]))).toBe( - '\x1b[M è#', - ); - }); - - test('drops a report with raw bytes above 0x7f, which text frames cannot carry', () => { - expect(mouseReportText(new Uint8Array([0x1b, 0x5b, 0x4d, 0x20, 0x80, 0x21]))).toBe(''); - }); -}); - describe('Terminal wheel in mouse-tracking apps', () => { // A 10 px x 20 px CSS cell at DPR 1, the grid at (5, 5) in a container at (0, 0). async function wheelTerm(modes: string) { @@ -188,59 +68,48 @@ describe('Terminal wheel in mouse-tracking apps', () => { }); }); -describe('Terminal scrollback options', () => { - // The engine config the terminal builds from its options (ADR 046). - const wasmConfig = (options: object) => - ( - new Terminal({ ghostty: {} as never, ...options }) as unknown as { - buildWasmConfig(): GhosttyTerminalConfig | undefined; - } - ).buildWasmConfig(); - - test('passes scrollbackLines through to the engine', () => { - expect(wasmConfig({ scrollbackLines: 10000 })?.scrollbackLines).toBe(10000); - expect(wasmConfig({ scrollbackLines: 0 })?.scrollbackLines).toBe(0); - }); - - test('leaves it unset when not given', () => { - expect(wasmConfig({ theme: {} })?.scrollbackLines).toBeUndefined(); - }); - - test('leaves unset and invalid colours undefined, so #000000 is black', () => { - const config = wasmConfig({ - theme: { foreground: '#d4d4d4', background: '#000000', red: 'nope', green: '#0dbc79' }, - }); - expect(config).toMatchObject({ fgColor: 0xd4d4d4, bgColor: 0, cursorColor: undefined }); - expect(config?.palette?.slice(0, 3)).toEqual([undefined, undefined, 0x0dbc79]); - }); -}); - -describe('Terminal cursor style', () => { - // The configured shape is the engine's default, which DECSCUSR Ps 0 and RIS restore; - // the output is not scanned (ADR 049). - test('passes cursorStyle and cursorBlink to the engine as its default', () => { - const term = new Terminal({ ghostty: {} as never, cursorStyle: 'bar', cursorBlink: true }); - const config = ( - term as unknown as { buildWasmConfig(): GhosttyTerminalConfig } - ).buildWasmConfig(); - expect(config).toMatchObject({ cursorStyle: 'bar', cursorBlink: true }); - }); - - test('a cursor option change sets the engine default', () => { +describe('Terminal options', () => { + // The configured cursor shape is the engine's default, which DECSCUSR Ps 0 and RIS + // restore; the output is not scanned (ADR 049). + function cursorTerm() { const term = new Terminal({ ghostty: {} as never }); const defaults: unknown[] = []; const internals = term as unknown as { isOpen: boolean; wasmTerm: object }; internals.wasmTerm = { setDefaultCursor: (style: string, blink: boolean) => defaults.push([style, blink]), }; + return { term, defaults, internals }; + } + + test('a cursor option change after open() sets the engine default', () => { + const { term, defaults, internals } = cursorTerm(); internals.isOpen = true; term.options.cursorStyle = 'underline'; term.options.cursorBlink = true; + // Setting the value it already has applies nothing + term.options.cursorBlink = true; expect(defaults).toEqual([ ['underline', false], ['underline', true], ]); }); + + test('a change before open() is kept and applied by nothing', () => { + const { term, defaults } = cursorTerm(); + term.options.cursorStyle = 'bar'; + expect(term.options.cursorStyle).toBe('bar'); + expect(defaults).toEqual([]); + }); + + test('fills every option it is not given with its default', () => { + const term = new Terminal({ ghostty: {} as never, fontSize: 20 }); + expect([term.options.fontSize, term.options.fontFamily, term.cols, term.rows]).toEqual([ + 20, + 'monospace', + 80, + 24, + ]); + }); }); describe('Terminal paste', () => { @@ -828,17 +697,6 @@ describe('Terminal clipboard writes', () => { ).clipboardWriteEmitter.fire('x'); expect(copied).toEqual([]); }); - - test('passes clipboardWrite to the engine, on by default', () => { - const wasmConfig = (options: object) => - ( - new Terminal({ ghostty: {} as never, ...options }) as unknown as { - buildWasmConfig(): GhosttyTerminalConfig; - } - ).buildWasmConfig(); - expect(wasmConfig({}).clipboardWrite).toBe(true); - expect(wasmConfig({ clipboardWrite: false }).clipboardWrite).toBe(false); - }); }); describe('Terminal desktop notifications', () => { @@ -861,17 +719,6 @@ describe('Terminal desktop notifications', () => { write('plain output'); expect(shown).toHaveLength(2); }); - - test('passes desktopNotifications to the engine, on by default', () => { - const wasmConfig = (options: object) => - ( - new Terminal({ ghostty: {} as never, ...options }) as unknown as { - buildWasmConfig(): GhosttyTerminalConfig; - } - ).buildWasmConfig(); - expect(wasmConfig({}).desktopNotifications).toBe(true); - expect(wasmConfig({ desktopNotifications: false }).desktopNotifications).toBe(false); - }); }); describe('Terminal input and the selection', () => { From 2702301619edd05ec3b0f8399fb9db2ac9c4c201 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 19:52:07 -0400 Subject: [PATCH 250/295] refactor(client): terminal split, part 2: focus, find, render loop, write pipeline Terminal's ordered pipelines become controllers in core/ (ADR 061), each reaching the terminal through a narrow host in core/host-types.ts; terminal.ts goes from 1822 to 1563 lines: - focus-tracker.ts: FocusTracker keeps focus off the container and sends the mode 1004 reports (ADR 053): attach() adds the six focus listeners in open()'s order, dispose() removes the container's three (a DisposableStore); the window and document ones are added as before and leak until ADR 065 (detachWindow keeps the unreachable removal). followMode() is the write's 1004 edge, resync() and reset() as before. - find-controller.ts: FindController holds the needle, the stale and read-backoff state, the anchor and the last count (ADR 055); step(now) is the render loop's find step, its read timing on an injected clock. - write-pipeline.ts: WritePipeline.write keeps the order clear-at-ground, engine write, replies, focus-mode edge, effects (bell, title, clipboard, notifications), link cache, auto-scroll, callback frame, echo render; clearAtGround, drainReplies (was processTerminalResponses), awaitEcho, reset. writeQueue and flushWriteQueue, never filled, are dropped. - render-loop.ts: RenderLoop keeps beforeRender, find step, render, replies, textarea anchor; one frame callback made once (no per-frame closure), frames and clock injected (browserFrames reads the globals when called). Listener targets, order and flags are unchanged: the listener snapshot passes as is. Tests: new vendor/tests/core/ focus-tracker (listener order on one timeline, teardown, every 1004 edge, on happy-dom and recording targets), find-controller (stepping, select, anchor, backoff), write-pipeline (the order against a recording engine, clear at ground on a real engine) and render-loop (order on a ManualClock, cancel, one callback). The focus and pure find tests move there from terminal.test.ts; its remaining casts name the new parts. Ratchet: terminal.ts 1563, terminal.test.ts 847. --- scripts/architecture/ratchet-allowlist.ts | 4 +- src/client/vendor/ghostty-web/PROVENANCE.md | 9 +- .../ghostty-web/core/find-controller.ts | 123 ++++++ .../vendor/ghostty-web/core/focus-tracker.ts | 155 +++++++ .../vendor/ghostty-web/core/host-types.ts | 110 ++++- .../vendor/ghostty-web/core/render-loop.ts | 64 +++ .../vendor/ghostty-web/core/write-pipeline.ts | 125 ++++++ src/client/vendor/ghostty-web/terminal.ts | 413 ++++-------------- .../vendor/tests/core/find-controller.test.ts | 182 ++++++++ .../vendor/tests/core/focus-tracker.test.ts | 263 +++++++++++ .../vendor/tests/core/render-loop.test.ts | 94 ++++ .../vendor/tests/core/write-pipeline.test.ts | 225 ++++++++++ src/client/vendor/tests/terminal.test.ts | 180 +------- 13 files changed, 1439 insertions(+), 508 deletions(-) create mode 100644 src/client/vendor/ghostty-web/core/find-controller.ts create mode 100644 src/client/vendor/ghostty-web/core/focus-tracker.ts create mode 100644 src/client/vendor/ghostty-web/core/render-loop.ts create mode 100644 src/client/vendor/ghostty-web/core/write-pipeline.ts create mode 100644 src/client/vendor/tests/core/find-controller.test.ts create mode 100644 src/client/vendor/tests/core/focus-tracker.test.ts create mode 100644 src/client/vendor/tests/core/render-loop.test.ts create mode 100644 src/client/vendor/tests/core/write-pipeline.test.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index d5174ac..64487be 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -17,10 +17,10 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, 'src/client/vendor/ghostty-web/terminal.ts': { - lines: 1822, + lines: 1563, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/terminal.test.ts': { lines: 1003, step: VENDOR_TESTS }, + 'src/client/vendor/tests/terminal.test.ts': { lines: 847, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, }, diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 7555a3d..1cda5a6 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -52,6 +52,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `input-handler.ts`: the listeners go into one `DisposableStore` (`listen`), so `dispose()` removes them all as before; the per-handler `isDisposed` checks, which no removed listener could reach, and the `keypressListener` field, which nothing ever set, are removed (ADR 061). The `EchoGuard` (`input/text-input.ts`) reads an injected clock, `performance.now()` by default as before. - `input-handler.ts`: `InputHandler` takes one `InputHandlerOptions` object instead of seven positional arguments (`ghostty` narrowed to its `createKeyEncoder`, `container`, `onData`, `getMode`, `onCopy`, `inputElement`, `mouseConfig`), plus `now`, the echo guard's clock (ADR 061). - `buffer.ts`, `addons/fit.ts`, `interfaces.ts`: the buffer API and `FitAddon` stop reaching into `Terminal` through `any` (ADR 061). `BufferNamespace` reads the engine of an `IBufferSource` (`{ wasmTerm? }`, the engine narrowed to `BufferEngine`), and `ITerminalCore` gains the optional `renderer` (its `getMetrics`) that `FitAddon` reads. `FitAddon` takes an optional computed-style reader, `window.getComputedStyle` by default, so tests inject one. +- `terminal.ts`: `writeQueue` and `flushWriteQueue`, which nothing ever filled, are removed with the split of the write pipeline (ADR 061). - `terminal.ts`: `viewportY` is an accessor over the new `Viewport` (`core/viewport.ts`), reading and setting its `y` as the field was read and set; `options` is typed as the stored values (`TerminalOptionValues`: every option but `ghostty`, `scrollbackLines` optional) instead of `Required<ITerminalOptions>`, and its proxy no longer goes through `any` (ADR 061). ## webtty-original files @@ -59,6 +60,7 @@ webtty edits to these files are tracked in git from the commit that added them; Not from upstream; each starts with, or will start with when it is next touched, `// webtty (ADR NNN), not from upstream.` - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). +- `core/focus-tracker.ts` (ADR 053), `core/find-controller.ts` (ADR 055): `Terminal`'s focus reports and find, which webtty added, moved out of `terminal.ts` (ADR 061). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. - `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts`, `render/row-repainter.ts`, `render/scroll-tracker.ts` (ADR 043), `render/decoration-tracker.ts` (ADR 043, ADR 055), `render/viewport-clip.ts` (ADR 040, ADR 043): renderer parts webtty added, moved out of `renderer.ts` (ADR 061). - `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043, ADR 061): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. @@ -70,7 +72,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. -- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`) and the view's place in the scrollback (`viewport.ts`). `terminal.ts` is the facade over them and re-exports what moved. +- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`) and the render loop (`render-loop.ts`). `terminal.ts` is the facade over them and re-exports what moved. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -143,3 +145,8 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `terminal.ts`: the constructor's option defaults (now `TERMINAL_DEFAULTS`, `withDefaults`) and options `Proxy` (now `createOptionsProxy`), `parseColor` (now `parseThemeColor`) and `buildWasmConfig` (now `toEngineConfig`); `terminal.ts` re-exports `TERMINAL_DEFAULTS` and `toEngineConfig` | `core/options-proxy.ts` | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | | `terminal.ts`: the `viewportY` field and the clamping, `onScroll` and scrollbar calls of `scrollLines`, `scrollToBottom` and `scrollToLine` | `core/viewport.ts` (`Viewport`) | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | | `buffer.ts`, `addons/fit.ts`: what they read of `Terminal` through `any` (webtty) | `buffer.ts` (`IBufferSource`, `BufferEngine`), `interfaces.ts` (`ITerminalCore.renderer`), `core/host-types.ts` (`ViewportHost`) | refactor(client): terminal split, part 1: constants, geometry, options, viewport, host interfaces | +| `terminal.ts`: `handleFocusIn`, `handleWindowFocus` (now `redirect`, `windowFocused`; webtty), `handleFocusGained`, `handleFocusLost`, `handleWindowBlur`, `handleVisibilityChange` (now `gained`, `lost`, `windowBlurred`, `visibilityChanged`), `setFocused`, `reportFocus` (now `report`), the body of `resyncFocus` (now `resync`), the mode 1004 edge of `writeInternal` (now `followMode`), the `focused` and `focusReporting` fields, and the focus listeners' registration in `open()` and removal in `cleanupComponents` (now `attach`, `dispose`, `detachWindow`) (webtty, ADR 053) | `core/focus-tracker.ts` (`FocusTracker`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | +| `terminal.ts`: the find fields (`findNeedle`, `findStale`, `findReadAt`, `findAnchor`, `findState`), the body of `find` and `selectMatch` (now `select`), `stepFind` (now `step`), `reportFind` (now `report`) and the needle set again in `reset` (now `restart`) (webtty, ADR 055) | `core/find-controller.ts` (`FindController`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | +| `terminal.ts`: `writeInternal` (now `write`), `writeClear`, the pending clear of `clearScreen` (now `clearAtGround`), `processTerminalResponses` (now `drainReplies`), the `pendingClear` and `awaitingEcho` fields; `writeQueue` and `flushWriteQueue` are dropped (never filled) | `core/write-pipeline.ts` (`WritePipeline`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | +| `terminal.ts`: `startRenderLoop` (its loop closure now the one `frame` callback), `cancelRenderLoop` and the `animationFrameId` field (now `start`, `cancel`), on injected animation frames and clock | `core/render-loop.ts` (`RenderLoop`, `browserFrames`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | +| `terminal.ts`: what the four parts above reach of `Terminal` | `core/host-types.ts` (`FrameScheduler`, `FocusHost`, `FindEngine`, `FindHost`, `WriteEngine`, `WriteHost`, `RenderLoopHost`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | diff --git a/src/client/vendor/ghostty-web/core/find-controller.ts b/src/client/vendor/ghostty-web/core/find-controller.ts new file mode 100644 index 0000000..75bb36d --- /dev/null +++ b/src/client/vendor/ghostty-web/core/find-controller.ts @@ -0,0 +1,123 @@ +// webtty (ADR 055, ADR 061), not from upstream. +// Find in the scrollback (controller): the needle on libghostty-vt's search, stepped a few +// milliseconds a frame by the render loop, its matches handed to the renderer, and the +// view taken to and kept on the selected match (ADR 055). Moved out of terminal.ts (ADR 061). + +import type { SearchState } from '../engine/engine-types'; +import { type FindAnchor, keptViewportY, viewportYToShow } from '../search'; +import type { FindHost } from './host-types'; +import { FIND_READ_BACKOFF, FIND_STEP_MS } from './terminal-constants'; + +/** The browser's monotonic clock, read when called. */ +const performanceNow = (): number => performance.now(); + +/** + * Ghostty's `search`, `navigate_search` and `end_search` on one terminal. The search runs + * in `step`, which the render loop calls before each render. + */ +export class FindController { + // The needle, '' with no search open + private needle = ''; + // Whether the matches need reading again, and when they may be + private stale = false; + private readAt = 0; + // Where the selected match was when the view last followed it + private anchor: FindAnchor | null = null; + // The count last reported + private state: SearchState = { total: 0, selected: null }; + + /** `now` times a read of the matches, for its backoff; `performance.now()` by default. */ + constructor( + private readonly host: FindHost, + private readonly now: () => number = performanceNow, + ) {} + + /** Whether a search is open. */ + get finding(): boolean { + return this.needle !== ''; + } + + /** + * Whether the view follows a selected match: output then keeps the view on its rows + * instead of scrolling to the bottom. + */ + get anchored(): boolean { + return this.anchor !== null; + } + + /** Starts a search for `needle`, every match highlighted and none selected; '' ends it. */ + find(needle: string): void { + this.needle = needle; + this.host.engine()?.setSearch(needle); + this.anchor = null; + this.stale = !!needle; + this.readAt = 0; + this.host.highlight(null); + this.report(); + } + + /** Sets the needle again on the new engine Terminal.reset() made. */ + restart(): void { + if (this.needle) this.find(this.needle); + } + + /** + * Selects the next match (toward older output) or the previous one, and scrolls to it + * if none of its rows is in view. False with no search open. + */ + select(direction: 'next' | 'previous'): boolean { + const engine = this.host.engine(); + if (!this.needle || !engine) return false; + engine.searchSelect(direction); + const match = engine.searchSelectedMatch(); + if (match) { + const y = viewportYToShow( + match, + this.host.getScrollbackLength(), + this.host.viewportY(), + this.host.rows(), + ); + if (y !== null) this.host.scrollToLine(y); + } + this.anchor = match && { row: match.start.row, scrollback: this.host.getScrollbackLength() }; + this.stale = true; + this.readAt = 0; + this.report(); + return true; + } + + /** + * Runs the search for a few milliseconds and, when its results changed, gives the + * renderer the matches. Reading them takes a few milliseconds with tens of thousands, + * so while output keeps changing them they are read at most every ten times the last + * read took. A scrolled view on a selected match is kept on its rows. + */ + step(now: number): void { + const engine = this.host.engine(); + if (!this.needle || !engine) return; + if (engine.searchStep(FIND_STEP_MS)) { + this.stale = true; + this.report(); + } + if (!this.stale || now < this.readAt) return; + const started = this.now(); + const selected = engine.searchSelectedMatch(); + this.host.highlight({ matches: engine.searchMatches(), selected }); + this.stale = false; + this.readAt = now + (this.now() - started) * FIND_READ_BACKOFF; + const scrollback = this.host.getScrollbackLength(); + const viewportY = this.host.viewportY(); + if (selected && this.anchor && viewportY > 0) { + this.host.scrollToLine(keptViewportY(viewportY, this.anchor, selected.start.row, scrollback)); + } + this.anchor = selected && { row: selected.start.row, scrollback }; + } + + /** Reports through the host if the count or the selected match changed. */ + private report(): void { + const state = this.host.engine()?.searchState() ?? { total: 0, selected: null }; + if (state.total === this.state.total && state.selected === this.state.selected) return; + this.state = state; + this.host.changed(state); + } +} diff --git a/src/client/vendor/ghostty-web/core/focus-tracker.ts b/src/client/vendor/ghostty-web/core/focus-tracker.ts new file mode 100644 index 0000000..146b619 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/focus-tracker.ts @@ -0,0 +1,155 @@ +// webtty (ADR 053, ADR 061), not from upstream. +// Where keyboard focus is (object): it keeps focus off the container, follows it into and +// out of the terminal, and sends Ghostty's focus reports while the app has mode 1004 on +// (ADR 053). Moved out of terminal.ts (ADR 061). + +import { + type Disposable, + DisposableStore, + type ListenerTarget, + listen, +} from '../lifecycle/disposable-store'; +import type { FocusHost } from './host-types'; +import { FOCUS_EVENT_MODE } from './terminal-constants'; + +/** The window and document the tracker listens on and reads, injected so tests pass fakes. */ +export interface FocusEnv { + readonly window: ListenerTarget; + readonly document: ListenerTarget & Pick<Document, 'activeElement' | 'visibilityState' | 'hasFocus'>; +} + +/** The page's own window and document, read when the terminal opens. */ +export function browserFocusEnv(): FocusEnv { + return { window, document }; +} + +/** + * Terminal focus for mode 1004: keyboard focus inside the container in a focused window. + * A report goes out on each change while the mode is on, and once when a write turns it on. + */ +export class FocusTracker implements Disposable { + private focused = false; + // Whether mode 1004 was on after the last write + private reporting = false; + private env?: FocusEnv; + private containerListeners?: DisposableStore; + + constructor(private readonly host: FocusHost) {} + + /** + * Adds the focus listeners, in the order Terminal.open() always added them: the + * container's redirect, the window's, then the four that follow focus. + */ + attach(container: HTMLElement, env: FocusEnv = browserFocusEnv()): void { + this.env = env; + const listeners = new DisposableStore(); + this.containerListeners = listeners; + // Never let the container hold focus, or the browser writes IME composition + // text into it; input goes to the textarea. This covers clicks in the padding, + // tab focus and focus(). (webtty) + listeners.add(listen(container, 'focusin', this.redirect)); + // focus() on a background window moves activeElement without a focusin; catch + // the container still holding focus when the window comes back. (webtty) + // leaks until ADR 065 + env.window.addEventListener('focus', this.windowFocused); + // Terminal focus for mode 1004: a window blur or focus usually reaches the focused + // element as focusout / focusin too, but Chrome switching tabs only changes the + // page's visibility, so blur and visibility are watched as well. (webtty, ADR 053) + listeners.add(listen(container, 'focusin', this.gained)); + listeners.add(listen(container, 'focusout', this.lost)); + // leaks until ADR 065 + env.window.addEventListener('blur', this.windowBlurred); + // leaks until ADR 065 + env.document.addEventListener('visibilitychange', this.visibilityChanged); + } + + /** Removes the container's listeners; the window's and document's stay (ADR 065). */ + dispose(): void { + this.containerListeners?.dispose(); + } + + /** + * Removes the window's and document's listeners. Terminal calls it only while open, + * which its teardown never is, so today they leak (ADR 065). + */ + detachWindow(): void { + if (!this.env) return; + this.env.window.removeEventListener('focus', this.windowFocused); + this.env.window.removeEventListener('blur', this.windowBlurred); + this.env.document.removeEventListener('visibilitychange', this.visibilityChanged); + } + + /** After a write: Ghostty reports the focus when the app turns mode 1004 on. */ + followMode(): void { + const reporting = this.host.engine()!.getMode(FOCUS_EVENT_MODE); + if (reporting && !this.reporting) this.report(); + this.reporting = reporting; + } + + /** A new engine starts with mode 1004 off. */ + reset(): void { + this.reporting = false; + } + + /** + * Sends the current focus if mode 1004 is on, for a new connection to the app: reports + * sent while there was none were dropped. + */ + resync(): void { + if (this.host.engine()?.getMode(FOCUS_EVENT_MODE)) this.report(); + } + + /** Sends focus that lands on the container itself to the textarea. (webtty) */ + private readonly redirect = (e: FocusEvent): void => { + if (e.target === this.host.element()) this.host.textarea()?.focus(); + }; + + private readonly windowFocused = (): void => { + const element = this.host.element(); + if (element && this.env!.document.activeElement === element) this.host.textarea()?.focus(); + }; + + /** Focus came into the container, or its window got focus back. */ + private readonly gained = (): void => { + this.setFocused(true); + }; + + /** + * Focus left the container, or its window lost focus (no `relatedTarget`). A move + * inside it, the container passing focus on to the textarea, is not a change. + */ + private readonly lost = (e: FocusEvent): void => { + const next = e.relatedTarget as Node | null; + if (next && this.host.element()?.contains(next)) return; + this.setFocused(false); + }; + + /** The window lost focus: the terminal has none, whatever element is active. */ + private readonly windowBlurred = (): void => { + this.setFocused(false); + }; + + /** + * The tab was hidden or shown. Chrome fires no blur or focus for a tab switch, so the + * focus is read again here. + */ + private readonly visibilityChanged = (): void => { + const doc = this.env!.document; + const shown = doc.visibilityState === 'visible' && doc.hasFocus(); + this.setFocused(shown && !!this.host.element()?.contains(doc.activeElement)); + }; + + private setFocused(focused: boolean): void { + if (focused === this.focused) return; + this.focused = focused; + if (this.host.engine()?.getMode(FOCUS_EVENT_MODE)) this.report(); + } + + /** Sends `CSI I` or `CSI O` for the current focus, encoded by Ghostty. */ + private report(): void { + const engine = this.host.engine(); + if (this.host.disableStdin() || !engine) return; + const report = new TextDecoder().decode(engine.encodeFocus(this.focused)); + if (report) this.host.send(report); + } +} diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index 82a934d..1530ce4 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -3,7 +3,12 @@ // Terminal, CanvasRenderer and engine classes, so a split part never imports a facade and // no import cycle forms (ADR 061). -import type { GhosttyTerminal } from '../engine/engine-types'; +import type { + DesktopNotification, + GhosttyTerminal, + SearchState, +} from '../engine/engine-types'; +import type { SearchHighlights } from '../search'; /** * What SelectionManager needs from the terminal: its size, the copyOnSelect option, the @@ -53,3 +58,106 @@ export interface ViewportHost { scrolled(viewportY: number): void; showScrollbar(): void; } + +/** + * The frame clock the render loop and the write callbacks run on, injected so tests step + * frames by hand; production passes the browser's (`browserFrames` in render-loop.ts). + */ +export interface FrameScheduler { + requestAnimationFrame(callback: (now: number) => void): number; + cancelAnimationFrame(id: number): void; +} + +/** + * What the FocusTracker reads of the terminal, each call made when an event arrives: the + * container and textarea (undefined before open() and after dispose()), the engine that + * reads mode 1004 and encodes the report, and where a report goes (ADR 053). + */ +export interface FocusHost { + element(): HTMLElement | undefined; + textarea(): HTMLTextAreaElement | undefined; + engine(): Pick<GhosttyTerminal, 'getMode' | 'encodeFocus'> | undefined; + disableStdin(): boolean; + /** Sends bytes to the app, as onData. */ + send(data: string): void; +} + +/** The engine calls a search makes: libghostty-vt's search (ADR 055). */ +export type FindEngine = Pick< + GhosttyTerminal, + | 'setSearch' + | 'searchStep' + | 'searchSelect' + | 'searchState' + | 'searchSelectedMatch' + | 'searchMatches' +>; + +/** + * What the FindController needs from the terminal: the engine's search, the renderer's + * highlights, the view it scrolls to a match, and where a new count goes (onFindChange). + */ +export interface FindHost { + engine(): FindEngine | undefined; + /** Hands the renderer the matches, or null to clear them. */ + highlight(highlights: SearchHighlights | null): void; + getScrollbackLength(): number; + viewportY(): number; + rows(): number; + scrollToLine(line: number): void; + /** The match count or the selected match changed. */ + changed(state: SearchState): void; +} + +/** The engine calls a write makes: the VT write, the clear's checks, replies and effects. */ +export type WriteEngine = Pick< + GhosttyTerminal, + | 'write' + | 'writeUntilGround' + | 'isAlternateScreen' + | 'cursorAtPrompt' + | 'getCursor' + | 'readResponse' + | 'readEffects' +>; + +/** + * What the WritePipeline reaches of the terminal, one call per step of a write: the + * engine, the app (onData), the focus-mode edge, the effects' emitters, the link cache, + * the view and the echo render. + */ +export interface WriteHost { + engine(): WriteEngine | undefined; + disableStdin(): boolean; + /** Sends bytes to the app, as onData: replies, and FF after a clear at a prompt. */ + send(data: string): void; + /** Reports the focus if the write turned mode 1004 on (FocusTracker.followMode). */ + followFocusMode(): void; + bell(): void; + title(title: string): void; + clipboardWrite(text: string): void; + notify(notification: DesktopNotification): void; + /** The content changed: the link cache is stale. */ + outputChanged(): void; + viewportY(): number; + /** Whether the view is kept on a selected find match (FindController.anchored). */ + findAnchored(): boolean; + scrollToBottom(): void; + /** Renders now, if the terminal is open, so typed input's echo shows without a frame's wait. */ + renderEcho(): void; +} + +/** The steps of one render-loop frame, called in the order the RenderLoop fixes. */ +export interface RenderLoopHost { + /** Open and not disposed: the loop stops at the first frame where this is false. */ + live(): boolean; + /** The embedder's callback (attachBeforeRender), so an animation shows in this frame. */ + beforeRender(now: number): void; + stepFind(now: number): void; + render(): void; + /** Whether replies arose outside a write (a timer, a mode change) and wait for the PTY. */ + hasReplies(): boolean; + drainReplies(): void; + /** Keeps the input textarea on the cursor cell. */ + anchorTextarea(): void; +} diff --git a/src/client/vendor/ghostty-web/core/render-loop.ts b/src/client/vendor/ghostty-web/core/render-loop.ts new file mode 100644 index 0000000..b426840 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/render-loop.ts @@ -0,0 +1,64 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The render loop (controller): one animation frame after another while the terminal is +// open, each running beforeRender, a find step, the render, the replies that arose outside +// a write and the textarea anchoring, in that order (ADR 042, ADR 045, ADR 055, ADR 061). + +import type { FrameScheduler, RenderLoopHost } from './host-types'; + +/** The browser's animation frames, read when called. */ +export const browserFrames: FrameScheduler = { + requestAnimationFrame: (callback) => requestAnimationFrame(callback), + cancelAnimationFrame: (id) => cancelAnimationFrame(id), +}; + +/** The browser's monotonic clock, read when called. */ +const performanceNow = (): number => performance.now(); + +/** + * Runs a frame at once on start() and then one per animation frame until cancelled or the + * host is no longer live. A frame allocates nothing: the frame callback is made once. + */ +export class RenderLoop { + private frameId?: number; + + /** `now` stands in for the frame time of the first, synchronous frame. */ + constructor( + private readonly host: RenderLoopHost, + private readonly frames: FrameScheduler = browserFrames, + private readonly now: () => number = performanceNow, + ) {} + + /** Runs a frame now and keeps the loop going; does nothing while it already runs. */ + start(): void { + if (this.frameId) return; + this.frame(); + } + + /** + * Stops the loop. Terminal.resize() stops it while the engine reallocates its buffers + * and starts it again after, rather than skipping frames on a flag cleared by an + * animation frame, which background tabs throttle. + */ + cancel(): void { + if (this.frameId) { + this.frames.cancelAnimationFrame(this.frameId); + this.frameId = undefined; + } + } + + // Hot path: every frame. The order is fixed (ADR 061). + private readonly frame = (now?: number): void => { + const host = this.host; + if (!host.live()) return; + host.beforeRender(now ?? this.now()); + host.stepFind(now ?? this.now()); + // Render with the engine's dirty tracking: it updates the render state once, + // redraws only dirty rows and clears the dirty flags at the end + host.render(); + // Replies that arose outside write() reach the PTY within a frame (webtty, ADR 042) + if (host.hasReplies()) host.drainReplies(); + // Keep the input textarea on the cursor cell (webtty) + host.anchorTextarea(); + this.frameId = this.frames.requestAnimationFrame(this.frame); + }; +} diff --git a/src/client/vendor/ghostty-web/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts new file mode 100644 index 0000000..2a0466d --- /dev/null +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -0,0 +1,125 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The write pipeline (controller): a write in its fixed order, a waiting clear at ground, +// the engine write, the replies, the focus-mode edge, the effects, the link cache, the +// auto-scroll and the echo render, plus clear screen and the reply drain (ADR 042, ADR 053, +// ADR 054, ADR 055, ADR 061). + +import type { FrameScheduler, WriteHost } from './host-types'; +import { browserFrames } from './render-loop'; + +/** + * Feeds output to the engine. Selection is not cleared on a write: most terminals keep it + * when data arrives; clicking or typing clears it. + */ +export class WritePipeline { + // A clearScreen waiting for the output to reach ground (webtty, ADR 054) + private pendingClear = false; + // Input was sent: the next write renders at once, for its echo + private awaitingEcho = false; + + /** `frames` runs a write's callback on the next frame. */ + constructor( + private readonly host: WriteHost, + private readonly frames: Pick<FrameScheduler, 'requestAnimationFrame'> = browserFrames, + ) {} + + /** Writes output to the engine and runs every step after it, in order. */ + write(data: string | Uint8Array, callback?: () => void): void { + const host = this.host; + const engine = host.engine()!; + // A waiting clear goes in where the output reaches ground (webtty, ADR 054) + if (this.pendingClear) { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + const n = engine.writeUntilGround(bytes); + if (n !== null) { + this.pendingClear = false; + this.writeClear(); + } + data = bytes.subarray(n ?? bytes.length); + } + + // The engine parses the VT stream + engine.write(data); + + // Replies the output asked for (DSR and the like) go back to the PTY + this.drainReplies(); + + // Ghostty reports the focus when the app turns mode 1004 on (webtty, ADR 053) + host.followFocusMode(); + + // Bell, title (OSC 0/2), clipboard writes (OSC 52) and desktop notifications (OSC 9, + // OSC 777) as Ghostty reported them (webtty, ADR 042, ADR 052, ADR 056) + const effects = engine.readEffects(); + if (effects.bell) host.bell(); + if (effects.title !== null) host.title(effects.title); + if (effects.clipboard !== null) host.clipboardWrite(effects.clipboard); + for (const n of effects.notifications) host.notify(n); + + // The content changed, so cached links are stale + host.outputChanged(); + + // Auto-scroll to bottom on new output (xterm.js behavior), except while the + // view is on a selected match: the find step keeps it on its rows. The alternate + // screen has no scrollback to keep a view in, and its matches are its own (webtty, ADR 055) + if (host.viewportY() !== 0 && (!host.findAnchored() || engine.isAlternateScreen())) { + host.scrollToBottom(); + } + + // The callback runs on the next frame, after the render + if (callback) this.frames.requestAnimationFrame(callback); + + if (this.awaitingEcho) { + this.awaitingEcho = false; + host.renderEcho(); + } + } + + /** + * The erasing of Ghostty's `clear_screen`, written to the terminal once the output + * reaches ground, so it never cuts a sequence the output left unfinished (ADR 054). + */ + clearAtGround(): void { + this.pendingClear = true; + this.write(new Uint8Array(0)); + } + + /** Renders the next write at once: input was sent and its echo should show. */ + awaitEcho(): void { + this.awaitingEcho = true; + } + + /** Drops a waiting clear: it was for an engine reset() replaced (ADR 054). */ + reset(): void { + this.pendingClear = false; + } + + /** + * Sends every reply the engine has queued to the PTY through onData: DSR 6 (cursor + * position), DSR 5 (status), size reports and the like. Shells such as nushell hang + * without them. One write can hold several queries, so all are read. + */ + drainReplies(): void { + const engine = this.host.engine(); + if (!engine) return; + while (true) { + const response = engine.readResponse(); + if (response === null) break; + this.host.send(response); + } + } + + /** Clears at a shell prompt (FF sent for the redraw), elsewhere the rows above the cursor. */ + private writeClear(): void { + const engine = this.host.engine()!; + // The output before it may have turned the alternate screen on + if (engine.isAlternateScreen()) return; + if (engine.cursorAtPrompt()) { + // ED 2 at a prompt moves the screen into the scrollback, so ED 3 comes after it + engine.write('\x1b[2J\x1b[3J'); + if (!this.host.disableStdin()) this.host.send('\x0c'); + } else { + const y = engine.getCursor().y; + engine.write(`${y > 0 ? `\x1b[${y}S\x1b[${y}A` : ''}\x1b[3J`); + } + } +} diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index dc1b91f..28d731f 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -4,11 +4,11 @@ // `new Terminal({ ghostty: await Ghostty.load(url), ... })` and `open(container)` (ADR 061). import { BufferNamespace } from './buffer'; +import { FindController } from './core/find-controller'; +import { FocusTracker } from './core/focus-tracker'; +import { RenderLoop } from './core/render-loop'; import { APP_CURSOR_KEYS_MODE, - FIND_READ_BACKOFF, - FIND_STEP_MS, - FOCUS_EVENT_MODE, MOUSE_MOVE_THROTTLE_MS, SCROLLBAR_FADE_DURATION_MS, SCROLLBAR_HIDE_DELAY_MS, @@ -30,6 +30,7 @@ import { viewportRowToBufferRow, } from './core/terminal-geometry'; import { Viewport } from './core/viewport'; +import { WritePipeline } from './core/write-pipeline'; import { EventEmitter } from './event-emitter'; import type { DesktopNotification, @@ -65,7 +66,6 @@ import { scrollbarThumbY, scrollbarTrackHeight, } from './render/scrollbar-geometry'; -import { type FindAnchor, keptViewportY, viewportYToShow } from './search'; import { SelectionManager } from './selection-manager'; import type { ILink } from './types'; import { @@ -154,9 +154,6 @@ export class Terminal implements ITerminalCore { // Lifecycle state private isOpen = false; private isDisposed = false; - private animationFrameId?: number; - private writeQueue: Uint8Array[] = []; - private awaitingEcho = false; // Addons private addons: ITerminalAddon[] = []; @@ -164,22 +161,58 @@ export class Terminal implements ITerminalCore { // The title last reported through onTitleChange private currentTitle: string = ''; - // Focus reports (webtty, ADR 053): keyboard focus inside the container in a focused - // window, and whether mode 1004 was on after the last write. - private focused = false; - private focusReporting = false; + // Focus kept off the container and reported to the app under mode 1004 (webtty, ADR 053) + private readonly focusTracker = new FocusTracker({ + element: () => this.element, + textarea: () => this.textarea, + engine: () => this.wasmTerm, + disableStdin: () => this.options.disableStdin, + send: (data) => this.dataEmitter.fire(data), + }); + + // Find in the scrollback, stepped by the render loop (webtty, ADR 055) + private readonly finder = new FindController({ + engine: () => this.wasmTerm, + highlight: (highlights) => this.renderer?.setSearchHighlights(highlights), + getScrollbackLength: () => this.getScrollbackLength(), + viewportY: () => this.viewportY, + rows: () => this.rows, + scrollToLine: (line) => this.scrollToLine(line), + changed: (state) => this.findChangeEmitter.fire(state), + }); - // A clearScreen waiting for the output to reach ground (webtty, ADR 054) - private pendingClear = false; + // Output into the engine and every step after it, in a fixed order (ADR 061) + private readonly writes = new WritePipeline({ + engine: () => this.wasmTerm, + disableStdin: () => this.options.disableStdin, + send: (data) => this.dataEmitter.fire(data), + followFocusMode: () => this.focusTracker.followMode(), + bell: () => this.bellEmitter.fire(), + title: (title) => this.setTitle(title), + clipboardWrite: (text) => this.clipboardWriteEmitter.fire(text), + notify: (notification) => this.desktopNotificationEmitter.fire(notification), + outputChanged: () => this.linkDetector?.invalidateCache(), + viewportY: () => this.viewportY, + findAnchored: () => this.finder.anchored, + scrollToBottom: () => this.scrollToBottom(), + renderEcho: () => { + if (this.renderer && this.wasmTerm) { + this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); + } + }, + }); - // Find (webtty, ADR 055): the needle ('' with no search open), whether the matches need - // reading again and when they may be, where the selected match was when the view last - // followed it, and the count last reported - private findNeedle = ''; - private findStale = false; - private findReadAt = 0; - private findAnchor: FindAnchor | null = null; - private findState: SearchState = { total: 0, selected: null }; + // One frame after another while open, each in a fixed order (ADR 061) + private readonly renderLoop = new RenderLoop({ + live: () => !this.isDisposed && this.isOpen, + beforeRender: (now) => this.beforeRender?.(now), + stepFind: (now) => this.finder.step(now), + render: () => + this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity), + hasReplies: () => this.wasmTerm!.hasResponse(), + drainReplies: () => this.writes.drainReplies(), + anchorTextarea: () => this.anchorTextarea(), + }); // Where the view is in the scrollback; each move fires onScroll and shows the scrollbar private readonly viewport = new Viewport({ @@ -339,20 +372,8 @@ export class Terminal implements ITerminalCore { e.preventDefault(); } }); - // Never let the container hold focus, or the browser writes IME composition - // text into it; input goes to the textarea. This covers clicks in the padding, - // tab focus and focus(). (webtty) - parent.addEventListener('focusin', this.handleFocusIn); - // focus() on a background window moves activeElement without a focusin; catch - // the container still holding focus when the window comes back. (webtty) - window.addEventListener('focus', this.handleWindowFocus); - // Terminal focus for mode 1004: a window blur or focus usually reaches the focused - // element as focusout / focusin too, but Chrome switching tabs only changes the - // page's visibility, so blur and visibility are watched as well. (webtty, ADR 053) - parent.addEventListener('focusin', this.handleFocusGained); - parent.addEventListener('focusout', this.handleFocusLost); - window.addEventListener('blur', this.handleWindowBlur); - document.addEventListener('visibilitychange', this.handleVisibilityChange); + // Focus is followed, and kept on the textarea, by the focus tracker (webtty, ADR 053) + this.focusTracker.attach(parent); // Add accessibility attributes for screen readers and extensions parent.setAttribute('role', 'textbox'); @@ -484,7 +505,7 @@ export class Terminal implements ITerminalCore { this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); // Start render loop - this.startRenderLoop(); + this.renderLoop.start(); // Focus input (auto-focus so user can start typing immediately) this.focus(); @@ -508,7 +529,7 @@ export class Terminal implements ITerminalCore { // Ghostty always scrolls to the bottom on a paste (webtty, ADR 055) this.scrollToBottom(); this.wasmTerm!.paste(text); - this.processTerminalResponses(); + this.writes.drainReplies(); } /** @@ -518,13 +539,7 @@ export class Terminal implements ITerminalCore { * (webtty, ADR 055) */ find(needle: string): void { - this.findNeedle = needle; - this.wasmTerm?.setSearch(needle); - this.findAnchor = null; - this.findStale = !!needle; - this.findReadAt = 0; - this.renderer?.setSearchHighlights(null); - this.reportFind(); + this.finder.find(needle); } /** Ends the search and clears its highlights (Ghostty's `end_search`). */ @@ -534,7 +549,7 @@ export class Terminal implements ITerminalCore { /** Whether a search is open. */ get finding(): boolean { - return this.findNeedle !== ''; + return this.finder.finding; } /** @@ -542,53 +557,12 @@ export class Terminal implements ITerminalCore { * scrolls to it if none of its rows is in view. False with no search open. */ findNext(): boolean { - return this.selectMatch('next'); + return this.finder.select('next'); } /** Selects the previous match, toward newer output. False with no search open. */ findPrevious(): boolean { - return this.selectMatch('previous'); - } - - private selectMatch(direction: 'next' | 'previous'): boolean { - if (!this.findNeedle || !this.wasmTerm) return false; - this.wasmTerm.searchSelect(direction); - const match = this.wasmTerm.searchSelectedMatch(); - if (match) { - const y = viewportYToShow(match, this.getScrollbackLength(), this.viewportY, this.rows); - if (y !== null) this.scrollToLine(y); - } - this.findAnchor = match && { row: match.start.row, scrollback: this.getScrollbackLength() }; - this.findStale = true; - this.findReadAt = 0; - this.reportFind(); - return true; - } - - /** - * Runs the search for a few milliseconds and, when its results changed, gives the - * renderer the matches. Reading them takes a few milliseconds with tens of thousands, - * so while output keeps changing them they are read at most every ten times the last - * read took. A scrolled view on a selected match is kept on its rows. - */ - private stepFind(now: number): void { - const wasmTerm = this.wasmTerm; - if (!this.findNeedle || !wasmTerm) return; - if (wasmTerm.searchStep(FIND_STEP_MS)) { - this.findStale = true; - this.reportFind(); - } - if (!this.findStale || now < this.findReadAt) return; - const started = performance.now(); - const selected = wasmTerm.searchSelectedMatch(); - this.renderer?.setSearchHighlights({ matches: wasmTerm.searchMatches(), selected }); - this.findStale = false; - this.findReadAt = now + (performance.now() - started) * FIND_READ_BACKOFF; - const scrollback = this.getScrollbackLength(); - if (selected && this.findAnchor && this.viewportY > 0) { - this.scrollToLine(keptViewportY(this.viewportY, this.findAnchor, selected.start.row, scrollback)); - } - this.findAnchor = selected && { row: selected.start.row, scrollback }; + return this.finder.select('previous'); } /** @@ -604,16 +578,6 @@ export class Terminal implements ITerminalCore { if (row !== top) this.scrollToLine(scrollback - row); } - /** Fires onFindChange if the count or the selected match changed. */ - private reportFind(): void { - const state = this.wasmTerm?.searchState() ?? { total: 0, selected: null }; - if (state.total === this.findState.total && state.selected === this.findState.selected) { - return; - } - this.findState = state; - this.findChangeEmitter.fire(state); - } - /** * Ghostty's `clear_screen` (Cmd+K): clears the selection and the scrollback, then at a * shell prompt the screen, sending FF so the shell redraws its prompt, and elsewhere the @@ -626,26 +590,10 @@ export class Terminal implements ITerminalCore { this.assertOpen(); if (this.wasmTerm!.isAlternateScreen()) return false; this.clearSelection(); - this.pendingClear = true; - this.writeInternal(new Uint8Array(0)); + this.writes.clearAtGround(); return true; } - /** The erasing of `clearScreen`, for when the parser is at ground. */ - private writeClear(): void { - const wasmTerm = this.wasmTerm!; - // The output before it may have turned the alternate screen on - if (wasmTerm.isAlternateScreen()) return; - if (wasmTerm.cursorAtPrompt()) { - // ED 2 at a prompt moves the screen into the scrollback, so ED 3 comes after it - wasmTerm.write('\x1b[2J\x1b[3J'); - if (!this.options.disableStdin) this.dataEmitter.fire('\x0c'); - } else { - const y = wasmTerm.getCursor().y; - wasmTerm.write(`${y > 0 ? `\x1b[${y}S\x1b[${y}A` : ''}\x1b[3J`); - } - } - /** * Write data to terminal */ @@ -657,75 +605,7 @@ export class Terminal implements ITerminalCore { data = data.replace(/\n/g, '\r\n'); } - this.writeInternal(data, callback); - } - - /** - * Internal write implementation (extracted from write()) - */ - private writeInternal(data: string | Uint8Array, callback?: () => void): void { - // Note: We intentionally do NOT clear selection on write - most modern terminals - // preserve selection when new data arrives. Selection is cleared by user actions - // like clicking or typing, not by incoming data. - - // A waiting clear goes in where the output reaches ground (webtty, ADR 054) - if (this.pendingClear) { - const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; - const n = this.wasmTerm!.writeUntilGround(bytes); - if (n !== null) { - this.pendingClear = false; - this.writeClear(); - } - data = bytes.subarray(n ?? bytes.length); - } - - // Write directly to WASM terminal (handles VT parsing internally) - this.wasmTerm!.write(data); - - // Process any responses generated by the terminal (e.g., DSR cursor position) - // These need to be sent back to the PTY via onData - this.processTerminalResponses(); - - // Ghostty reports the focus when the app turns mode 1004 on (webtty, ADR 053) - const focusReporting = this.wasmTerm!.getMode(FOCUS_EVENT_MODE); - if (focusReporting && !this.focusReporting) this.reportFocus(); - this.focusReporting = focusReporting; - - // Bell, title (OSC 0/2), clipboard writes (OSC 52) and desktop notifications (OSC 9, - // OSC 777) as Ghostty reported them (webtty, ADR 042, ADR 052, ADR 056) - const effects = this.wasmTerm!.readEffects(); - if (effects.bell) this.bellEmitter.fire(); - if (effects.title !== null) this.setTitle(effects.title); - if (effects.clipboard !== null) this.clipboardWriteEmitter.fire(effects.clipboard); - for (const n of effects.notifications) this.desktopNotificationEmitter.fire(n); - - // Invalidate link cache (content changed) - this.linkDetector?.invalidateCache(); - - // Auto-scroll to bottom on new output (xterm.js behavior), except while the - // view is on a selected match: stepFind keeps it on its rows. The alternate screen - // has no scrollback to keep a view in, and its matches are its own (webtty, ADR 055) - if ( - this.viewportY !== 0 && - (!this.findAnchor || this.wasmTerm!.isAlternateScreen()) - ) { - this.scrollToBottom(); - } - - // Call callback if provided - if (callback) { - // Queue callback after next render - requestAnimationFrame(callback); - } - - if (this.awaitingEcho) { - this.awaitingEcho = false; - if (this.renderer && this.wasmTerm) { - this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); - } - } - - // Render will happen on next animation frame + this.writes.write(data, callback); } /** @@ -742,7 +622,7 @@ export class Terminal implements ITerminalCore { // views while WASM reallocates buffers. We restart it after resize completes. // This avoids the background-tab regression of using an isResizing flag // cleared via requestAnimationFrame (rAF is throttled/paused in background tabs). - this.cancelRenderLoop(); + this.renderLoop.cancel(); try { // Update dimensions @@ -769,7 +649,7 @@ export class Terminal implements ITerminalCore { // A mode 2048 size report from the resize, sent after the PTY resize message // (webtty, ADR 042) - this.processTerminalResponses(); + this.writes.drainReplies(); // Force full render, the scrollbar at its current opacity (webtty, ADR 043) this.renderer!.render(this.wasmTerm!, true, this.viewportY, this, this.scrollbarOpacity); @@ -777,9 +657,7 @@ export class Terminal implements ITerminalCore { console.error('Terminal resize failed:', e); } - // Flush any writes that were queued during resize, then restart render loop - this.flushWriteQueue(); - this.startRenderLoop(); + this.renderLoop.start(); } /** @@ -792,7 +670,7 @@ export class Terminal implements ITerminalCore { const px = this.cellPixelSize(); if (!this.wasmTerm || !px) return; this.wasmTerm.setCellPixelSize(px.width, px.height); - this.processTerminalResponses(); + this.writes.drainReplies(); } /** The renderer's cell in whole device pixels, or null before open(). (webtty) */ @@ -826,11 +704,11 @@ export class Terminal implements ITerminalCore { // Reset title this.currentTitle = ''; // The new engine starts with mode 1004 off (webtty, ADR 053) - this.focusReporting = false; + this.focusTracker.reset(); // A clear waiting for the old engine's output is not for the new one (webtty, ADR 054) - this.pendingClear = false; + this.writes.reset(); // A search goes on in the new engine (webtty, ADR 055) - if (this.findNeedle) this.find(this.findNeedle); + this.finder.restart(); } /** @@ -951,9 +829,8 @@ export class Terminal implements ITerminalCore { this.isDisposed = true; this.isOpen = false; - // Stop render loop and clear write queue - this.cancelRenderLoop(); - this.writeQueue.length = 0; + // Stop render loop + this.renderLoop.cancel(); this.tickPacer.cancel(); @@ -985,66 +862,12 @@ export class Terminal implements ITerminalCore { this.findChangeEmitter.dispose(); } - /** - * Sends focus that lands on the container itself to the textarea. (webtty) - */ - private handleFocusIn = (e: FocusEvent): void => { - if (e.target === this.element) this.textarea?.focus(); - }; - - private handleWindowFocus = (): void => { - if (this.element && document.activeElement === this.element) this.textarea?.focus(); - }; - - /** Focus came into the container, or its window got focus back. (webtty, ADR 053) */ - private handleFocusGained = (): void => { - this.setFocused(true); - }; - - /** - * Focus left the container, or its window lost focus (no `relatedTarget`). A move - * inside it, the container passing focus on to the textarea, is not a change. - * (webtty, ADR 053) - */ - private handleFocusLost = (e: FocusEvent): void => { - const next = e.relatedTarget as Node | null; - if (next && this.element?.contains(next)) return; - this.setFocused(false); - }; - - /** The window lost focus: the terminal has none, whatever element is active. (webtty, ADR 053) */ - private handleWindowBlur = (): void => { - this.setFocused(false); - }; - - /** - * The tab was hidden or shown. Chrome fires no blur or focus for a tab switch, so the - * focus is read again here. (webtty, ADR 053) - */ - private handleVisibilityChange = (): void => { - const shown = document.visibilityState === 'visible' && document.hasFocus(); - this.setFocused(shown && !!this.element?.contains(document.activeElement)); - }; - - private setFocused(focused: boolean): void { - if (focused === this.focused) return; - this.focused = focused; - if (this.wasmTerm?.getMode(FOCUS_EVENT_MODE)) this.reportFocus(); - } - /** * Sends the current focus if mode 1004 is on, for a new connection to the app: reports * sent while there was none were dropped. (webtty, ADR 053) */ resyncFocus(): void { - if (this.wasmTerm?.getMode(FOCUS_EVENT_MODE)) this.reportFocus(); - } - - /** Sends `CSI I` or `CSI O` for the current focus, encoded by Ghostty. (webtty, ADR 053) */ - private reportFocus(): void { - if (this.options.disableStdin || !this.wasmTerm) return; - const report = new TextDecoder().decode(this.wasmTerm.encodeFocus(this.focused)); - if (report) this.dataEmitter.fire(report); + this.focusTracker.resync(); } /** @@ -1082,55 +905,6 @@ export class Terminal implements ITerminalCore { setStylePx(textarea, 'height', this.renderer!.getMetrics().height); } - /** - * Cancel the render loop - */ - private cancelRenderLoop(): void { - if (this.animationFrameId) { - cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = undefined; - } - } - - /** - * Flush any writes that were queued during resize - */ - private flushWriteQueue(): void { - while (this.writeQueue.length > 0) { - const data = this.writeQueue.shift()!; - this.wasmTerm!.write(data); - } - } - - /** - * Start the render loop - */ - private startRenderLoop(): void { - if (this.animationFrameId) return; // already running - const loop = (now?: number) => { - if (!this.isDisposed && this.isOpen) { - this.beforeRender?.(now ?? performance.now()); - this.stepFind(now ?? performance.now()); - - // Render using WASM's native dirty tracking - // The render() method: - // 1. Calls update() once to sync state and check dirty flags - // 2. Only redraws dirty rows when forceAll=false - // 3. Always calls clearDirty() at the end - this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); - - // Replies that arose outside write() reach the PTY within a frame (webtty, ADR 042) - if (this.wasmTerm!.hasResponse()) this.processTerminalResponses(); - - // Keep the input textarea on the cursor cell (webtty) - this.anchorTextarea(); - - this.animationFrameId = requestAnimationFrame(loop); - } - }; - loop(); - } - /** * Get a line from native WASM scrollback buffer * Implements IScrollbackProvider @@ -1199,9 +973,7 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mousemove', this.handleMouseMove); this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); - this.element.removeEventListener('focusin', this.handleFocusIn); - this.element.removeEventListener('focusin', this.handleFocusGained); - this.element.removeEventListener('focusout', this.handleFocusLost); + this.focusTracker.dispose(); this.element.removeEventListener('mousedown', this.handleMenuPress, { capture: true }); this.element.removeEventListener('keydown', this.handleMenuKey, { capture: true }); this.element.removeEventListener('contextmenu', this.handleContextMenu, { @@ -1218,9 +990,7 @@ export class Terminal implements ITerminalCore { // Remove document-level listeners (only if opened) if (this.isOpen && typeof document !== 'undefined') { document.removeEventListener('mouseup', this.handleMouseUp); - window.removeEventListener('focus', this.handleWindowFocus); - window.removeEventListener('blur', this.handleWindowBlur); - document.removeEventListener('visibilitychange', this.handleVisibilityChange); + this.focusTracker.detachWindow(); } // Clean up scrollbar timers @@ -1530,7 +1300,7 @@ export class Terminal implements ITerminalCore { // match is selected (webtty, ADR 055) this.scrollToBottom(); } - this.awaitingEcho = true; + this.writes.awaitEcho(); this.dataEmitter.fire(data); }; @@ -1764,35 +1534,6 @@ export class Terminal implements ITerminalCore { animate(); } - /** - * Process any pending terminal responses and emit them via onData. - * - * This handles escape sequences that require the terminal to send a response - * back to the PTY, such as: - * - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR - * - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n - * - * Without this, shells like nushell that rely on cursor position queries - * will hang waiting for a response that never comes. - * - * Note: We loop to read all pending responses, not just one. This is important - * when multiple queries are processed in a single write() call (e.g., when - * buffered data is written all at once during terminal initialization). - */ - private processTerminalResponses(): void { - if (!this.wasmTerm) return; - - // Read all pending responses from the WASM terminal - // Multiple responses can be queued if a single write() contained multiple queries - while (true) { - const response = this.wasmTerm.readResponse(); - if (response === null) break; - // Send response back to the PTY via onData - // This is the same path as user keyboard input - this.dataEmitter.fire(response); - } - } - /** Fires onTitleChange when the title differs from the current one. */ private setTitle(title: string): void { if (title === this.currentTitle) return; diff --git a/src/client/vendor/tests/core/find-controller.test.ts b/src/client/vendor/tests/core/find-controller.test.ts new file mode 100644 index 0000000..a4e0bba --- /dev/null +++ b/src/client/vendor/tests/core/find-controller.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'bun:test'; +import { FindController } from '../../ghostty-web/core/find-controller'; +import type { FindHost } from '../../ghostty-web/core/host-types'; +import { FIND_READ_BACKOFF } from '../../ghostty-web/core/terminal-constants'; +import type { SearchState } from '../../ghostty-web/ghostty'; +import type { SearchHighlights } from '../../ghostty-web/search'; +import { loadGhostty } from '../support/wasm'; + +// Find on libghostty-vt's search, stepped frame by frame as the render loop does (ADR 055): +// the count, the highlights, the view taken to a match and kept on it, and the read backoff. + +/** A FindController on a real 20x4 engine; the host records what it is handed. */ +async function finder() { + const ghostty = await loadGhostty(); + const engine = ghostty.createTerminal(20, 4, { scrollbackLines: 1000 }); + const highlights: (SearchHighlights | null)[] = []; + const states: SearchState[] = []; + const view = { y: 0 }; + // The clock moves on by `readMs` at each reading, so a read of the matches takes that long + let clock = 0; + let readMs = 0; + const host: FindHost = { + engine: () => engine, + highlight: (h) => highlights.push(h), + getScrollbackLength: () => engine.getScrollbackLength(), + viewportY: () => view.y, + rows: () => 4, + scrollToLine: (line) => { + view.y = line; + }, + changed: (state) => states.push(state), + }; + const find = new FindController(host, () => { + clock += readMs; + return clock; + }); + let now = 0; + /** Render-loop frames, a second apart. */ + const frames = (n = 1) => { + for (let i = 0; i < n; i++) { + now += 1000; + find.step(now); + } + }; + const lastState = () => states[states.length - 1]; + const last = () => highlights[highlights.length - 1]; + return { + engine, + find, + host, + view, + frames, + states, + highlights, + lastState, + last, + readTakes: (ms: number) => { + readMs = ms; + }, + }; +} + +describe('FindController', () => { + test('finds in its steps, reports the count, and highlights every match', async () => { + const { engine, find, frames, lastState, last } = await finder(); + for (let i = 0; i < 20; i++) engine.write(i % 5 === 0 ? `Error ${i}\r\n` : `ok ${i}\r\n`); + expect(find.finding).toBe(false); + find.find('error'); + expect(find.finding).toBe(true); + // A new search clears the old highlights at once + expect(last()).toBeNull(); + frames(3); + expect(lastState()).toEqual({ total: 4, selected: null }); + expect(last()?.matches.length).toBe(16); + expect(last()?.selected).toBeNull(); + engine.free(); + }); + + test('next scrolls to a match out of view, its first row at the top, and wraps', async () => { + const { engine, find, view, frames, lastState } = await finder(); + engine.write('needle\r\n'); + for (let i = 0; i < 20; i++) engine.write(`line ${i}\r\n`); + engine.write('needle'); + find.find('needle'); + frames(2); + const sb = engine.getScrollbackLength(); + // The newest match is on screen: no scroll + expect(find.select('next')).toBe(true); + expect(view.y).toBe(0); + expect(find.anchored).toBe(true); + expect(lastState()).toEqual({ total: 2, selected: 0 }); + // The oldest is the first scrollback row + find.select('next'); + expect(view.y).toBe(sb); + expect(lastState()).toEqual({ total: 2, selected: 1 }); + find.select('previous'); + expect(view.y).toBe(0); + engine.free(); + }); + + test('with no search open, or no engine, next and previous are not performed', async () => { + const { engine, find, host } = await finder(); + expect(find.select('next')).toBe(false); + expect(find.select('previous')).toBe(false); + find.find('x'); + const lost = new FindController({ ...host, engine: () => undefined }); + lost.find('x'); + expect(lost.select('next')).toBe(false); + // A step without an engine does nothing + lost.step(1000); + engine.free(); + }); + + test('a step keeps a scrolled view on its match while output scrolls it', async () => { + const { engine, find, view, frames } = await finder(); + engine.write('needle\r\n'); + for (let i = 0; i < 20; i++) engine.write(`line ${i}\r\n`); + find.find('needle'); + frames(2); + find.select('next'); + expect(view.y).toBe(engine.getScrollbackLength()); + const before = view.y; + engine.write('more\r\nmore\r\nmore\r\n'); + frames(); + expect(view.y).toBe(before + 3); + engine.free(); + }); + + test('while output keeps changing the matches, a read waits for its backoff', async () => { + const { engine, find, highlights, readTakes } = await finder(); + // Each read of the matches takes 2 ms: the next one waits 2 * backoff ms + readTakes(2); + engine.write('abc'); + find.find('abc'); + find.step(1000); + const reads = highlights.length; + engine.write(' abc'); + find.step(1000 + 2 * FIND_READ_BACKOFF - 1); + expect(highlights.length).toBe(reads); + find.step(1000 + 2 * FIND_READ_BACKOFF); + expect(highlights.length).toBe(reads + 1); + engine.free(); + }); + + test('ending the search clears the highlights and the count', async () => { + const { engine, find, frames, lastState, last } = await finder(); + engine.write('abc abc'); + find.find('abc'); + frames(); + find.select('next'); + frames(); + find.find(''); + expect(find.finding).toBe(false); + expect(find.anchored).toBe(false); + expect(last()).toBeNull(); + expect(lastState()).toEqual({ total: 0, selected: null }); + frames(); + expect(last()).toBeNull(); + engine.free(); + }); + + test('restart sets the needle again, and does nothing with no search open', async () => { + const { engine, find, highlights } = await finder(); + find.restart(); + expect(highlights).toEqual([]); + find.find('abc'); + find.restart(); + expect(highlights).toEqual([null, null]); + engine.free(); + }); + + test('reports only a changed count or selection', async () => { + const { engine, find, frames, states } = await finder(); + engine.write('abc'); + find.find('abc'); + frames(3); + find.find('abc'); + frames(3); + expect(states).toEqual([{ total: 1, selected: null }]); + engine.free(); + }); +}); diff --git a/src/client/vendor/tests/core/focus-tracker.test.ts b/src/client/vendor/tests/core/focus-tracker.test.ts new file mode 100644 index 0000000..9c5c905 --- /dev/null +++ b/src/client/vendor/tests/core/focus-tracker.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { + type ListenerRecord, + RecordingTarget, + recordListeners, +} from '../../../test-support/recording-target'; +import { type FocusEnv, FocusTracker } from '../../ghostty-web/core/focus-tracker'; +import { loadGhostty } from '../support/wasm'; + +// Mode 1004 focus reports, encoded by Ghostty and sent as Ghostty's Surface sends them: on a +// focus change while the mode is on, and the current state when the mode is set (ADR 053); +// and focus kept off the container. A happy-dom container and textarea, a recording window +// and document, a real engine. + +/** A document stand-in whose visibility, window focus and active element a test sets. */ +class FakePage extends RecordingTarget { + visibilityState: DocumentVisibilityState = 'visible'; + activeElement: Element | null = null; + focused = true; + + hasFocus(): boolean { + return this.focused; + } +} + +let dom: DomWindow | undefined; + +afterEach(async () => { + await dom?.close(); + dom = undefined; +}); + +/** A tracker attached to a container holding a textarea; `sent` collects its reports. */ +async function tracked(options: { disableStdin?: boolean } = {}) { + dom = createDomWindow(); + const { document, window } = dom; + const container = document.createElement('div'); + const textarea = document.createElement('textarea'); + container.appendChild(textarea); + document.body.appendChild(container); + const containerLog = recordListeners(container, 'container'); + const page = new FakePage('document'); + const win = new RecordingTarget('window'); + const env: FocusEnv = { window: win, document: page }; + // One log for the three targets, in the order the listeners are added + const timeline: ListenerRecord[] = []; + for (const target of [containerLog, page, win]) target.timeline = timeline; + const engine = (await loadGhostty()).createTerminal(20, 2); + const sent: string[] = []; + let element: HTMLElement | undefined = container; + const tracker = new FocusTracker({ + element: () => element, + textarea: () => textarea, + engine: () => engine, + disableStdin: () => options.disableStdin ?? false, + send: (data) => sent.push(data), + }); + tracker.attach(container, env); + const focusEvent = (type: string, relatedTarget: EventTarget | null = null) => + new window.FocusEvent(type, { relatedTarget }); + return { + tracker, + engine, + sent, + container, + textarea, + page, + win, + containerLog, + timeline, + write: (data: string) => { + engine.write(data); + tracker.followMode(); + }, + focusIn: () => container.dispatchEvent(focusEvent('focusin')), + focusOut: (to: EventTarget | null = null) => + container.dispatchEvent(focusEvent('focusout', to)), + windowBlur: () => win.dispatch({ type: 'blur' }), + windowFocus: () => win.dispatch({ type: 'focus' }), + visibility: () => page.dispatch({ type: 'visibilitychange' }), + detachElement: () => { + element = undefined; + }, + }; +} + +describe('FocusTracker listeners', () => { + test('adds the container, window and document listeners in open() order', async () => { + const { timeline, engine } = await tracked(); + expect(timeline.map((r) => `${r.target} ${r.type}`)).toEqual([ + 'container focusin', + 'window focus', + 'container focusin', + 'container focusout', + 'window blur', + 'document visibilitychange', + ]); + engine.free(); + }); + + test('dispose removes the container listeners only, once; detachWindow the rest', async () => { + const { tracker, containerLog, win, page, engine } = await tracked(); + tracker.dispose(); + tracker.dispose(); + expect(containerLog.active).toEqual([]); + expect(containerLog.removed).toHaveLength(3); + // leaks until ADR 065 + expect(win.active).toHaveLength(2); + expect(page.active).toHaveLength(1); + tracker.detachWindow(); + expect(win.active).toEqual([]); + expect(page.active).toEqual([]); + engine.free(); + }); + + test('detachWindow before attach does nothing', () => { + const tracker = new FocusTracker({ + element: () => undefined, + textarea: () => undefined, + engine: () => undefined, + disableStdin: () => false, + send: () => {}, + }); + expect(() => tracker.detachWindow()).not.toThrow(); + }); +}); + +describe('FocusTracker keeps focus off the container', () => { + test('focus landing on the container goes to the textarea', async () => { + const { container, textarea, engine } = await tracked(); + container.tabIndex = 0; + container.focus(); + expect(container.ownerDocument.activeElement).toBe(textarea); + engine.free(); + }); + + test('a window coming back with the container active focuses the textarea', async () => { + const { container, textarea, page, windowFocus, detachElement, engine } = await tracked(); + let focused = 0; + textarea.focus = () => { + focused++; + }; + page.activeElement = textarea; + windowFocus(); + expect(focused).toBe(0); + page.activeElement = container; + windowFocus(); + expect(focused).toBe(1); + // After teardown the leaked listener finds no container + detachElement(); + windowFocus(); + expect(focused).toBe(1); + engine.free(); + }); +}); + +describe('FocusTracker mode 1004 reports', () => { + test('sends nothing on focus changes while mode 1004 is off', async () => { + const { sent, focusIn, focusOut, engine } = await tracked(); + focusIn(); + focusOut(); + expect(sent).toEqual([]); + engine.free(); + }); + + test('sends the current focus when the app sets the mode, then each change', async () => { + const { sent, write, focusIn, focusOut, engine } = await tracked(); + focusIn(); + write('\x1b[?1004h'); + expect(sent).toEqual(['\x1b[I']); + focusOut(); + focusIn(); + expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I']); + // Setting it again while it is on, or other output, sends nothing. + write('\x1b[?1004hplain'); + expect(sent).toHaveLength(3); + write('\x1b[?1004l'); + focusOut(); + expect(sent).toHaveLength(3); + // Set again while unfocused: the report says so. + write('\x1b[?1004h'); + expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I', '\x1b[O']); + engine.free(); + }); + + test('a move inside the terminal, or a repeated event, is not a change', async () => { + const { sent, write, focusIn, focusOut, textarea, engine } = await tracked(); + focusIn(); + write('\x1b[?1004h'); + // The container passing focus on to the textarea: out with an inside target, in. + focusOut(textarea); + focusIn(); + focusIn(); + expect(sent).toEqual(['\x1b[I']); + focusOut(); + focusOut(); + expect(sent).toEqual(['\x1b[I', '\x1b[O']); + engine.free(); + }); + + test('a window blur, or a tab hidden and shown with no blur (Chrome), is a change', async () => { + const { sent, write, focusIn, windowBlur, visibility, page, textarea, engine } = + await tracked(); + page.activeElement = textarea; + focusIn(); + write('\x1b[?1004h'); + windowBlur(); + focusIn(); + page.visibilityState = 'hidden'; + visibility(); + page.visibilityState = 'visible'; + visibility(); + expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I', '\x1b[O', '\x1b[I']); + // Shown with focus elsewhere in the page, or in another window: still unfocused. + page.visibilityState = 'hidden'; + visibility(); + page.activeElement = null; + page.visibilityState = 'visible'; + visibility(); + page.activeElement = textarea; + page.focused = false; + visibility(); + expect(sent).toHaveLength(6); + engine.free(); + }); + + test('resync sends the current focus while the mode is on, for a new connection', async () => { + const { tracker, sent, write, focusIn, focusOut, engine } = await tracked(); + focusIn(); + tracker.resync(); + expect(sent).toEqual([]); + write('\x1b[?1004h'); + // The socket goes down: this report is dropped on the way to the app. + focusOut(); + sent.length = 0; + // The replay of a reconnect leaves the mode on, which sends nothing: the resync does. + write('\x1b[?1004h'); + expect(sent).toEqual([]); + tracker.resync(); + expect(sent).toEqual(['\x1b[O']); + engine.free(); + }); + + test('a new engine starts with the mode off: setting it reports again', async () => { + const { tracker, sent, write, focusIn, engine } = await tracked(); + focusIn(); + write('\x1b[?1004h'); + tracker.reset(); + write('\x1b[?1004h'); + expect(sent).toEqual(['\x1b[I', '\x1b[I']); + engine.free(); + }); + + test('sends nothing with stdin disabled', async () => { + const { sent, write, focusIn, focusOut, engine } = await tracked({ disableStdin: true }); + write('\x1b[?1004h'); + focusIn(); + focusOut(); + expect(sent).toEqual([]); + engine.free(); + }); +}); diff --git a/src/client/vendor/tests/core/render-loop.test.ts b/src/client/vendor/tests/core/render-loop.test.ts new file mode 100644 index 0000000..e1b93e8 --- /dev/null +++ b/src/client/vendor/tests/core/render-loop.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import type { RenderLoopHost } from '../../ghostty-web/core/host-types'; +import { RenderLoop } from '../../ghostty-web/core/render-loop'; + +// The render loop on a manual clock: each frame's fixed order (ADR 061), the first frame +// run at once, stopping and restarting, and one frame callback reused for every frame. + +/** A loop whose host logs each step; `replies` says whether replies are waiting. */ +function loop(start = 100) { + const clock = new ManualClock(start); + const calls: string[] = []; + const state = { live: true, replies: false }; + const host: RenderLoopHost = { + live: () => state.live, + beforeRender: (now) => calls.push(`beforeRender ${now}`), + stepFind: (now) => calls.push(`stepFind ${now}`), + render: () => calls.push('render'), + hasReplies: () => state.replies, + drainReplies: () => calls.push('drainReplies'), + anchorTextarea: () => calls.push('anchorTextarea'), + }; + const callbacks = new Set<unknown>(); + const frames = { + requestAnimationFrame: (callback: FrameRequestCallback) => { + callbacks.add(callback); + return clock.requestAnimationFrame(callback); + }, + cancelAnimationFrame: clock.cancelAnimationFrame, + }; + return { clock, calls, state, callbacks, renderLoop: new RenderLoop(host, frames, clock.now) }; +} + +describe('RenderLoop', () => { + test('start runs a frame at once on the clock, in order, and queues the next', () => { + const { calls, clock, renderLoop } = loop(); + renderLoop.start(); + expect(calls).toEqual(['beforeRender 100', 'stepFind 100', 'render', 'anchorTextarea']); + expect(clock.pending.frames).toBe(1); + }); + + test('each frame passes its time, and drains replies before anchoring the textarea', () => { + const { calls, clock, state, renderLoop } = loop(0); + renderLoop.start(); + calls.length = 0; + state.replies = true; + clock.frame(16); + expect(calls).toEqual([ + 'beforeRender 16', + 'stepFind 16', + 'render', + 'drainReplies', + 'anchorTextarea', + ]); + }); + + test('start while running does nothing', () => { + const { calls, clock, renderLoop } = loop(); + renderLoop.start(); + renderLoop.start(); + expect(calls.filter((c) => c === 'render')).toHaveLength(1); + expect(clock.pending.frames).toBe(1); + }); + + test('cancel stops it, twice is a no-op, and start runs it again', () => { + const { calls, clock, renderLoop } = loop(); + renderLoop.start(); + renderLoop.cancel(); + renderLoop.cancel(); + expect(clock.pending.frames).toBe(0); + calls.length = 0; + clock.frame(); + expect(calls).toEqual([]); + renderLoop.start(); + expect(calls).toContain('render'); + }); + + test('stops at the first frame where the host is not live', () => { + const { calls, clock, state, renderLoop } = loop(); + renderLoop.start(); + state.live = false; + calls.length = 0; + clock.frame(); + expect(calls).toEqual([]); + expect(clock.pending.frames).toBe(0); + }); + + test('every frame requests the same callback: a frame allocates no closure', () => { + const { clock, callbacks, renderLoop } = loop(); + renderLoop.start(); + for (let i = 0; i < 5; i++) clock.frame(); + expect(callbacks.size).toBe(1); + }); +}); diff --git a/src/client/vendor/tests/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts new file mode 100644 index 0000000..f62871c --- /dev/null +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import type { WriteEngine, WriteHost } from '../../ghostty-web/core/host-types'; +import { WritePipeline } from '../../ghostty-web/core/write-pipeline'; +import type { TerminalEffects } from '../../ghostty-web/ghostty'; +import { loadGhostty } from '../support/wasm'; + +// The write pipeline: one write's fixed order against a recording engine and host +// (ADR 061), the effects, the auto-scroll, the echo render, the write callback, the +// reply drain and clear screen at ground (ADR 054) on a real engine. + +const NO_EFFECTS: TerminalEffects = { + bell: false, + title: null, + clipboard: null, + notifications: [], +}; + +interface Options { + /** What the engine's readEffects returns once. */ + effects?: TerminalEffects; + replies?: string[]; + viewportY?: number; + anchored?: boolean; + alternate?: boolean; + disableStdin?: boolean; +} + +/** A pipeline on a recording engine and host: `calls` logs both, in order. */ +function recorded(options: Options = {}) { + const calls: string[] = []; + const replies = [...(options.replies ?? [])]; + let effects = options.effects ?? NO_EFFECTS; + const engine: WriteEngine = { + write: (data) => { + calls.push(`engine.write ${JSON.stringify(typeof data === 'string' ? data : [...data])}`); + }, + writeUntilGround: () => null, + isAlternateScreen: () => options.alternate ?? false, + cursorAtPrompt: () => false, + getCursor: () => ({ + x: 0, + y: 0, + viewportX: 0, + viewportY: 0, + visible: true, + style: 'block', + blinking: false, + }), + readResponse: () => { + const reply = replies.shift() ?? null; + if (reply !== null) calls.push(`reply ${reply}`); + return reply; + }, + readEffects: () => { + calls.push('readEffects'); + const once = effects; + effects = NO_EFFECTS; + return once; + }, + }; + const host = recordingHost(calls, () => engine, options); + const clock = new ManualClock(); + return { calls, clock, pipeline: new WritePipeline(host, clock) }; +} + +/** A host that logs every call into `calls`. */ +function recordingHost( + calls: string[], + engine: () => WriteEngine | undefined, + options: Options, +): WriteHost { + return { + engine, + disableStdin: () => options.disableStdin ?? false, + send: (data) => calls.push(`send ${JSON.stringify(data)}`), + followFocusMode: () => calls.push('followFocusMode'), + bell: () => calls.push('bell'), + title: (title) => calls.push(`title ${title}`), + clipboardWrite: (text) => calls.push(`clipboard ${text}`), + notify: (n) => calls.push(`notify ${n.title}/${n.body}`), + outputChanged: () => calls.push('outputChanged'), + viewportY: () => options.viewportY ?? 0, + findAnchored: () => options.anchored ?? false, + scrollToBottom: () => calls.push('scrollToBottom'), + renderEcho: () => calls.push('renderEcho'), + }; +} + +describe('WritePipeline order', () => { + test('write, replies, focus edge, effects, link cache, auto-scroll, callback, echo', () => { + const { calls, clock, pipeline } = recorded({ + effects: { + bell: true, + title: 'vim', + clipboard: 'copied', + notifications: [ + { title: 'a', body: '1' }, + { title: 'b', body: '2' }, + ], + }, + replies: ['\x1b[1;1R', '\x1b[0n'], + viewportY: 5, + }); + let called = 0; + pipeline.awaitEcho(); + pipeline.write('x', () => called++); + expect(calls).toEqual([ + 'engine.write "x"', + 'reply \x1b[1;1R', + 'send "\\u001b[1;1R"', + 'reply \x1b[0n', + 'send "\\u001b[0n"', + 'followFocusMode', + 'readEffects', + 'bell', + 'title vim', + 'clipboard copied', + 'notify a/1', + 'notify b/2', + 'outputChanged', + 'scrollToBottom', + 'renderEcho', + ]); + // The callback waits for the next frame + expect(called).toBe(0); + clock.frame(); + expect(called).toBe(1); + }); + + test('without effects, at the bottom and with no input sent, only the fixed steps run', () => { + const { calls, clock, pipeline } = recorded(); + pipeline.write('x'); + expect(calls).toEqual(['engine.write "x"', 'followFocusMode', 'readEffects', 'outputChanged']); + expect(clock.pending.frames).toBe(0); + }); + + test('the echo render runs once, for the first write after input', () => { + const { calls, pipeline } = recorded(); + pipeline.awaitEcho(); + pipeline.write('a'); + pipeline.write('b'); + expect(calls.filter((c) => c === 'renderEcho')).toHaveLength(1); + }); + + test.each([ + // [anchored on a find match, alternate screen, scrolls to the bottom] + [false, false, true], + [true, false, false], + [true, true, true], + ])( + 'a scrolled view, anchored %p, alternate %p: scrolls to the bottom %p', + (anchored, alternate, scrolls) => { + const { calls, pipeline } = recorded({ viewportY: 3, anchored, alternate }); + pipeline.write('x'); + expect(calls.includes('scrollToBottom')).toBe(scrolls); + }, + ); + + test('drainReplies sends every queued reply, and nothing without an engine', () => { + const { calls, pipeline } = recorded({ replies: ['a', 'b'] }); + pipeline.drainReplies(); + expect(calls).toEqual(['reply a', 'send "a"', 'reply b', 'send "b"']); + const none: string[] = []; + new WritePipeline(recordingHost(none, () => undefined, {})).drainReplies(); + expect(none).toEqual([]); + }); +}); + +describe('WritePipeline clear at ground', () => { + // Ghostty's clear_screen written to the terminal (ADR 054), on a real 20x5 engine. + async function engine() { + const ghostty = await loadGhostty(); + const term = ghostty.createTerminal(20, 5); + const calls: string[] = []; + const pipeline = new WritePipeline(recordingHost(calls, () => term, {})); + const sent = () => calls.filter((c) => c.startsWith('send')); + return { term, pipeline, sent }; + } + + test('waits for a sequence cut off by a write, then clears where it ends', async () => { + const { term, pipeline, sent } = await engine(); + for (let i = 0; i < 8; i++) pipeline.write(`line${i}\r\n`); + pipeline.write('\x1b[3'); + pipeline.clearAtGround(); + expect(term.getScrollbackLength()).toBeGreaterThan(0); + // The sequence ends (CUF 3): the clear goes in after it + pipeline.write('Cafter'); + expect(term.getScrollbackLength()).toBe(0); + expect(sent()).toEqual([]); + term.free(); + }); + + test('at an OSC 133 prompt, erases everything and sends FF', async () => { + const { term, pipeline, sent } = await engine(); + pipeline.write('old\r\n\x1b]133;A\x07$ '); + pipeline.clearAtGround(); + expect(sent()).toEqual(['send "\\f"']); + term.free(); + }); + + test('reset drops a waiting clear', async () => { + const { term, pipeline } = await engine(); + for (let i = 0; i < 8; i++) pipeline.write(`line${i}\r\n`); + pipeline.write('\x1b[3'); + pipeline.clearAtGround(); + pipeline.reset(); + pipeline.write('C'); + expect(term.getScrollbackLength()).toBeGreaterThan(0); + term.free(); + }); + + test('a waiting clear is dropped if the screen has turned alternate', async () => { + const { term, pipeline } = await engine(); + for (let i = 0; i < 8; i++) pipeline.write(`line${i}\r\n`); + const scrollback = term.getScrollbackLength(); + pipeline.write('\x1b[?1049'); + pipeline.clearAtGround(); + pipeline.write('h'); + expect(term.isAlternateScreen()).toBe(true); + pipeline.write('\x1b[?1049l'); + expect(term.getScrollbackLength()).toBe(scrollback); + term.free(); + }); +}); diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index dab49b0..cee4f4d 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -324,7 +324,7 @@ describe('Terminal find', () => { ghostty: typeof ghostty; renderer: typeof renderer; showScrollbar: () => void; - stepFind: (now: number) => void; + finder: { step(now: number): void }; }; internals.wasmTerm = ghostty.createTerminal(20, 4, { scrollbackLines: 1000 }); internals.ghostty = ghostty; @@ -336,7 +336,7 @@ describe('Terminal find', () => { const frames = (n = 1) => { for (let i = 0; i < n; i++) { now += 1000; - internals.stepFind(now); + internals.finder.step(now); } }; const states: unknown[] = []; @@ -349,47 +349,6 @@ describe('Terminal find', () => { return { term, internals, frames, states, last }; } - test('finds in the render loop, reports the count, and highlights every match', async () => { - const { term, internals, frames, states, last } = await findTerm(); - for (let i = 0; i < 20; i++) term.write(i % 5 === 0 ? `Error ${i}\r\n` : `ok ${i}\r\n`); - expect(term.finding).toBe(false); - term.find('error'); - expect(term.finding).toBe(true); - frames(3); - expect(states[states.length - 1]).toEqual({ total: 4, selected: null }); - expect(last()?.matches.length).toBe(16); - expect(last()?.selected).toBeNull(); - internals.wasmTerm.free(); - }); - - test('next scrolls to a match out of view, its first row at the top, and wraps', async () => { - const { term, internals, frames, states } = await findTerm(); - term.write('needle\r\n'); - for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); - term.write('needle'); - term.find('needle'); - frames(2); - const sb = term.getScrollbackLength(); - // The newest match is on screen: no scroll - expect(term.findNext()).toBe(true); - expect(term.viewportY).toBe(0); - expect(states[states.length - 1]).toEqual({ total: 2, selected: 0 }); - // The oldest is the first scrollback row - term.findNext(); - expect(term.viewportY).toBe(sb); - expect(states[states.length - 1]).toEqual({ total: 2, selected: 1 }); - term.findPrevious(); - expect(term.viewportY).toBe(0); - internals.wasmTerm.free(); - }); - - test('with no search open, next and previous are not performed', async () => { - const { term, internals } = await findTerm(); - expect(term.findNext()).toBe(false); - expect(term.findPrevious()).toBe(false); - internals.wasmTerm.free(); - }); - test('output keeps a scrolled view on its rows while a match is selected', async () => { const { term, internals, frames } = await findTerm(); term.write('needle\r\n'); @@ -516,7 +475,7 @@ describe('Terminal bell and title', () => { }, }; const write = (data: string) => - (term as unknown as { writeInternal(data: string): void }).writeInternal(data); + (term as unknown as { writes: { write(data: string): void } }).writes.write(data); let bells = 0; const titles: string[] = []; term.onBell(() => bells++); @@ -533,137 +492,22 @@ describe('Terminal bell and title', () => { }); describe('Terminal focus reports', () => { - // Mode 1004 reports are encoded by Ghostty and sent as Ghostty's Surface sends them: - // on a focus change while the mode is on, and the current state when it is set - // (ADR 053). - async function focusTerm(options: object = {}) { + // The focus tracker hears of each write and of a new connection (ADR 053); its own + // tests are in core/focus-tracker.test.ts. + test('reports when a write sets mode 1004, and on resyncFocus, the focus it has', async () => { const ghostty = await loadGhostty(); const wasmTerm = ghostty.createTerminal(20, 2); - const term = new Terminal({ ghostty: {} as never, ...options }); - const element = { contains: (node: unknown) => node === inside }; - const inside = {}; - const internals = term as unknown as { - isOpen: boolean; - wasmTerm: unknown; - element: unknown; - handleFocusGained(): void; - handleFocusLost(e: { relatedTarget: unknown }): void; - handleWindowBlur(): void; - handleVisibilityChange(): void; - writeInternal(data: string): void; - }; + const term = new Terminal({ ghostty: {} as never }); + const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; internals.wasmTerm = wasmTerm; internals.isOpen = true; - internals.element = element; const sent: string[] = []; term.onData((data) => sent.push(data)); - return { - term, - wasmTerm, - sent, - write: (data: string) => internals.writeInternal(data), - focusIn: () => internals.handleFocusGained(), - focusOut: (relatedTarget: unknown = null) => internals.handleFocusLost({ relatedTarget }), - windowBlur: () => internals.handleWindowBlur(), - visibility: () => internals.handleVisibilityChange(), - inside, - }; - } - - test('sends nothing on focus changes while mode 1004 is off', async () => { - const { wasmTerm, sent, focusIn, focusOut } = await focusTerm(); - focusIn(); - focusOut(); - expect(sent).toEqual([]); - wasmTerm.free(); - }); - - test('sends the current focus when the app sets the mode, then each change', async () => { - const { wasmTerm, sent, write, focusIn, focusOut } = await focusTerm(); - focusIn(); - write('\x1b[?1004h'); - expect(sent).toEqual(['\x1b[I']); - focusOut(); - focusIn(); - expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I']); - // Setting it again while it is on, or other output, sends nothing. - write('\x1b[?1004hplain'); - expect(sent).toHaveLength(3); - write('\x1b[?1004l'); - focusOut(); - expect(sent).toHaveLength(3); - // Set again while unfocused: the report says so. - write('\x1b[?1004h'); - expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I', '\x1b[O']); - wasmTerm.free(); - }); - - test('a move inside the terminal, or a repeated event, is not a change', async () => { - const { wasmTerm, sent, write, focusIn, focusOut, inside } = await focusTerm(); - focusIn(); - write('\x1b[?1004h'); - // The container passing focus on to the textarea: out with an inside target, in. - focusOut(inside); - focusIn(); - focusIn(); - expect(sent).toEqual(['\x1b[I']); - focusOut(); - focusOut(); - expect(sent).toEqual(['\x1b[I', '\x1b[O']); - wasmTerm.free(); - }); - - test('a window blur, or a tab hidden and shown with no blur (Chrome), is a change', async () => { - const { wasmTerm, sent, write, focusIn, windowBlur, visibility, inside } = await focusTerm(); - const doc = globalThis.document; - const page = { visibilityState: 'visible', hasFocus: () => true, activeElement: inside }; - globalThis.document = page as unknown as Document; - try { - focusIn(); - write('\x1b[?1004h'); - windowBlur(); - focusIn(); - page.visibilityState = 'hidden'; - visibility(); - page.visibilityState = 'visible'; - visibility(); - expect(sent).toEqual(['\x1b[I', '\x1b[O', '\x1b[I', '\x1b[O', '\x1b[I']); - // Shown with focus elsewhere in the page, or in another window: still unfocused. - page.visibilityState = 'hidden'; - visibility(); - page.activeElement = {}; - page.visibilityState = 'visible'; - visibility(); - expect(sent).toHaveLength(6); - } finally { - globalThis.document = doc; - } - wasmTerm.free(); - }); - - test('resyncFocus sends the current focus while the mode is on, for a new connection', async () => { - const { term, wasmTerm, sent, write, focusIn, focusOut } = await focusTerm(); - focusIn(); term.resyncFocus(); expect(sent).toEqual([]); - write('\x1b[?1004h'); - // The socket goes down: this report is dropped on the way to the app. - focusOut(); - sent.length = 0; - // The replay of a reconnect leaves the mode on, which sends nothing: the resync does. - write('\x1b[?1004h'); - expect(sent).toEqual([]); + term.write('\x1b[?1004h'); term.resyncFocus(); - expect(sent).toEqual(['\x1b[O']); - wasmTerm.free(); - }); - - test('sends nothing with stdin disabled', async () => { - const { wasmTerm, sent, write, focusIn, focusOut } = await focusTerm({ disableStdin: true }); - write('\x1b[?1004h'); - focusIn(); - focusOut(); - expect(sent).toEqual([]); + expect(sent).toEqual(['\x1b[O', '\x1b[O']); wasmTerm.free(); }); }); @@ -676,7 +520,7 @@ describe('Terminal clipboard writes', () => { const term = new Terminal({ ghostty: {} as never }); (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); const write = (data: string) => - (term as unknown as { writeInternal(data: string): void }).writeInternal(data); + (term as unknown as { writes: { write(data: string): void } }).writes.write(data); const copied: string[] = []; term.onClipboardWrite((text) => copied.push(text)); @@ -707,7 +551,7 @@ describe('Terminal desktop notifications', () => { const term = new Terminal({ ghostty: {} as never }); (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); const write = (data: string) => - (term as unknown as { writeInternal(data: string): void }).writeInternal(data); + (term as unknown as { writes: { write(data: string): void } }).writes.write(data); const shown: { title: string; body: string }[] = []; term.onDesktopNotification((n) => shown.push(n)); From 06e858039856397ca0f8ce9fe9579bdd77f0a238 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:03:16 -0400 Subject: [PATCH 251/295] refactor(client): terminal split, part 3: mouse and wheel controllers Terminal's mouse and wheel handling become two controllers in core/ (ADR 061), each reaching the terminal through a narrow host in core/host-types.ts; terminal.ts goes from 1563 to 1248 lines: - mouse-controller.ts: MouseController keeps the link hover (one lookup at once, then at most one per 16 ms for the last move, on injected timeouts), the cursor and underline, link clicks, the mouse report encoder (ADR 049) and the one right-click route (ADR 051). attach() adds move, leave, click and the menu's press, key and contextmenu at the position open() always added them, after the scrollbar press; dispose() removes them (a DisposableStore) and drops a throttled move. The scrollbar drag still takes a move first, through MouseHost.dragScrollbar. - wheel-controller.ts: WheelController keeps the zoom cancel, the embedder's handler, the ticks to an app that tracks the mouse and the arrows on the alternate screen, with their accumulators and the TickPacer; attach() adds the capture, non-passive wheel listener, dispose() removes it and drops the queued ticks. Both disposes run in cleanupComponents, so the pacer cancel and the throttle clear move from the top of dispose() to its teardown (noted in PROVENANCE.md). The listener snapshot is unchanged. The wheel, link hover and right-click cases move from terminal.test.ts to vendor/tests/core/{mouse,wheel}-controller.test.ts, on the controllers instead of Terminal's privates; terminal.test.ts drops under the test limit and leaves the ratchet allowlist. --- scripts/architecture/ratchet-allowlist.ts | 4 +- src/client/vendor/ghostty-web/PROVENANCE.md | 6 +- .../vendor/ghostty-web/core/host-types.ts | 91 ++++ .../ghostty-web/core/mouse-controller.ts | 249 +++++++++++ .../ghostty-web/core/wheel-controller.ts | 123 ++++++ .../vendor/ghostty-web/input-handler.ts | 2 +- src/client/vendor/ghostty-web/terminal.ts | 411 ++---------------- src/client/vendor/ghostty-web/wheel.ts | 2 +- .../tests/core/mouse-controller.test.ts | 395 +++++++++++++++++ .../tests/core/wheel-controller.test.ts | 253 +++++++++++ src/client/vendor/tests/terminal.test.ts | 239 +--------- 11 files changed, 1171 insertions(+), 604 deletions(-) create mode 100644 src/client/vendor/ghostty-web/core/mouse-controller.ts create mode 100644 src/client/vendor/ghostty-web/core/wheel-controller.ts create mode 100644 src/client/vendor/tests/core/mouse-controller.test.ts create mode 100644 src/client/vendor/tests/core/wheel-controller.test.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 64487be..aab8e0e 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -5,7 +5,6 @@ import type { Allowlist } from './ratchet'; -const VENDOR_TESTS = 'vendor tests split with their facades into vendor/tests/ (ADR 061, ADR 064)'; const SERVER_HARNESS = 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; @@ -17,10 +16,9 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, 'src/client/vendor/ghostty-web/terminal.ts': { - lines: 1563, + lines: 1248, step: 'vendor terminal split into core/ (ADR 061)', }, - 'src/client/vendor/tests/terminal.test.ts': { lines: 847, step: VENDOR_TESTS }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, }, diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 1cda5a6..6fc3ba7 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -54,6 +54,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `buffer.ts`, `addons/fit.ts`, `interfaces.ts`: the buffer API and `FitAddon` stop reaching into `Terminal` through `any` (ADR 061). `BufferNamespace` reads the engine of an `IBufferSource` (`{ wasmTerm? }`, the engine narrowed to `BufferEngine`), and `ITerminalCore` gains the optional `renderer` (its `getMetrics`) that `FitAddon` reads. `FitAddon` takes an optional computed-style reader, `window.getComputedStyle` by default, so tests inject one. - `terminal.ts`: `writeQueue` and `flushWriteQueue`, which nothing ever filled, are removed with the split of the write pipeline (ADR 061). - `terminal.ts`: `viewportY` is an accessor over the new `Viewport` (`core/viewport.ts`), reading and setting its `y` as the field was read and set; `options` is typed as the stored values (`TerminalOptionValues`: every option but `ghostty`, `scrollbackLines` optional) instead of `Required<ITerminalOptions>`, and its proxy no longer goes through `any` (ADR 061). +- `terminal.ts`: the queued wheel ticks and a throttled mouse move are dropped with their listeners in `cleanupComponents` (`WheelController.dispose`, `MouseController.dispose`) instead of at the top of `dispose()`; both are idle before an `open()` that failed, so nothing observable changes (ADR 061). ## webtty-original files @@ -72,7 +73,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. -- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`) and the render loop (`render-loop.ts`). `terminal.ts` is the facade over them and re-exports what moved. +- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`), the render loop (`render-loop.ts`), link hover and clicks, the mouse report encoder and the right-click route (`mouse-controller.ts`), and wheel routing (`wheel-controller.ts`). `terminal.ts` is the facade over them and re-exports what moved. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -150,3 +151,6 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `terminal.ts`: `writeInternal` (now `write`), `writeClear`, the pending clear of `clearScreen` (now `clearAtGround`), `processTerminalResponses` (now `drainReplies`), the `pendingClear` and `awaitingEcho` fields; `writeQueue` and `flushWriteQueue` are dropped (never filled) | `core/write-pipeline.ts` (`WritePipeline`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | | `terminal.ts`: `startRenderLoop` (its loop closure now the one `frame` callback), `cancelRenderLoop` and the `animationFrameId` field (now `start`, `cancel`), on injected animation frames and clock | `core/render-loop.ts` (`RenderLoop`, `browserFrames`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | | `terminal.ts`: what the four parts above reach of `Terminal` | `core/host-types.ts` (`FrameScheduler`, `FocusHost`, `FindEngine`, `FindHost`, `WriteEngine`, `WriteHost`, `RenderLoopHost`) | refactor(client): terminal split, part 2: focus, find, render loop, write pipeline | +| `terminal.ts`: `handleMouseMove` (now `move`, the scrollbar drag reached through `MouseHost.dragScrollbar`), `processMouseMove` (now `hover`, `linkAt`, `setHovered`), `handleMouseLeave` (now `leave`), `handleClick` (now `click`), `encodeMouse` (now `encode`; webtty, ADR 049), `handleMenuPress`, `handleMenuKey`, `handleContextMenu` (now `menuPress`, `menuKey`, `contextMenu`; webtty, ADR 051), the `currentHoveredLink` (now `hoveredLink`), `mouseMoveThrottleTimeout`, `pendingMouseMove` and `menuSource` fields, and those listeners' registration in `open()` and removal in `cleanupComponents` and `dispose()` (now `attach`, `dispose`) | `core/mouse-controller.ts` (`MouseController`, `browserTimeouts`) | refactor(client): terminal split, part 3: mouse and wheel controllers | +| `terminal.ts`: `handleWheel` (now `handle`, `sendTicks`, `sendArrows`; webtty, ADR 045, ADR 049), the `customWheelEventHandler`, `wheelPace`, `wheelAccumulator`, `arrowAccumulator` and `tickPacer` fields (the setters of `attachCustomWheelEventHandler` and `attachWheelPace` now `setCustomHandler`, `setPace`), and the wheel listener's registration and removal (now `attach`, `dispose`) | `core/wheel-controller.ts` (`WheelController`) | refactor(client): terminal split, part 3: mouse and wheel controllers | +| `terminal.ts`: what the two parts above reach of `Terminal` | `core/host-types.ts` (`TimeoutScheduler`, `MouseEngine`, `MouseRenderer`, `LinkSource`, `MenuSelection`, `MouseHost`, `WheelEngine`, `WheelHost`) | refactor(client): terminal split, part 3: mouse and wheel controllers | diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index 1530ce4..8874343 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -8,7 +8,11 @@ import type { GhosttyTerminal, SearchState, } from '../engine/engine-types'; +import type { MouseEventReport } from '../input/mouse-report'; +import type { ILink } from '../link-types'; +import type { RightClickBehavior } from '../mouse-route'; import type { SearchHighlights } from '../search'; +import type { ViewportLinkRange } from './terminal-geometry'; /** * What SelectionManager needs from the terminal: its size, the copyOnSelect option, the @@ -161,3 +165,90 @@ export interface RenderLoopHost { /** Keeps the input textarea on the cursor cell. */ anchorTextarea(): void; } + +/** + * The timeouts the mouse-move throttle runs on, injected so tests run them by hand; + * production passes the browser's (`browserTimeouts` in mouse-controller.ts). + */ +export interface TimeoutScheduler { + setTimeout(callback: () => void, ms: number): number; + clearTimeout(id: number | undefined): void; +} + +/** The engine calls the mouse makes: the row mapping, mouse tracking and the encoder (ADR 049). */ +export type MouseEngine = Pick< + GhosttyTerminal, + 'getScrollbackLength' | 'hasMouseTracking' | 'setMouseGeometry' | 'encodeMouse' +>; + +/** + * What the MouseController reads of the renderer: the cell size, how far row 0 sits below + * the canvas top (ADR 043), and the hovered link's underline. + */ +export interface MouseRenderer { + readonly charWidth: number; + readonly charHeight: number; + readonly scrollOffset: number; + getMetrics(): { width: number; height: number }; + setHoveredLinkRange(range: ViewportLinkRange | null): void; +} + +/** The link under a cell (LinkDetector), in absolute buffer rows. */ +export interface LinkSource { + getLinkAt(col: number, row: number): Promise<ILink | undefined>; +} + +/** The selection calls a right-click makes (SelectionManager, ADR 051). */ +export interface MenuSelection { + getSelection(): string; + copySelection(): boolean; + clearSelection(): void; + /** Readies the textarea under the pointer so the browser menu offers Copy. */ + prepareContextMenu(e: MouseEvent): void; +} + +/** + * What the MouseController reads of the terminal, each call made when an event arrives: + * the parts open() creates (undefined before it and after dispose()), the grid and view, + * the device cell size, the right-click option, the scrollbar drag that takes a move + * first, and where a right-click paste goes (onPasteRequest). + */ +export interface MouseHost { + element(): HTMLElement | undefined; + canvas(): HTMLCanvasElement | undefined; + engine(): MouseEngine | undefined; + renderer(): MouseRenderer | undefined; + links(): LinkSource | undefined; + selection(): MenuSelection | undefined; + rows(): number; + cols(): number; + viewportY(): number; + /** The renderer's cell in whole device pixels, or null before open() (ADR 042). */ + cellPixelSize(): { width: number; height: number } | null; + rightClickBehavior(): RightClickBehavior; + /** Moves a scrollbar drag in progress; true when there was one and the move is used up. */ + dragScrollbar(e: MouseEvent): boolean; + requestPaste(): void; +} + +/** The engine calls wheel routing makes: mouse tracking, the screen, and DECCKM. */ +export type WheelEngine = Pick< + GhosttyTerminal, + 'hasMouseTracking' | 'isAlternateScreen' | 'getMode' +>; + +/** + * What the WheelController reads of the terminal when a wheel event arrives: the engine, + * the cell height (undefined before open()), whether the canvas exists, the grid's rows, + * the scroll speed option, the mouse encoder, and where ticks and arrows go (onData). + */ +export interface WheelHost { + engine(): WheelEngine | undefined; + cellHeight(): number | undefined; + hasCanvas(): boolean; + rows(): number; + mouseScrollSpeed(): number; + /** A mouse report at a client position, or '' (MouseController.encode). */ + encodeMouse(event: MouseEventReport): string; + send(data: string): void; +} diff --git a/src/client/vendor/ghostty-web/core/mouse-controller.ts b/src/client/vendor/ghostty-web/core/mouse-controller.ts new file mode 100644 index 0000000..4c0d112 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/mouse-controller.ts @@ -0,0 +1,249 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The terminal's own mouse handling (controller): link hover, throttled to a frame, and +// link clicks; the mouse reports' encoding (ADR 049); and the one right-click route +// (ADR 051). Moved out of terminal.ts (ADR 061). + +import { type MouseEventReport, mouseReportText } from '../input/mouse-report'; +import { + type Disposable, + DisposableStore, + listen, +} from '../lifecycle/disposable-store'; +import type { ILink } from '../link-types'; +import { MenuSource, rightClickAction } from '../mouse-route'; +import type { MouseHost, TimeoutScheduler } from './host-types'; +import { MOUSE_MOVE_THROTTLE_MS } from './terminal-constants'; +import { + linkViewportRange, + mouseCursorFor, + mouseSurface, + pointerCell, + viewportRowToBufferRow, +} from './terminal-geometry'; + +/** The browser's timeouts, read when called. */ +export const browserTimeouts: TimeoutScheduler = { + setTimeout: (callback, ms) => window.setTimeout(callback, ms), + clearTimeout: (id) => clearTimeout(id), +}; + +/** + * Link hover and click, the mouse report encoder and the context menu, on the listeners + * Terminal.open() adds after its scrollbar press: move, leave, click, then the menu's + * press, key and contextmenu. + */ +export class MouseController implements Disposable { + private hoveredLink?: ILink; + private throttle?: number; + private pendingMove?: MouseEvent; + // Whether a contextmenu came from a button or the keyboard (webtty, ADR 051) + private readonly menuSource = new MenuSource(); + private listeners?: DisposableStore; + + constructor( + private readonly host: MouseHost, + private readonly timeouts: TimeoutScheduler = browserTimeouts, + ) {} + + /** Adds the container's listeners, in the order Terminal.open() always added them. */ + attach(container: HTMLElement): void { + const listeners = new DisposableStore(); + this.listeners = listeners; + listeners.add(listen(container, 'mousemove', this.move)); + listeners.add(listen(container, 'mouseleave', this.leave)); + listeners.add(listen(container, 'click', this.click)); + // Every right-click and keyboard menu goes through contextMenu (webtty, ADR 051) + listeners.add(listen(container, 'mousedown', this.menuPress, { capture: true })); + listeners.add(listen(container, 'keydown', this.menuKey, { capture: true })); + listeners.add(listen(container, 'contextmenu', this.contextMenu, { capture: true })); + } + + /** Removes the listeners and drops a throttled move. Calling it again does nothing. */ + dispose(): void { + if (this.throttle) { + this.timeouts.clearTimeout(this.throttle); + this.throttle = undefined; + } + this.pendingMove = undefined; + this.listeners?.dispose(); + } + + /** + * A mouse report at a client position, encoded by Ghostty's mouse encoder in the app's + * tracking mode and format (`mouseSurface`, `mouseReportText`), or '' when the app does + * not get one. (webtty, ADR 049) + */ + encode(event: MouseEventReport): string { + const host = this.host; + const px = host.cellPixelSize(); + const engine = host.engine(); + const renderer = host.renderer(); + const canvas = host.canvas(); + const element = host.element(); + if (!engine || !renderer || !canvas || !element || !px) return ''; + const metrics = renderer.getMetrics(); + const rect = canvas.getBoundingClientRect(); + const { geometry, x, y } = mouseSurface( + event.clientX, + event.clientY, + { + left: rect.left, + // Viewport row 0 starts the scroll offset below the canvas top (ADR 043) + top: rect.top + renderer.scrollOffset, + cols: host.cols(), + rows: host.rows(), + cellWidth: metrics.width, + cellHeight: metrics.height, + }, + element.getBoundingClientRect(), + px, + ); + engine.setMouseGeometry(geometry); + const { clientX: _x, clientY: _y, ...input } = event; + return mouseReportText(engine.encodeMouse({ ...input, x, y })); + } + + /** + * A move drags the scrollbar at once, or looks for a link under the pointer at most once + * a throttle period (16 ms, about a frame), so it never holds up scroll events; the last + * move inside the period is looked at when it ends. + */ + private readonly move = (e: MouseEvent): void => { + const host = this.host; + if (!host.canvas() || !host.renderer() || !host.engine()) return; + if (host.dragScrollbar(e)) return; + if (!host.links()) return; + if (this.throttle) { + this.pendingMove = e; + return; + } + this.hover(e); + this.throttle = this.timeouts.setTimeout(() => { + this.throttle = undefined; + if (this.pendingMove) { + const pending = this.pendingMove; + this.pendingMove = undefined; + this.hover(pending); + } + }, MOUSE_MOVE_THROTTLE_MS); + }; + + /** The link under the pointer, as an absolute buffer row, or undefined before open(). */ + private linkAt(e: MouseEvent): Promise<ILink | undefined> | undefined { + const host = this.host; + const canvas = host.canvas(); + const renderer = host.renderer(); + const links = host.links(); + const engine = host.engine(); + if (!canvas || !renderer || !links || !engine) return undefined; + const { x, y } = pointerCell(e.clientX, e.clientY, canvas.getBoundingClientRect(), renderer); + const row = viewportRowToBufferRow(y, engine.getScrollbackLength(), host.viewportY()); + return links.getLinkAt(x, row); + } + + /** + * Looks for a link under the pointer without waiting for it; a new one gets the hover, + * the pointer cursor and the renderer's underline. + */ + private hover(e: MouseEvent): void { + this.linkAt(e) + ?.then((link) => { + if (link !== this.hoveredLink) this.setHovered(link); + }) + .catch((err) => { + console.warn('Link detection error:', err); + }); + } + + private setHovered(link: ILink | undefined): void { + const host = this.host; + this.hoveredLink?.hover?.(false); + this.hoveredLink = link; + link?.hover?.(true); + // The cursor on both the container and the canvas + const cursorStyle = mouseCursorFor(link); + const element = host.element(); + const canvas = host.canvas(); + if (element) element.style.cursor = cursorStyle; + if (canvas) canvas.style.cursor = cursorStyle; + // The underline: the link's own range, OSC 8 or regex, while any of it is in view (webtty) + host + .renderer() + ?.setHoveredLinkRange( + link + ? linkViewportRange( + link.range, + host.engine()?.getScrollbackLength() || 0, + host.viewportY(), + host.rows(), + ) + : null, + ); + } + + /** The pointer left the container: no link is hovered. */ + private readonly leave = (): void => { + const host = this.host; + const renderer = host.renderer(); + if (renderer && host.engine()) renderer.setHoveredLinkRange(null); + if (!this.hoveredLink) return; + this.hoveredLink.hover?.(false); + this.hoveredLink = undefined; + const element = host.element(); + if (element) { + element.style.cursor = mouseCursorFor(undefined); + const canvas = host.canvas(); + if (canvas) canvas.style.cursor = mouseCursorFor(undefined); + } + }; + + /** + * Activates the link under a click, looked up at click time rather than taken from the + * hover, which may not have settled; with Ctrl or Cmd held the click does nothing else. + */ + private readonly click = async (e: MouseEvent): Promise<void> => { + const link = await this.linkAt(e); + if (!link) return; + link.activate(e); + if (e.ctrlKey || e.metaKey) e.preventDefault(); + }; + + private readonly menuPress = (e: MouseEvent): void => { + this.menuSource.press(e); + }; + + private readonly menuKey = (): void => { + this.menuSource.key(); + }; + + /** + * The one place a right-click or a keyboard menu is handled. A right button the app + * takes (it tracks the mouse, Shift is up) is the app's: its press and release were + * reported, and the menu is cancelled. Otherwise `rightClickBehavior` decides: the + * browser menu, copy and clear the selection, paste, or nothing. (webtty: upstream + * always showed the menu, from the selection manager, ADR 051) + */ + private readonly contextMenu = (e: MouseEvent): void => { + const host = this.host; + const selection = host.selection(); + const behavior = host.rightClickBehavior(); + const action = rightClickAction({ + tracking: host.engine()?.hasMouseTracking() ?? false, + shiftKey: e.shiftKey, + fromKeyboard: this.menuSource.fromKeyboard(e), + behavior, + hasSelection: behavior === 'copyPaste' && (selection?.getSelection() || '') !== '', + }); + if (action === 'menu') { + selection?.prepareContextMenu(e); + return; + } + e.preventDefault(); + if (action === 'copy') { + selection?.copySelection(); + selection?.clearSelection(); + } else if (action === 'paste') { + host.requestPaste(); + } + }; +} diff --git a/src/client/vendor/ghostty-web/core/wheel-controller.ts b/src/client/vendor/ghostty-web/core/wheel-controller.ts new file mode 100644 index 0000000..41da14a --- /dev/null +++ b/src/client/vendor/ghostty-web/core/wheel-controller.ts @@ -0,0 +1,123 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Wheel routing (controller): a wheel event goes to the embedder's handler, as scroll +// ticks to an app that tracks the mouse, or as arrow keys on the alternate screen, paced +// over frames (ADR 045, ADR 049). Moved out of terminal.ts (ADR 061). + +import { AT_ONCE, type Pace } from '../glide'; +import { mouseMods } from '../input/mouse-report'; +import { + type Disposable, + DisposableStore, + listen, +} from '../lifecycle/disposable-store'; +import { TickPacer, type WheelAccumulator, wheelArrowKey, wheelArrows, wheelTicks } from '../wheel'; +import type { WheelHost } from './host-types'; +import { APP_CURSOR_KEYS_MODE } from './terminal-constants'; + +/** The cell height the arrow keys count by before the renderer exists. */ +const FALLBACK_CELL_HEIGHT = 20; + +/** + * The container's capture wheel listener and where each event goes. The normal screen's + * scrollback is scrolled by the custom handler (webtty's SmoothScroll, ADR 040, ADR 045); + * upstream's easing here is removed. + */ +export class WheelController implements Disposable { + private customHandler?: (event: WheelEvent) => boolean; + private pace?: (event: WheelEvent) => Pace; + private readonly tickTravel: WheelAccumulator = { accum: 0, dir: 0 }; + private readonly arrowTravel: WheelAccumulator = { accum: 0, dir: 0 }; + private listeners?: DisposableStore; + + /** `pacer` sends the ticks and arrows over frames; by default to `host.send`. */ + constructor( + private readonly host: WheelHost, + private readonly pacer: Pick<TickPacer, 'push' | 'cancel'> = new TickPacer((data) => + host.send(data), + ), + ) {} + + /** The embedder's handler, which takes an event by returning true. */ + setCustomHandler(handler?: (event: WheelEvent) => boolean): void { + this.customHandler = handler; + } + + /** How to pace the ticks of an event sent to the app; without it they go at once. */ + setPace(pace?: (event: WheelEvent) => Pace): void { + this.pace = pace; + } + + /** + * Adds the wheel listener: capture, so it runs before the browser scrolls, and not + * passive, so it can cancel a zoom. + */ + attach(container: HTMLElement): void { + this.listeners = new DisposableStore(); + this.listeners.add( + listen(container, 'wheel', this.handle, { passive: false, capture: true }), + ); + } + + /** Removes the listener and drops the queued ticks. Calling it again does nothing. */ + dispose(): void { + this.pacer.cancel(); + this.listeners?.dispose(); + } + + /** Routes one wheel event. */ + readonly handle = (e: WheelEvent): void => { + // Cancel only pinch and ctrl+wheel, which would zoom the page. A cancelled wheel + // event makes Chrome hold the gesture's next event until the page has handled it, + // which put scrolling about a frame behind native; nothing else on the page scrolls, + // and overscroll-behavior stops rubber-banding and swipe-back. (webtty, ADR 045) + if (e.ctrlKey) e.preventDefault(); + e.stopPropagation(); + + if (this.customHandler && this.customHandler(e)) return; + + const engine = this.host.engine(); + // App tracks the mouse (e.g. vim `set mouse=a`): report scroll ticks as mouse + // buttons 4/5 (SGR 64/65) at the cell under the pointer, on either screen. + if (engine?.hasMouseTracking()) { + this.sendTicks(e); + return; + } + // Alternate screen (vim, less, htop): arrow keys to the application, one per line of + // travel, paced like the ticks (webtty, ADR 045) + if (engine?.isAlternateScreen() ?? false) this.sendArrows(e); + }; + + /** + * One tick, a press of button 4 (up) or 5 (down) at the pointer's cell as Ghostty + * encodes it, repeated by the pacer. Nothing in X10 mode 9. (webtty, ADR 049) + */ + private sendTicks(e: WheelEvent): void { + const host = this.host; + const cellHeight = host.cellHeight(); + if (cellHeight === undefined || !host.hasCanvas()) return; + const pace = this.pace?.(e) ?? AT_ONCE; + const ticks = wheelTicks(this.tickTravel, e, cellHeight, host.mouseScrollSpeed()); + if (ticks === 0) return; + const tick = host.encodeMouse({ + action: 'press', + button: ticks < 0 ? 'four' : 'five', + mods: mouseMods(e), + clientX: e.clientX, + clientY: e.clientY, + anyButtonPressed: false, + }); + if (tick) this.pacer.push(tick, Math.sign(ticks), Math.abs(ticks), pace); + } + + /** Arrow keys for the travel, ESC O A/B under application cursor keys (DECCKM). */ + private sendArrows(e: WheelEvent): void { + const host = this.host; + const pace = this.pace?.(e) ?? AT_ONCE; + const cellHeight = host.cellHeight() ?? FALLBACK_CELL_HEIGHT; + const lines = wheelArrows(this.arrowTravel, e, cellHeight, host.rows()); + if (lines === 0) return; + const dir = Math.sign(lines); + const key = wheelArrowKey(dir, host.engine()?.getMode(APP_CURSOR_KEYS_MODE, false) ?? false); + this.pacer.push(key, dir, Math.abs(lines), pace); + } +} diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 3fdb6ba..20fe0b0 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -100,7 +100,7 @@ export class InputHandler { store.add(listen(container, 'mousedown', (e) => this.mouse.down(e))); store.add(listen(mouseTarget, 'mouseup', (e) => this.mouse.up(e as MouseEvent))); store.add(listen(mouseTarget, 'mousemove', (e) => this.mouse.move(e as MouseEvent))); - // Wheel input is the terminal's alone (Terminal.handleWheel, paced). A listener here + // Wheel input is the terminal's alone (the WheelController, paced). A listener here // also fired for a wheel over the padding, where the container itself is the target, // and sent that tick a second time, unpaced. (webtty) } diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 28d731f..9277745 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -6,10 +6,9 @@ import { BufferNamespace } from './buffer'; import { FindController } from './core/find-controller'; import { FocusTracker } from './core/focus-tracker'; +import { MouseController } from './core/mouse-controller'; import { RenderLoop } from './core/render-loop'; import { - APP_CURSOR_KEYS_MODE, - MOUSE_MOVE_THROTTLE_MS, SCROLLBAR_FADE_DURATION_MS, SCROLLBAR_HIDE_DELAY_MS, } from './core/terminal-constants'; @@ -22,14 +21,11 @@ import { import { cursorCellOffset, deviceCellPixels, - linkViewportRange, mouseCursorFor, - mouseSurface, - pointerCell, setStylePx, - viewportRowToBufferRow, } from './core/terminal-geometry'; import { Viewport } from './core/viewport'; +import { WheelController } from './core/wheel-controller'; import { WritePipeline } from './core/write-pipeline'; import { EventEmitter } from './event-emitter'; import type { @@ -39,14 +35,8 @@ import type { GhosttyTerminal, SearchState, } from './ghostty'; -import { AT_ONCE, type Pace } from './glide'; -import { - InputHandler, - type MouseEventReport, - type MouseTrackingConfig, - mouseMods, -} from './input-handler'; -import { mouseReportText } from './input/mouse-report'; +import type { Pace } from './glide'; +import { InputHandler, type MouseTrackingConfig } from './input-handler'; import type { IBufferNamespace, IEvent, @@ -55,7 +45,6 @@ import type { ITerminalOptions, } from './interfaces'; import { LinkDetector } from './link-detector'; -import { MenuSource, rightClickAction } from './mouse-route'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; @@ -67,14 +56,6 @@ import { scrollbarTrackHeight, } from './render/scrollbar-geometry'; import { SelectionManager } from './selection-manager'; -import type { ILink } from './types'; -import { - TickPacer, - type WheelAccumulator, - wheelArrowKey, - wheelArrows, - wheelTicks, -} from './wheel'; // The facade's former contents, re-exported so deep imports keep working (ADR 061) export { TERMINAL_DEFAULTS, toEngineConfig } from './core/options-proxy'; @@ -110,9 +91,6 @@ export class Terminal implements ITerminalCore { // Link detection system private linkDetector?: LinkDetector; - private currentHoveredLink?: ILink; - private mouseMoveThrottleTimeout?: number; - private pendingMouseMove?: MouseEvent; // Event emitters private dataEmitter = new EventEmitter<string>(); @@ -220,14 +198,39 @@ export class Terminal implements ITerminalCore { scrolled: (viewportY) => this.scrollEmitter.fire(viewportY), showScrollbar: () => this.showScrollbar(), }); - private customWheelEventHandler?: (event: WheelEvent) => boolean; - private wheelAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; - // Whether a contextmenu came from a button or the keyboard (webtty, ADR 051) - private readonly menuSource = new MenuSource(); - private arrowAccumulator: WheelAccumulator = { accum: 0, dir: 0 }; - private wheelPace?: (event: WheelEvent) => Pace; + + // Link hover and clicks, the mouse report encoder and the right-click route (ADR 049, ADR 051) + private readonly mouse = new MouseController({ + element: () => this.element, + canvas: () => this.canvas, + engine: () => this.wasmTerm, + renderer: () => this.renderer, + links: () => this.linkDetector, + selection: () => this.selectionManager, + rows: () => this.rows, + cols: () => this.cols, + viewportY: () => this.getViewportY(), + cellPixelSize: () => this.cellPixelSize(), + rightClickBehavior: () => this.options.rightClickBehavior, + dragScrollbar: (e) => { + if (!this.isDraggingScrollbar) return false; + this.processScrollbarDrag(e); + return true; + }, + requestPaste: () => this.pasteRequestEmitter.fire(), + }); + + // Wheel events to the embedder, or to the app as ticks or arrows (ADR 045, ADR 049) + private readonly wheel = new WheelController({ + engine: () => this.wasmTerm, + cellHeight: () => this.renderer?.getMetrics()?.height, + hasCanvas: () => !!this.canvas, + rows: () => this.rows, + mouseScrollSpeed: () => this.options.mouseScrollSpeed, + encodeMouse: (event) => this.mouse.encode(event), + send: (data) => this.dataEmitter.fire(data), + }); private beforeRender?: (now: number) => void; - private readonly tickPacer = new TickPacer((data) => this.dataEmitter.fire(data)); // Scrollbar interaction state private isDraggingScrollbar: boolean = false; @@ -446,7 +449,7 @@ export class Terminal implements ITerminalCore { // Create mouse tracking configuration. Reports are encoded by Ghostty (webtty, ADR 049) const mouseConfig: MouseTrackingConfig = { hasMouseTracking: () => this.wasmTerm?.hasMouseTracking() ?? false, - encode: (event) => this.encodeMouse(event), + encode: (event) => this.mouse.encode(event), }; // Create input handler @@ -482,24 +485,16 @@ export class Terminal implements ITerminalCore { // URL regex second (fallback for plain text URLs) this.linkDetector.registerProvider(new UrlRegexProvider(this)); - // Setup mouse event handling for links and scrollbar - // Use capture phase to intercept scrollbar clicks before SelectionManager + // Scrollbar clicks, in the capture phase to intercept them before SelectionManager parent.addEventListener('mousedown', this.handleMouseDown, { capture: true }); - parent.addEventListener('mousemove', this.handleMouseMove); - parent.addEventListener('mouseleave', this.handleMouseLeave); - parent.addEventListener('click', this.handleClick); - - // Every right-click and keyboard menu goes through handleContextMenu (webtty, ADR 051) - parent.addEventListener('mousedown', this.handleMenuPress, { capture: true }); - parent.addEventListener('keydown', this.handleMenuKey, { capture: true }); - parent.addEventListener('contextmenu', this.handleContextMenu, { capture: true }); + // Link hover and clicks, then the right-click route (webtty, ADR 051) + this.mouse.attach(parent); // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) document.addEventListener('mouseup', this.handleMouseUp); - // Setup wheel event handling for scrolling - // Use capture phase to ensure we get the event before browser scrolling - parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); + // Wheel events, in the capture phase before the browser scrolls + this.wheel.attach(parent); // Render initial blank screen (force full redraw) this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); @@ -774,7 +769,7 @@ export class Terminal implements ITerminalCore { public attachCustomWheelEventHandler( customWheelEventHandler?: (event: WheelEvent) => boolean ): void { - this.customWheelEventHandler = customWheelEventHandler; + this.wheel.setCustomHandler(customWheelEventHandler); } /** @@ -783,7 +778,7 @@ export class Terminal implements ITerminalCore { * Called once for each such event. (webtty, ADR 045) */ public attachWheelPace(pace?: (event: WheelEvent) => Pace): void { - this.wheelPace = pace; + this.wheel.setPace(pace); } /** @@ -832,15 +827,6 @@ export class Terminal implements ITerminalCore { // Stop render loop this.renderLoop.cancel(); - this.tickPacer.cancel(); - - // Clear mouse move throttle timeout - if (this.mouseMoveThrottleTimeout) { - clearTimeout(this.mouseMoveThrottleTimeout); - this.mouseMoveThrottleTimeout = undefined; - } - this.pendingMouseMove = undefined; - // Dispose addons for (const addon of this.addons) { addon.dispose(); @@ -966,19 +952,13 @@ export class Terminal implements ITerminalCore { this.textarea = undefined; } - // Remove event listeners + // Remove event listeners; the wheel's queued ticks and the mouse's throttled move go + // with theirs + this.wheel.dispose(); + this.mouse.dispose(); if (this.element) { - this.element.removeEventListener('wheel', this.handleWheel, { capture: true }); this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true }); - this.element.removeEventListener('mousemove', this.handleMouseMove); - this.element.removeEventListener('mouseleave', this.handleMouseLeave); - this.element.removeEventListener('click', this.handleClick); this.focusTracker.dispose(); - this.element.removeEventListener('mousedown', this.handleMenuPress, { capture: true }); - this.element.removeEventListener('keydown', this.handleMenuKey, { capture: true }); - this.element.removeEventListener('contextmenu', this.handleContextMenu, { - capture: true, - }); // Remove contenteditable and accessibility attributes added in open() this.element.removeAttribute('contenteditable'); @@ -1029,264 +1009,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Handle mouse move for link hover detection and scrollbar dragging - * Throttled to avoid blocking scroll events (except when dragging scrollbar) - */ - private handleMouseMove = (e: MouseEvent): void => { - if (!this.canvas || !this.renderer || !this.wasmTerm) return; - - // If dragging scrollbar, handle immediately without throttling - if (this.isDraggingScrollbar) { - this.processScrollbarDrag(e); - return; - } - - if (!this.linkDetector) return; - - // Throttle to ~60fps (16ms) to avoid blocking scroll/other events - if (this.mouseMoveThrottleTimeout) { - this.pendingMouseMove = e; - return; - } - - this.processMouseMove(e); - - this.mouseMoveThrottleTimeout = window.setTimeout(() => { - this.mouseMoveThrottleTimeout = undefined; - if (this.pendingMouseMove) { - const pending = this.pendingMouseMove; - this.pendingMouseMove = undefined; - this.processMouseMove(pending); - } - }, MOUSE_MOVE_THROTTLE_MS); - }; - - /** - * Process mouse move for link detection (internal, called by throttled handler) - */ - private processMouseMove(e: MouseEvent): void { - if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; - - // The cell under the pointer, as an absolute buffer row (scrollback included), which - // the link detector works in - const { x, y } = pointerCell( - e.clientX, - e.clientY, - this.canvas.getBoundingClientRect(), - this.renderer - ); - const bufferRow = viewportRowToBufferRow( - y, - this.wasmTerm.getScrollbackLength(), - this.getViewportY() - ); - - // Make async call non-blocking - don't await - this.linkDetector - .getLinkAt(x, bufferRow) - .then((link) => { - // Update hover state for cursor changes and click handling - if (link !== this.currentHoveredLink) { - // Notify old link we're leaving - this.currentHoveredLink?.hover?.(false); - - // Update current link - this.currentHoveredLink = link; - - // Notify new link we're entering - link?.hover?.(true); - - // Update cursor style on both container and canvas - const cursorStyle = mouseCursorFor(link); - if (this.element) { - this.element.style.cursor = cursorStyle; - } - if (this.canvas) { - this.canvas.style.cursor = cursorStyle; - } - - // Update renderer for underline: the link's own range, OSC 8 or regex, while - // any of it is in view (webtty) - if (this.renderer) { - this.renderer.setHoveredLinkRange( - link - ? linkViewportRange( - link.range, - this.wasmTerm?.getScrollbackLength() || 0, - this.getViewportY(), - this.rows - ) - : null - ); - } - } - }) - .catch((err) => { - console.warn('Link detection error:', err); - }); - } - - /** - * Handle mouse leave to clear link hover - */ - private handleMouseLeave = (): void => { - // Clear the link underline - if (this.renderer && this.wasmTerm) { - this.renderer.setHoveredLinkRange(null); - } - - if (this.currentHoveredLink) { - // Notify link we're leaving - this.currentHoveredLink.hover?.(false); - - // Clear hovered link - this.currentHoveredLink = undefined; - - // Reset cursor - if (this.element) { - this.element.style.cursor = mouseCursorFor(undefined); - if (this.canvas) { - this.canvas.style.cursor = mouseCursorFor(undefined); - } - } - } - }; - - /** - * Handle mouse click for link activation - */ - private handleClick = async (e: MouseEvent): Promise<void> => { - // For more reliable clicking, detect the link at click time - // rather than relying on cached hover state (avoids async races) - if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; - - // The clicked cell as an absolute buffer row, as for a hover - const { x, y } = pointerCell( - e.clientX, - e.clientY, - this.canvas.getBoundingClientRect(), - this.renderer - ); - const bufferRow = viewportRowToBufferRow( - y, - this.wasmTerm.getScrollbackLength(), - this.getViewportY() - ); - - // Get the link at this position - const link = await this.linkDetector.getLinkAt(x, bufferRow); - - if (link) { - // Activate link - link.activate(e); - - // Prevent default action if modifier key held - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - } - } - }; - - /** - * Handle wheel events for scrolling - */ - private handleWheel = (e: WheelEvent): void => { - // Cancel only pinch and ctrl+wheel, which would zoom the page. A cancelled wheel - // event makes Chrome hold the gesture's next event until the page has handled it, - // which put scrolling about a frame behind native; nothing else on the page scrolls, - // and overscroll-behavior stops rubber-banding and swipe-back. (webtty, ADR 045) - if (e.ctrlKey) e.preventDefault(); - e.stopPropagation(); - - // Allow custom handler to override - if (this.customWheelEventHandler && this.customWheelEventHandler(e)) { - return; - } - - // App tracks the mouse (e.g. vim `set mouse=a`): report scroll ticks as mouse - // buttons 4/5 (SGR 64/65) at the cell under the pointer, on either screen. - if (this.wasmTerm?.hasMouseTracking()) { - const metrics = this.renderer?.getMetrics(); - if (!metrics || !this.canvas) return; - const pace = this.wheelPace?.(e) ?? AT_ONCE; - const ticks = wheelTicks( - this.wheelAccumulator, - e, - metrics.height, - this.options.mouseScrollSpeed - ); - if (ticks === 0) return; - // One tick, a press of button 4 (up) or 5 (down) at the pointer's cell as Ghostty - // encodes it, repeated by the pacer. Nothing in X10 mode 9. (webtty, ADR 049) - const tick = this.encodeMouse({ - action: 'press', - button: ticks < 0 ? 'four' : 'five', - mods: mouseMods(e), - clientX: e.clientX, - clientY: e.clientY, - anyButtonPressed: false, - }); - if (tick) this.tickPacer.push(tick, Math.sign(ticks), Math.abs(ticks), pace); - return; - } - - // Check if in alternate screen mode (vim, less, htop, etc.) - const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false; - - if (isAltScreen) { - // Alternate screen: send arrow keys to the application, one per line of - // travel, paced like the SGR ticks above (webtty, ADR 045) - const pace = this.wheelPace?.(e) ?? AT_ONCE; - const lines = wheelArrows( - this.arrowAccumulator, - e, - this.renderer?.getMetrics()?.height ?? 20, - this.rows - ); - if (lines !== 0) { - const dir = Math.sign(lines); - const key = wheelArrowKey( - dir, - this.wasmTerm?.getMode(APP_CURSOR_KEYS_MODE, false) ?? false - ); - this.tickPacer.push(key, dir, Math.abs(lines), pace); - } - } - // The normal screen's scrollback is scrolled by the custom handler (webtty's - // SmoothScroll, ADR 040, ADR 045); upstream's easing here is removed. - }; - - /** - * A mouse report at a client position, encoded by Ghostty's mouse encoder in the - * app's tracking mode and format (`mouseSurface`, `mouseReportText`), or '' when the - * app does not get one. (webtty, ADR 049) - */ - private encodeMouse(event: MouseEventReport): string { - const px = this.cellPixelSize(); - if (!this.wasmTerm || !this.renderer || !this.canvas || !this.element || !px) return ''; - const metrics = this.renderer.getMetrics(); - const rect = this.canvas.getBoundingClientRect(); - const { geometry, x, y } = mouseSurface( - event.clientX, - event.clientY, - { - left: rect.left, - // Viewport row 0 starts the scroll offset below the canvas top (ADR 043) - top: rect.top + this.renderer.scrollOffset, - cols: this.cols, - rows: this.rows, - cellWidth: metrics.width, - cellHeight: metrics.height, - }, - this.element.getBoundingClientRect(), - px - ); - this.wasmTerm.setMouseGeometry(geometry); - const { clientX: _x, clientY: _y, ...input } = event; - return mouseReportText(this.wasmTerm.encodeMouse({ ...input, x, y })); - } - /** * Input from the InputHandler. Keys and text clear the selection and scroll to the * bottom; a mouse report leaves both alone, so a Shift selection outlives the app's @@ -1304,43 +1026,6 @@ export class Terminal implements ITerminalCore { this.dataEmitter.fire(data); }; - private handleMenuPress = (e: MouseEvent): void => { - this.menuSource.press(e); - }; - - private handleMenuKey = (): void => { - this.menuSource.key(); - }; - - /** - * The one place a right-click or a keyboard menu is handled. A right button the app - * takes (it tracks the mouse, Shift is up) is the app's: its press and release were - * reported, and the menu is cancelled. Otherwise `rightClickBehavior` decides: the - * browser menu, copy and clear the selection, paste, or nothing. (webtty: upstream - * always showed the menu, from the selection manager, ADR 051) - */ - private handleContextMenu = (e: MouseEvent): void => { - const behavior = this.options.rightClickBehavior; - const action = rightClickAction({ - tracking: this.wasmTerm?.hasMouseTracking() ?? false, - shiftKey: e.shiftKey, - fromKeyboard: this.menuSource.fromKeyboard(e), - behavior, - hasSelection: behavior === 'copyPaste' && this.getSelection() !== '', - }); - if (action === 'menu') { - this.selectionManager?.prepareContextMenu(e); - return; - } - e.preventDefault(); - if (action === 'copy') { - this.copySelection(); - this.clearSelection(); - } else if (action === 'paste') { - this.pasteRequestEmitter.fire(); - } - }; - /** * Handle mouse down for scrollbar interaction */ diff --git a/src/client/vendor/ghostty-web/wheel.ts b/src/client/vendor/ghostty-web/wheel.ts index c248908..d48b662 100644 --- a/src/client/vendor/ghostty-web/wheel.ts +++ b/src/client/vendor/ghostty-web/wheel.ts @@ -3,7 +3,7 @@ * app tracks the mouse (scroll ticks, each encoded by Ghostty's mouse encoder as a * press of button 4 or 5, ADR 049) or is on the alternate screen without mouse * tracking (arrow keys), and the pacing that spreads it over time. The - * terminal's `handleWheel` routes events here. (webtty, ADR 017, ADR 040, ADR 045) + * terminal's `WheelController` routes events here. (webtty, ADR 017, ADR 040, ADR 045) */ import { Glide, linear, type Pace } from './glide'; diff --git a/src/client/vendor/tests/core/mouse-controller.test.ts b/src/client/vendor/tests/core/mouse-controller.test.ts new file mode 100644 index 0000000..6c343ac --- /dev/null +++ b/src/client/vendor/tests/core/mouse-controller.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { RecordingTarget } from '../../../test-support/recording-target'; +import { BufferNamespace } from '../../ghostty-web/buffer'; +import type { + LinkSource, + MenuSelection, + MouseEngine, + MouseHost, + MouseRenderer, +} from '../../ghostty-web/core/host-types'; +import { browserTimeouts, MouseController } from '../../ghostty-web/core/mouse-controller'; +import { LinkDetector } from '../../ghostty-web/link-detector'; +import type { ILink } from '../../ghostty-web/link-types'; +import type { RightClickBehavior } from '../../ghostty-web/mouse-route'; +import { OSC8LinkProvider } from '../../ghostty-web/providers/osc8-link-provider'; +import { loadGhostty } from '../support/wasm'; + +// The terminal's mouse: the link hover, throttled to one lookup per 16 ms, link clicks, +// the mouse report encoder (ADR 049) and the right-click route (ADR 051), on a recording +// container, a hand-run clock and, where the engine matters, a real one. + +/** A link over columns 2-5 of buffer row 1; `log` records its hover and activation. */ +function fakeLink(log: string[], name = 'link'): ILink { + return { + text: name, + range: { start: { x: 2, y: 1 }, end: { x: 5, y: 1 } }, + activate: () => log.push(`activate ${name}`), + hover: (on) => log.push(`hover ${name} ${on}`), + }; +} + +interface Options { + engine?: MouseEngine; + links?: LinkSource; + selection?: MenuSelection; + rightClickBehavior?: RightClickBehavior; + dragging?: boolean; + opened?: boolean; +} + +/** + * A controller on an 8 x 16 px cell grid of 30 x 4 at the client origin, attached to a + * recording container; `log` collects what it does to the host. + */ +function mouse(options: Options = {}) { + const log: string[] = []; + const clock = new ManualClock(); + const container = new RecordingTarget('container'); + const element = { + style: { cursor: '' }, + getBoundingClientRect: () => ({ left: 0, top: 0, right: 240, bottom: 64 }), + }; + const canvas = { style: { cursor: '' }, getBoundingClientRect: () => ({ left: 0, top: 0 }) }; + const renderer: MouseRenderer = { + charWidth: 8, + charHeight: 16, + scrollOffset: 0, + getMetrics: () => ({ width: 8, height: 16 }), + setHoveredLinkRange: (range) => log.push(`underline ${JSON.stringify(range)}`), + }; + const engine = + options.engine ?? + ({ getScrollbackLength: () => 0, hasMouseTracking: () => false } as MouseEngine); + const opened = options.opened ?? true; + const host: MouseHost = { + element: () => (opened ? (element as unknown as HTMLElement) : undefined), + canvas: () => (opened ? (canvas as unknown as HTMLCanvasElement) : undefined), + engine: () => (opened ? engine : undefined), + renderer: () => (opened ? renderer : undefined), + links: () => options.links, + selection: () => options.selection, + rows: () => 4, + cols: () => 30, + viewportY: () => 0, + cellPixelSize: () => (opened ? { width: 8, height: 16 } : null), + rightClickBehavior: () => options.rightClickBehavior ?? 'contextMenu', + dragScrollbar: () => { + if (!options.dragging) return false; + log.push('drag'); + return true; + }, + requestPaste: () => log.push('paste'), + }; + const controller = new MouseController(host, clock); + controller.attach(container as unknown as HTMLElement); + const move = (clientX: number, clientY: number) => + container.dispatch({ type: 'mousemove', clientX, clientY } as unknown as Event); + return { controller, container, clock, log, element, canvas, move }; +} + +/** A LinkSource whose every lookup is logged and finds `link`. */ +function lookups(log: string[], link?: ILink): LinkSource { + return { + getLinkAt: async (col, row) => { + log.push(`lookup ${col},${row}`); + return link; + }, + }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('MouseController listeners', () => { + test('attach adds the six listeners in open() order; dispose removes them, once', () => { + const { controller, container } = mouse(); + const rows = container.added.map((r) => `${r.type}${r.capture ? ' capture' : ''}`); + expect(rows).toEqual([ + 'mousemove', + 'mouseleave', + 'click', + 'mousedown capture', + 'keydown capture', + 'contextmenu capture', + ]); + controller.dispose(); + expect(container.active).toEqual([]); + expect(container.removed).toHaveLength(6); + controller.dispose(); + expect(container.removed).toHaveLength(6); + }); + + test('dispose drops a throttled move', async () => { + const log: string[] = []; + const t = mouse({ links: lookups(log) }); + t.move(1, 1); + t.move(9, 1); + expect(t.clock.pending.timers).toBe(1); + t.controller.dispose(); + expect(t.clock.pending.timers).toBe(0); + t.clock.advance(100); + expect(log).toEqual(['lookup 0,0']); + }); +}); + +describe('MouseController link hover', () => { + test('looks a link up at once, then at most once per 16 ms, for the last move', () => { + const log: string[] = []; + const t = mouse({ links: lookups(log) }); + t.move(1, 1); + t.move(9, 1); + t.move(17, 1); + expect(log).toEqual(['lookup 0,0']); + t.clock.advance(15); + expect(log).toEqual(['lookup 0,0']); + t.clock.advance(1); + expect(log).toEqual(['lookup 0,0', 'lookup 2,0']); + t.clock.advance(16); + t.move(25, 17); + expect(log).toEqual(['lookup 0,0', 'lookup 2,0', 'lookup 3,1']); + }); + + test('a scrollbar drag takes the move; nothing is looked up before open()', () => { + const log: string[] = []; + const dragging = mouse({ links: lookups(log), dragging: true }); + dragging.move(1, 1); + expect(dragging.log).toEqual(['drag']); + const closed = mouse({ links: lookups(log), opened: false }); + closed.move(1, 1); + expect(log).toEqual([]); + }); + + test('a new link gets the hover, the pointer and its underline; leaving clears them', async () => { + const log: string[] = []; + const t = mouse({ links: lookups(log, fakeLink(log)) }); + t.move(17, 17); + await settle(); + expect(t.element.style.cursor).toBe('pointer'); + expect(t.canvas.style.cursor).toBe('pointer'); + expect(log).toEqual(['lookup 2,1', 'hover link true']); + expect(t.log).toEqual(['underline {"startX":2,"startY":1,"endX":5,"endY":1}']); + // The same link again changes nothing + t.clock.advance(16); + t.move(25, 17); + await settle(); + expect(t.log).toHaveLength(1); + t.container.dispatch({ type: 'mouseleave' }); + expect(log.at(-1)).toBe('hover link false'); + expect(t.log.at(-1)).toBe('underline null'); + expect(t.element.style.cursor).toBe('default'); + expect(t.canvas.style.cursor).toBe('default'); + }); + + test('a failed lookup is logged, not thrown', async () => { + const warn = console.warn; + const warned: unknown[] = []; + console.warn = (...args: unknown[]) => warned.push(args[0]); + try { + const t = mouse({ links: { getLinkAt: () => Promise.reject(new Error('no')) } }); + t.move(1, 1); + await settle(); + expect(warned).toEqual(['Link detection error:']); + } finally { + console.warn = warn; + } + }); + + // The engine's hyperlink_id is a 1-bit "has a link" flag, so every OSC 8 cell carries + // the same value: the hover is the hovered link's range, never an id. + test('hovering an OSC 8 link gives the renderer that link range only', async () => { + const ghostty = await loadGhostty(); + const wasmTerm = ghostty.createTerminal(30, 4); + wasmTerm.write('\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\n'); + wasmTerm.write('xx \x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); + const term = { buffer: new BufferNamespace({ wasmTerm }), wasmTerm }; + const links = new LinkDetector(term); + links.registerProvider(new OSC8LinkProvider(term)); + const t = mouse({ links, engine: wasmTerm }); + // Over "second", on row 1 + t.move(5 * 8 + 1, 16 + 1); + await settle(); + expect(t.log).toEqual(['underline {"startX":3,"startY":1,"endX":8,"endY":1}']); + wasmTerm.free(); + }); +}); + +describe('MouseController link clicks', () => { + /** Clicks at a cell with `mods`; whether the click was cancelled. */ + async function click(t: ReturnType<typeof mouse>, mods: object = {}) { + let cancelled = false; + t.container.dispatch({ + type: 'click', + clientX: 17, + clientY: 17, + ctrlKey: false, + metaKey: false, + ...mods, + preventDefault: () => { + cancelled = true; + }, + } as unknown as Event); + await settle(); + return cancelled; + } + + test('activates the link under the click, cancelling it with Ctrl or Cmd', async () => { + const log: string[] = []; + const t = mouse({ links: lookups(log, fakeLink(log)) }); + expect(await click(t)).toBe(false); + expect(await click(t, { ctrlKey: true })).toBe(true); + expect(await click(t, { metaKey: true })).toBe(true); + expect(log.filter((l) => l.startsWith('activate'))).toHaveLength(3); + }); + + test('a click on no link does nothing', async () => { + const log: string[] = []; + expect(await click(mouse({ links: lookups(log) }), { ctrlKey: true })).toBe(false); + expect(await click(mouse(), { ctrlKey: true })).toBe(false); + }); +}); + +describe('MouseController encode', () => { + test('encodes at the pointer cell in the app mode, and is empty before open()', async () => { + const ghostty = await loadGhostty(); + const wasmTerm = ghostty.createTerminal(30, 4); + wasmTerm.write('\x1b[?1000h\x1b[?1006h'); + const report = { + action: 'press', + button: 'left', + mods: 0, + clientX: 8 * 6 + 1, + clientY: 16 * 2 + 1, + anyButtonPressed: false, + } as const; + expect(mouse({ engine: wasmTerm }).controller.encode(report)).toBe('\x1b[<0;7;3M'); + expect(mouse({ engine: wasmTerm, opened: false }).controller.encode(report)).toBe(''); + wasmTerm.free(); + }); +}); + +describe('MouseController right-click', () => { + // Every contextmenu goes through one handler: the app's right button cancels the menu, + // and otherwise rightClickBehavior decides (ADR 051). + async function setup(rightClickBehavior: RightClickBehavior) { + const ghostty = await loadGhostty(); + const wasmTerm = ghostty.createTerminal(20, 2); + wasmTerm.write('hello'); + const calls: string[] = []; + let selection = ''; + const t = mouse({ + engine: wasmTerm, + rightClickBehavior, + selection: { + getSelection: () => selection, + copySelection: () => { + calls.push(`copy ${selection}`); + return true; + }, + clearSelection: () => { + calls.push('clear'); + selection = ''; + }, + prepareContextMenu: () => calls.push('menu'), + }, + }); + t.log.length = 0; + /** A right-click (press, then menu), or a keyboard menu; whether the menu was cancelled. */ + const menu = (opts: { shiftKey?: boolean; keyboard?: boolean } = {}) => { + let cancelled = false; + if (opts.keyboard) t.container.dispatch({ type: 'keydown' }); + else t.container.dispatch({ type: 'mousedown', button: 2, ctrlKey: false } as never); + t.container.dispatch({ + type: 'contextmenu', + button: opts.keyboard ? 0 : 2, + shiftKey: opts.shiftKey ?? false, + clientX: 1, + clientY: 1, + preventDefault: () => { + cancelled = true; + }, + } as unknown as Event); + return cancelled; + }; + return { + menu, + calls, + pastes: () => t.log.filter((l) => l === 'paste').length, + select: (text: string) => { + selection = text; + }, + track: (on: boolean) => wasmTerm.write(on ? '\x1b[?1000h' : '\x1b[?1000l'), + free: () => wasmTerm.free(), + }; + } + + test('contextMenu shows the menu; while the app tracks the mouse only Shift or the keyboard do', async () => { + const t = await setup('contextMenu'); + expect(t.menu()).toBe(false); + t.track(true); + expect(t.menu()).toBe(true); + expect(t.menu({ shiftKey: true })).toBe(false); + expect(t.menu({ keyboard: true })).toBe(false); + expect(t.calls).toEqual(['menu', 'menu', 'menu']); + t.free(); + }); + + test('copyPaste copies and clears a selection, or shows the menu without one', async () => { + const t = await setup('copyPaste'); + expect(t.menu()).toBe(false); + t.select('hello'); + expect(t.menu()).toBe(true); + expect(t.calls).toEqual(['menu', 'copy hello', 'clear']); + t.track(true); + t.select('hello'); + expect(t.menu()).toBe(true); + expect(t.calls.length).toBe(3); + t.free(); + }); + + test('paste asks for a paste with no menu; none does nothing', async () => { + const p = await setup('paste'); + expect(p.menu()).toBe(true); + p.track(true); + expect(p.menu()).toBe(true); + expect(p.menu({ shiftKey: true })).toBe(true); + expect(p.pastes()).toBe(2); + p.free(); + const n = await setup('none'); + expect(n.menu()).toBe(true); + expect(n.menu({ keyboard: true })).toBe(true); + expect(n.calls).toEqual([]); + expect(n.pastes()).toBe(0); + n.free(); + }); + + test('with no selection manager the menu still routes', () => { + const t = mouse({ rightClickBehavior: 'copyPaste' }); + let cancelled = false; + t.container.dispatch({ + type: 'contextmenu', + button: 2, + shiftKey: false, + preventDefault: () => { + cancelled = true; + }, + } as unknown as Event); + expect(cancelled).toBe(false); + }); +}); + +describe('browserTimeouts', () => { + test("reads the page's window.setTimeout and the global clearTimeout when called", () => { + const g = globalThis as Record<string, unknown>; + const saved = g.window; + const scheduled: number[] = []; + g.window = { setTimeout: (_run: () => void, ms: number) => scheduled.push(ms) }; + try { + expect(browserTimeouts.setTimeout(() => {}, 16)).toBe(1); + expect(scheduled).toEqual([16]); + browserTimeouts.clearTimeout(undefined); + } finally { + g.window = saved; + } + }); +}); diff --git a/src/client/vendor/tests/core/wheel-controller.test.ts b/src/client/vendor/tests/core/wheel-controller.test.ts new file mode 100644 index 0000000..1deb2a5 --- /dev/null +++ b/src/client/vendor/tests/core/wheel-controller.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test'; +import { RecordingTarget } from '../../../test-support/recording-target'; +import type { WheelEngine, WheelHost } from '../../ghostty-web/core/host-types'; +import { MouseController } from '../../ghostty-web/core/mouse-controller'; +import { WheelController } from '../../ghostty-web/core/wheel-controller'; +import type { GhosttyTerminal } from '../../ghostty-web/ghostty'; +import { AT_ONCE, type Pace } from '../../ghostty-web/glide'; +import { loadGhostty } from '../support/wasm'; + +// Wheel routing: the zoom cancel, the embedder's handler, scroll ticks to an app that +// tracks the mouse, encoded by Ghostty (ADR 049), and arrow keys on the alternate screen +// (ADR 045), with a recording pacer and container. + +interface Options { + engine?: WheelEngine; + cellHeight?: number; + hasCanvas?: boolean; + /** What the mouse encoder returns, `<button>` by default. */ + encoded?: string; +} + +/** A controller on a recording container; `pushed` logs the pacer's queue. */ +function wheelOn(options: Options = {}) { + const pushed: [string, number, number, Pace][] = []; + const log: string[] = []; + const container = new RecordingTarget('container'); + const host: WheelHost = { + engine: () => options.engine, + cellHeight: () => ('cellHeight' in options ? options.cellHeight : 20), + hasCanvas: () => options.hasCanvas ?? true, + rows: () => 10, + mouseScrollSpeed: () => 1, + encodeMouse: (event) => options.encoded ?? `<${event.button}>`, + send: (data) => log.push(data), + }; + const pacer = { + push: (seq: string, dir: number, count: number, pace: Pace) => + pushed.push([seq, dir, count, pace]), + cancel: () => log.push('cancel'), + }; + const controller = new WheelController(host, pacer); + controller.attach(container as unknown as HTMLElement); + return { controller, container, pushed, log }; +} + +/** A line-mode wheel event of `deltaY` lines; `cancelled` and `stopped` report what it did. */ +function wheelEvent(deltaY: number, mods: object = {}) { + const result = { cancelled: false, stopped: false }; + const event = { + type: 'wheel', + deltaY, + deltaMode: 1, + clientX: 1, + clientY: 1, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: () => { + result.cancelled = true; + }, + stopPropagation: () => { + result.stopped = true; + }, + ...mods, + } as unknown as WheelEvent; + return { event, result }; +} + +/** An engine stand-in in one state. */ +function engineIn(state: { tracking?: boolean; alternate?: boolean; appCursor?: boolean }) { + return { + hasMouseTracking: () => state.tracking ?? false, + isAlternateScreen: () => state.alternate ?? false, + getMode: (mode: number) => mode === 1 && (state.appCursor ?? false), + } as WheelEngine; +} + +describe('WheelController listener', () => { + test('attach adds one capture, non-passive wheel listener; dispose removes it once', () => { + const { controller, container, log } = wheelOn(); + expect(container.added.map((r) => [r.type, r.capture, r.passive])).toEqual([ + ['wheel', true, false], + ]); + controller.dispose(); + expect(container.active).toEqual([]); + expect(log).toEqual(['cancel']); + controller.dispose(); + expect(container.removed).toHaveLength(1); + }); + + test('the default pacer sends through the host', () => { + const sent: string[] = []; + const controller = new WheelController({ + engine: () => engineIn({ alternate: true }), + cellHeight: () => 20, + hasCanvas: () => true, + rows: () => 10, + mouseScrollSpeed: () => 1, + encodeMouse: () => '', + send: (data) => sent.push(data), + }); + controller.handle(wheelEvent(2).event); + expect(sent).toEqual(['\x1b[B\x1b[B']); + controller.dispose(); + }); +}); + +describe('WheelController routing', () => { + // A cancelled wheel event makes Chrome hold the gesture's next one until the page has + // handled it: only zooming (pinch, ctrl+wheel) is cancelled (ADR 045). + test('a scroll is not cancelled; pinch and ctrl+wheel are; neither propagates', () => { + const { container } = wheelOn(); + const scroll = wheelEvent(3); + container.dispatch(scroll.event); + expect(scroll.result).toEqual({ cancelled: false, stopped: true }); + const zoom = wheelEvent(3, { ctrlKey: true }); + container.dispatch(zoom.event); + expect(zoom.result).toEqual({ cancelled: true, stopped: true }); + }); + + test("the embedder's handler takes an event by returning true", () => { + const t = wheelOn({ engine: engineIn({ alternate: true }) }); + const seen: number[] = []; + t.controller.setCustomHandler((e) => { + seen.push(e.deltaY); + return e.deltaY > 0; + }); + t.controller.handle(wheelEvent(2).event); + t.controller.handle(wheelEvent(-1).event); + expect(seen).toEqual([2, -1]); + expect(t.pushed.map(([seq, dir, count]) => [seq, dir, count])).toEqual([['\x1b[A', -1, 1]]); + }); + + test('ticks go to an app that tracks the mouse, paced as the embedder says', () => { + const t = wheelOn({ engine: engineIn({ tracking: true, alternate: true }) }); + const pace: Pace = { duration: 100 }; + t.controller.setPace(() => pace); + // Pixel deltas count one tick per 20 px cell + t.controller.handle(wheelEvent(-40, { deltaMode: 0 }).event); + t.controller.handle(wheelEvent(60, { deltaMode: 0 }).event); + expect(t.pushed).toEqual([ + ['<four>', -1, 2, pace], + ['<five>', 1, 3, pace], + ]); + }); + + test('no ticks before open(), nor while the encoder sends nothing', () => { + const tracking = engineIn({ tracking: true }); + for (const options of [{ cellHeight: undefined }, { hasCanvas: false }]) { + const t = wheelOn({ engine: tracking, ...options }); + t.controller.handle(wheelEvent(3).event); + expect(t.pushed).toEqual([]); + } + const silent = wheelOn({ engine: tracking, encoded: '' }); + silent.controller.handle(wheelEvent(3).event); + expect(silent.pushed).toEqual([]); + }); + + test('the alternate screen gets arrows, ESC O under application cursor keys', () => { + const plain = wheelOn({ engine: engineIn({ alternate: true }) }); + plain.controller.handle(wheelEvent(-3).event); + expect(plain.pushed).toEqual([['\x1b[A', -1, 3, AT_ONCE]]); + const app = wheelOn({ engine: engineIn({ alternate: true, appCursor: true }) }); + app.controller.handle(wheelEvent(2).event); + expect(app.pushed).toEqual([['\x1bOB', 1, 2, AT_ONCE]]); + }); + + test('arrows count 20 px cells before open(), and a part line sends nothing yet', () => { + const t = wheelOn({ engine: engineIn({ alternate: true }), cellHeight: undefined }); + const pixels = (deltaY: number) => wheelEvent(deltaY, { deltaMode: 0 }).event; + t.controller.handle(pixels(30)); + t.controller.handle(pixels(10)); + expect(t.pushed.map(([seq, dir, count]) => [seq, dir, count])).toEqual([ + ['\x1b[B', 1, 1], + ['\x1b[B', 1, 1], + ]); + }); + + test('the normal screen scrollback is left to the embedder', () => { + for (const engine of [engineIn({}), undefined]) { + const t = wheelOn({ engine }); + t.controller.handle(wheelEvent(3).event); + expect(t.pushed).toEqual([]); + } + }); +}); + +describe('WheelController in mouse-tracking apps', () => { + // A 10 px x 20 px CSS cell at DPR 1, the grid at (5, 5) in a container at (0, 0); the + // ticks are encoded by a MouseController on a real engine. + async function wheelTerm(modes: string) { + const ghostty = await loadGhostty(); + const wasmTerm: GhosttyTerminal = ghostty.createTerminal(40, 10); + wasmTerm.write(modes); + const mouse = new MouseController({ + element: () => + ({ + getBoundingClientRect: () => ({ left: 0, top: 0, right: 410, bottom: 210 }), + }) as HTMLElement, + canvas: () => ({ getBoundingClientRect: () => ({ left: 5, top: 5 }) }) as HTMLCanvasElement, + engine: () => wasmTerm, + renderer: () => ({ + charWidth: 10, + charHeight: 20, + scrollOffset: 0, + getMetrics: () => ({ width: 10, height: 20 }), + setHoveredLinkRange: () => {}, + }), + links: () => undefined, + selection: () => undefined, + rows: () => 10, + cols: () => 40, + viewportY: () => 0, + cellPixelSize: () => ({ width: 10, height: 20 }), + rightClickBehavior: () => 'contextMenu', + dragScrollbar: () => false, + requestPaste: () => {}, + }); + const sent: string[] = []; + const wheel = new WheelController({ + engine: () => wasmTerm, + cellHeight: () => 20, + hasCanvas: () => true, + rows: () => 10, + mouseScrollSpeed: () => 1, + encodeMouse: (event) => mouse.encode(event), + send: (data) => sent.push(data), + }); + const send = (deltaY: number, clientX: number, clientY: number, mods: object = {}) => + wheel.handle(wheelEvent(deltaY, { clientX, clientY, ...mods }).event); + return { sent, send, free: () => wasmTerm.free() }; + } + + test('sends buttons 4/5 at the pointer cell in SGR, with modifiers', async () => { + const { sent, send, free } = await wheelTerm('\x1b[?1000h\x1b[?1006h'); + send(-3, 5 + 10 * 6 + 1, 5 + 20 * 3 + 1); + send(3, 5 + 10 * 6 + 1, 5 + 20 * 3 + 1, { altKey: true }); + expect(sent).toEqual(['\x1b[<64;7;4M', '\x1b[<73;7;4M']); + free(); + }); + + test('follows the format: X10 bytes without 1006, nothing in X10 mode 9', async () => { + const x10 = await wheelTerm('\x1b[?1000h'); + x10.send(3, 6, 6); + expect(x10.sent).toEqual(['\x1b[Ma!!']); + x10.free(); + const mode9 = await wheelTerm('\x1b[?9h'); + mode9.send(3, 6, 6); + expect(mode9.sent).toEqual([]); + mode9.free(); + }); +}); diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index cee4f4d..6229de4 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -1,73 +1,9 @@ import { describe, expect, test } from 'bun:test'; import type { TerminalEffects } from '../ghostty-web/ghostty'; import { InputHandler } from '../ghostty-web/input-handler'; -import { LinkDetector } from '../ghostty-web/link-detector'; -import { OSC8LinkProvider } from '../ghostty-web/providers/osc8-link-provider'; import { Terminal } from '../ghostty-web/terminal'; import { loadGhostty } from './support/wasm'; -describe('Terminal wheel in mouse-tracking apps', () => { - // A 10 px x 20 px CSS cell at DPR 1, the grid at (5, 5) in a container at (0, 0). - async function wheelTerm(modes: string) { - const ghostty = await loadGhostty(); - const wasmTerm = ghostty.createTerminal(40, 10); - wasmTerm.write(modes); - const term = new Terminal({ ghostty: {} as never, cols: 40, rows: 10 }); - const internals = term as unknown as { - wasmTerm: unknown; - renderer: unknown; - canvas: unknown; - element: unknown; - handleWheel(e: object): void; - }; - internals.wasmTerm = wasmTerm; - internals.renderer = { - getMetrics: () => ({ width: 10, height: 20, baseline: 15 }), - scrollOffset: 0, - }; - internals.canvas = { getBoundingClientRect: () => ({ left: 5, top: 5 }) }; - internals.element = { - getBoundingClientRect: () => ({ left: 0, top: 0, right: 410, bottom: 210 }), - }; - const sent: string[] = []; - term.onData((data) => sent.push(data)); - const wheel = (deltaY: number, clientX: number, clientY: number, mods: object = {}) => - internals.handleWheel({ - deltaY, - deltaMode: 1, - clientX, - clientY, - shiftKey: false, - altKey: false, - ctrlKey: false, - metaKey: false, - preventDefault: () => {}, - stopPropagation: () => {}, - ...mods, - }); - return { sent, wheel, free: () => wasmTerm.free() }; - } - - test('sends buttons 4/5 at the pointer cell in SGR, with modifiers', async () => { - const { sent, wheel, free } = await wheelTerm('\x1b[?1000h\x1b[?1006h'); - wheel(-3, 5 + 10 * 6 + 1, 5 + 20 * 3 + 1); - wheel(3, 5 + 10 * 6 + 1, 5 + 20 * 3 + 1, { altKey: true }); - expect(sent).toEqual(['\x1b[<64;7;4M', '\x1b[<73;7;4M']); - free(); - }); - - test('follows the format: X10 bytes without 1006, nothing in X10 mode 9', async () => { - const x10 = await wheelTerm('\x1b[?1000h'); - x10.wheel(3, 6, 6); - expect(x10.sent).toEqual(['\x1b[Ma!!']); - x10.free(); - const mode9 = await wheelTerm('\x1b[?9h'); - mode9.wheel(3, 6, 6); - expect(mode9.sent).toEqual([]); - mode9.free(); - }); -}); - describe('Terminal options', () => { // The configured cursor shape is the engine's default, which DECSCUSR Ps 0 and RIS // restore; the output is not scanned (ADR 049). @@ -110,6 +46,10 @@ describe('Terminal options', () => { 24, ]); }); + + test('a right-click defaults to contextMenu', () => { + expect(new Terminal({ ghostty: {} as never }).options.rightClickBehavior).toBe('contextMenu'); + }); }); describe('Terminal paste', () => { @@ -623,177 +563,6 @@ describe('Terminal input and the selection', () => { }); }); -describe('Terminal link hover', () => { - // The engine's hyperlink_id is a 1-bit "has a link" flag, so every OSC 8 cell carries - // the same value: the hover is the hovered link's range, never an id. - test('hovering an OSC 8 link gives the renderer that link range only', async () => { - const ghostty = await loadGhostty(); - const wasmTerm = ghostty.createTerminal(30, 4); - wasmTerm.write('\x1b]8;;https://a/\x1b\\first\x1b]8;;\x1b\\\r\n'); - wasmTerm.write('xx \x1b]8;;https://b/\x1b\\second\x1b]8;;\x1b\\'); - const term = new Terminal({ ghostty: {} as never, cols: 30, rows: 4 }); - const calls: [string, unknown][] = []; - const renderer = new Proxy( - { charWidth: 8, charHeight: 16, scrollOffset: 0 }, - { - get: (t, k) => - k in t ? t[k as keyof typeof t] : (arg: unknown) => calls.push([String(k), arg]), - }, - ); - const internals = term as unknown as { - wasmTerm: unknown; - renderer: unknown; - canvas: unknown; - linkDetector: LinkDetector; - processMouseMove(e: { clientX: number; clientY: number }): void; - }; - internals.wasmTerm = wasmTerm; - internals.renderer = renderer; - internals.canvas = { getBoundingClientRect: () => ({ left: 0, top: 0 }), style: {} }; - internals.linkDetector = new LinkDetector(term); - internals.linkDetector.registerProvider(new OSC8LinkProvider(term)); - - // Over "second", on row 1 - internals.processMouseMove({ clientX: 5 * 8 + 1, clientY: 16 + 1 }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls).toEqual([['setHoveredLinkRange', { startX: 3, startY: 1, endX: 8, endY: 1 }]]); - wasmTerm.free(); - }); -}); - -describe('Terminal wheel events', () => { - // A cancelled wheel event makes Chrome hold the gesture's next one until the page has - // handled it: only zooming (pinch, ctrl+wheel) is cancelled (ADR 045). - const wheel = (ctrlKey: boolean) => { - let cancelled = false; - const e = { - ctrlKey, - deltaX: 0, - deltaY: 10, - deltaMode: 0, - preventDefault: () => { - cancelled = true; - }, - stopPropagation: () => {}, - }; - const term = new Terminal({ ghostty: {} as never }) as unknown as { - handleWheel: (e: unknown) => void; - }; - term.handleWheel(e); - return cancelled; - }; - - test('a scroll is not cancelled', () => { - expect(wheel(false)).toBe(false); - }); - - test('pinch and ctrl+wheel are cancelled, so the page does not zoom', () => { - expect(wheel(true)).toBe(true); - }); -}); - -describe('Terminal right-click', () => { - // Every contextmenu goes through one handler: the app's right button cancels the menu, - // and otherwise rightClickBehavior decides (ADR 051). - async function setup(rightClickBehavior: 'contextMenu' | 'copyPaste' | 'paste' | 'none') { - const ghostty = await loadGhostty(); - const wasmTerm = ghostty.createTerminal(20, 2); - wasmTerm.write('hello'); - const term = new Terminal({ ghostty: {} as never, rightClickBehavior }); - const calls: string[] = []; - let selection = ''; - const internals = term as unknown as { - wasmTerm: unknown; - selectionManager: unknown; - handleMenuPress(e: object): void; - handleMenuKey(): void; - handleContextMenu(e: object): void; - }; - internals.wasmTerm = wasmTerm; - internals.selectionManager = { - getSelection: () => selection, - copySelection: () => { - calls.push(`copy ${selection}`); - return true; - }, - clearSelection: () => { - calls.push('clear'); - selection = ''; - }, - prepareContextMenu: () => calls.push('menu'), - }; - term.onPasteRequest(() => calls.push('paste')); - /** A right-click (press, then menu), or a keyboard menu; whether the menu was cancelled. */ - const menu = (opts: { shiftKey?: boolean; keyboard?: boolean } = {}) => { - let cancelled = false; - if (opts.keyboard) internals.handleMenuKey(); - else internals.handleMenuPress({ button: 2, ctrlKey: false }); - internals.handleContextMenu({ - button: opts.keyboard ? 0 : 2, - shiftKey: opts.shiftKey ?? false, - clientX: 1, - clientY: 1, - preventDefault: () => { - cancelled = true; - }, - }); - return cancelled; - }; - return { - menu, - calls, - select: (text: string) => { - selection = text; - }, - track: (on: boolean) => wasmTerm.write(on ? '\x1b[?1000h' : '\x1b[?1000l'), - free: () => wasmTerm.free(), - }; - } - - test('contextMenu shows the menu; while the app tracks the mouse only Shift or the keyboard do', async () => { - const t = await setup('contextMenu'); - expect(t.menu()).toBe(false); - t.track(true); - expect(t.menu()).toBe(true); - expect(t.menu({ shiftKey: true })).toBe(false); - expect(t.menu({ keyboard: true })).toBe(false); - expect(t.calls).toEqual(['menu', 'menu', 'menu']); - t.free(); - }); - - test('copyPaste copies and clears a selection, or shows the menu without one', async () => { - const t = await setup('copyPaste'); - expect(t.menu()).toBe(false); - t.select('hello'); - expect(t.menu()).toBe(true); - expect(t.calls).toEqual(['menu', 'copy hello', 'clear']); - t.track(true); - t.select('hello'); - expect(t.menu()).toBe(true); - expect(t.calls.length).toBe(3); - t.free(); - }); - - test('paste asks for a paste with no menu; none does nothing', async () => { - const p = await setup('paste'); - expect(p.menu()).toBe(true); - p.track(true); - expect(p.menu()).toBe(true); - expect(p.menu({ shiftKey: true })).toBe(true); - expect(p.calls).toEqual(['paste', 'paste']); - p.free(); - const n = await setup('none'); - expect(n.menu()).toBe(true); - expect(n.menu({ keyboard: true })).toBe(true); - expect(n.calls).toEqual([]); - n.free(); - }); - - test('defaults to contextMenu', () => { - expect(new Terminal({ ghostty: {} as never }).options.rightClickBehavior).toBe('contextMenu'); - }); -}); - describe('Terminal jumpToPrompt', () => { // Ghostty's jump_to_prompt on libghostty-vt's DELTA_PROMPT, in webtty's view (ADR 057). async function promptTerm() { From 1c9110f455c763bd3317e718035f937651ed9c87 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:30:37 -0400 Subject: [PATCH 252/295] refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade Terminal's last collaborators move into core/ (ADR 061); terminal.ts goes from 1248 to 596 lines, under its facade limit of 600, and leaves the ratchet allowlist: - scrollbar-controller.ts: ScrollbarController keeps the press (track jump, thumb drag start), the drag, the release and the fade in and out, on an injected clock (Date.now and the browser's frames and timeouts by default); the hit test is render/scrollbar-geometry.ts, shared with the painter. - dom-setup.ts: the container's attributes and beforeinput guard, the canvas and textarea, their press-to-focus, the canvas element sizing and the textarea anchor. - opened-parts.ts: OpenedParts, everything open() creates, held together, so methods take the parts from one check instead of asserting each field; disposeParts takes down a whole set or the part a failed open() got to. - terminal-controllers.ts: the focus tracker, finder, write pipeline, render loop, viewport, scrollbar, mouse and wheel made and wired in one place, with one optional clock for tests, their listeners' attach and detach, and the input route. - terminal-emitters.ts, option-changes.ts: the nine emitters, and what an option set after open() changes (applyOptionChange over a host). - options-proxy.ts gains rendererOptions, terminal-geometry.ts cellDevicePixels, and Viewport the prompt jump. element, textarea, wasmTerm and renderer become read-only accessors over the parts. Listener targets, types, flags and order are unchanged: the listener snapshot passes as it was. The leaking listeners stay as they are, marked "leaks until ADR 065". Two equivalences are noted in PROVENANCE.md: open() no longer drains replies after setting the cell pixels (a new engine has mode 2048 off, so there are none), and the controllers are disposed before the parts. terminal.test.ts now opens a real Terminal on the fakes through vendor/tests/support/terminal-harness.ts and uses the public API only (its private casts are gone); new tests cover the scrollbar (ManualClock, happy-dom), the DOM set-up (happy-dom), the parts teardown, the controllers' attach order and input route, the emitters and the option changes. --- scripts/architecture/ratchet-allowlist.ts | 4 - src/client/vendor/ghostty-web/PROVENANCE.md | 15 +- .../vendor/ghostty-web/core/dom-setup.ts | 126 ++ .../vendor/ghostty-web/core/host-types.ts | 27 + .../vendor/ghostty-web/core/opened-parts.ts | 53 + .../vendor/ghostty-web/core/option-changes.ts | 63 + .../vendor/ghostty-web/core/options-proxy.ts | 14 + .../ghostty-web/core/scrollbar-controller.ts | 204 +++ .../ghostty-web/core/terminal-controllers.ts | 250 ++++ .../ghostty-web/core/terminal-emitters.ts | 32 + .../ghostty-web/core/terminal-geometry.ts | 13 + .../vendor/ghostty-web/core/viewport.ts | 13 + src/client/vendor/ghostty-web/terminal.ts | 1174 ++++------------- .../vendor/tests/core/dom-setup.test.ts | 154 +++ .../vendor/tests/core/opened-parts.test.ts | 57 + .../vendor/tests/core/option-changes.test.ts | 79 ++ .../vendor/tests/core/options-proxy.test.ts | 15 + .../tests/core/scrollbar-controller.test.ts | 244 ++++ .../tests/core/terminal-controllers.test.ts | 138 ++ .../tests/core/terminal-emitters.test.ts | 37 + .../tests/core/terminal-geometry.test.ts | 26 + src/client/vendor/tests/core/viewport.test.ts | 29 + .../vendor/tests/support/fake-event-target.ts | 11 + .../vendor/tests/support/terminal-harness.ts | 129 ++ src/client/vendor/tests/terminal.test.ts | 787 ++++++----- 25 files changed, 2417 insertions(+), 1277 deletions(-) create mode 100644 src/client/vendor/ghostty-web/core/dom-setup.ts create mode 100644 src/client/vendor/ghostty-web/core/opened-parts.ts create mode 100644 src/client/vendor/ghostty-web/core/option-changes.ts create mode 100644 src/client/vendor/ghostty-web/core/scrollbar-controller.ts create mode 100644 src/client/vendor/ghostty-web/core/terminal-controllers.ts create mode 100644 src/client/vendor/ghostty-web/core/terminal-emitters.ts create mode 100644 src/client/vendor/tests/core/dom-setup.test.ts create mode 100644 src/client/vendor/tests/core/opened-parts.test.ts create mode 100644 src/client/vendor/tests/core/option-changes.test.ts create mode 100644 src/client/vendor/tests/core/scrollbar-controller.test.ts create mode 100644 src/client/vendor/tests/core/terminal-controllers.test.ts create mode 100644 src/client/vendor/tests/core/terminal-emitters.test.ts create mode 100644 src/client/vendor/tests/support/terminal-harness.ts diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index aab8e0e..09bf4da 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -15,10 +15,6 @@ export const RATCHET_ALLOWLIST: Allowlist = { sizes: { 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/client/vendor/ghostty-web/terminal.ts': { - lines: 1248, - step: 'vendor terminal split into core/ (ADR 061)', - }, 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, }, diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 6fc3ba7..238f129 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -55,6 +55,9 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: `writeQueue` and `flushWriteQueue`, which nothing ever filled, are removed with the split of the write pipeline (ADR 061). - `terminal.ts`: `viewportY` is an accessor over the new `Viewport` (`core/viewport.ts`), reading and setting its `y` as the field was read and set; `options` is typed as the stored values (`TerminalOptionValues`: every option but `ghostty`, `scrollbackLines` optional) instead of `Required<ITerminalOptions>`, and its proxy no longer goes through `any` (ADR 061). - `terminal.ts`: the queued wheel ticks and a throttled mouse move are dropped with their listeners in `cleanupComponents` (`WheelController.dispose`, `MouseController.dispose`) instead of at the top of `dispose()`; both are idle before an `open()` that failed, so nothing observable changes (ADR 061). +- `terminal.ts`: the parts `open()` creates are held in one `OpenedParts` (`core/opened-parts.ts`) instead of eight optional fields; `element`, `textarea`, `wasmTerm` and `renderer` are read-only accessors over it, undefined before `open()` and after `dispose()` as the fields were. The methods that asserted each field (`this.wasmTerm!`) take the parts from one check (`openParts`, with `assertOpen`'s messages), and a failed `open()` takes down the parts it got to, as `cleanupComponents` did. The teardown disposes the controllers before the parts instead of between the textarea and the link detector; none of them reaches another while it is taken down (ADR 061). +- `terminal.ts`: `open()` sends the cell's device pixels to the new engine without the reply drain `syncCellPixelSize` adds: on a new engine mode 2048 is off, so the call makes no reply and the drain had nothing to send (ADR 042, ADR 061). +- `terminal.ts`: the scrollbar's fade reads an injected clock (`Date.now()` and the browser's frames and timeouts by default, as before), and the fade in and fade out are one `fade('in' | 'out')` with the same opacity formulas (ADR 061). ## webtty-original files @@ -73,7 +76,7 @@ Not from upstream; each starts with, or will start with when it is next touched, - `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). - `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. -- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`), the render loop (`render-loop.ts`), link hover and clicks, the mouse report encoder and the right-click route (`mouse-controller.ts`), and wheel routing (`wheel-controller.ts`). `terminal.ts` is the facade over them and re-exports what moved. +- `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`), the render loop (`render-loop.ts`), link hover and clicks, the mouse report encoder and the right-click route (`mouse-controller.ts`), wheel routing (`wheel-controller.ts`), the scrollbar's press, drag and fade (`scrollbar-controller.ts`), those controllers made and wired together with the input route (`terminal-controllers.ts`), the emitters behind the `onX` events (`terminal-emitters.ts`), what an option set after `open()` changes (`option-changes.ts`), the DOM `open()` builds (`dom-setup.ts`) and the parts it holds (`opened-parts.ts`). `terminal.ts` is the facade over them and re-exports what moved. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. - `types.ts`: a re-export shim for the files above until its importers move (ADR 061). @@ -154,3 +157,13 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `terminal.ts`: `handleMouseMove` (now `move`, the scrollbar drag reached through `MouseHost.dragScrollbar`), `processMouseMove` (now `hover`, `linkAt`, `setHovered`), `handleMouseLeave` (now `leave`), `handleClick` (now `click`), `encodeMouse` (now `encode`; webtty, ADR 049), `handleMenuPress`, `handleMenuKey`, `handleContextMenu` (now `menuPress`, `menuKey`, `contextMenu`; webtty, ADR 051), the `currentHoveredLink` (now `hoveredLink`), `mouseMoveThrottleTimeout`, `pendingMouseMove` and `menuSource` fields, and those listeners' registration in `open()` and removal in `cleanupComponents` and `dispose()` (now `attach`, `dispose`) | `core/mouse-controller.ts` (`MouseController`, `browserTimeouts`) | refactor(client): terminal split, part 3: mouse and wheel controllers | | `terminal.ts`: `handleWheel` (now `handle`, `sendTicks`, `sendArrows`; webtty, ADR 045, ADR 049), the `customWheelEventHandler`, `wheelPace`, `wheelAccumulator`, `arrowAccumulator` and `tickPacer` fields (the setters of `attachCustomWheelEventHandler` and `attachWheelPace` now `setCustomHandler`, `setPace`), and the wheel listener's registration and removal (now `attach`, `dispose`) | `core/wheel-controller.ts` (`WheelController`) | refactor(client): terminal split, part 3: mouse and wheel controllers | | `terminal.ts`: what the two parts above reach of `Terminal` | `core/host-types.ts` (`TimeoutScheduler`, `MouseEngine`, `MouseRenderer`, `LinkSource`, `MenuSelection`, `MouseHost`, `WheelEngine`, `WheelHost`) | refactor(client): terminal split, part 3: mouse and wheel controllers | +| `terminal.ts`: `handleMouseDown`, `handleMouseUp`, `processScrollbarDrag` (now `press`, `release`, `dragTo`; the drag test of `MouseHost.dragScrollbar` now `drag`), `showScrollbar`, `hideScrollbar` (now `show`, `hide`, `cancelHide`), `fadeInScrollbar`, `fadeOutScrollbar` (now one `fade`), the `isDraggingScrollbar`, `scrollbarDragStart`, `scrollbarDragStartViewportY`, `scrollbarVisible`, `scrollbarOpacity` (now `opacity`) and `scrollbarHideTimeout` fields, and the press and document mouseup listeners' registration in `open()` and removal in `cleanupComponents` (now `attach`, `watchRelease`, `unwatchRelease`, `dispose`), on an injected clock | `core/scrollbar-controller.ts` (`ScrollbarController`, `browserScrollbarClock`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: from `open()`, the container's tabindex, contenteditable and `beforeinput` guard (now `makeContainerEditable`), its role and labels (now `labelContainer`), the canvas and textarea creation (now `createTerminalCanvas`, `createInputTextarea`) and their press-to-focus listeners (now `focusTextareaOnPress`); the attribute removal of `cleanupComponents` (now `unlabelContainer`); the canvas element sizing of `resize` and `handleFontChange` (now `sizeCanvasElement`); the body of `anchorTextarea` (webtty) | `core/dom-setup.ts` | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the `ghostty`-created parts' fields (`wasmTerm`, `renderer`, `inputHandler`, `selectionManager`, `canvas`, `linkDetector`, with `element` and `textarea`; now `OpenedParts`), and the part teardown of `cleanupComponents` (now `disposeParts`) | `core/opened-parts.ts` | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the construction of the focus tracker, finder, write pipeline, render loop, viewport, mouse and wheel controllers (now one `TerminalControllers`, with the scrollbar's), their listeners' registration in `open()` (now `attach`) and the wheel's and mouse's removal in `cleanupComponents` (now `detach`), and `handleInput` (now `input`; webtty, ADR 051, ADR 055) | `core/terminal-controllers.ts` (`TerminalControllers`, `ControllersHost`, `ControllersInternalsHost`, `ControllersClock`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the nine event emitter fields and their disposal in `dispose()` | `core/terminal-emitters.ts` (`TerminalEmitters`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: `handleOptionChange` (now `applyOptionChange` over an `OptionChangeHost`) | `core/option-changes.ts` | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the renderer options of `open()` (now `rendererOptions`) | `core/options-proxy.ts` | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the device-pixel maths of `cellPixelSize` (now `cellDevicePixels`, `pageDevicePixelRatio`) | `core/terminal-geometry.ts` | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the body of `jumpToPrompt` (webtty, ADR 057) | `core/viewport.ts` (`Viewport.jumpToPrompt`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: what the scrollbar reaches of `Terminal` | `core/host-types.ts` (`ScrollbarClock`, `ScrollbarHost`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | diff --git a/src/client/vendor/ghostty-web/core/dom-setup.ts b/src/client/vendor/ghostty-web/core/dom-setup.ts new file mode 100644 index 0000000..a227540 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/dom-setup.ts @@ -0,0 +1,126 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The DOM Terminal.open() builds and cleanupComponents takes down (util): the container's +// attributes, the canvas element, the hidden input textarea, the canvas's press-to-focus, +// and the element sizing and textarea anchoring done after (ADR 061). + +import { mouseCursorFor, setStylePx } from './terminal-geometry'; + +/** + * Makes the container focusable and contenteditable, so browser extensions (Vimium and + * the like) treat it as an input and leave its keys alone, while the browser's own editing + * of it is cancelled: input is handled by the terminal. + */ +export function makeContainerEditable(container: HTMLElement): void { + if (!container.hasAttribute('tabindex')) container.setAttribute('tabindex', '0'); + container.setAttribute('contenteditable', 'true'); + // leaks until ADR 065 + container.addEventListener('beforeinput', (e) => { + if (e.target === container) e.preventDefault(); + }); +} + +/** The container's role and label for screen readers and extensions. */ +export function labelContainer(container: HTMLElement): void { + container.setAttribute('role', 'textbox'); + container.setAttribute('aria-label', 'Terminal input'); + container.setAttribute('aria-multiline', 'true'); +} + +/** Removes what the two functions above set, except the tabindex, as dispose always has. */ +export function unlabelContainer(container: HTMLElement): void { + container.removeAttribute('contenteditable'); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); + container.removeAttribute('aria-multiline'); +} + +/** + * The terminal's canvas element, a block with the default arrow cursor. It draws nothing + * itself: the renderer draws on a canvas of its own (ADR 043). + */ +export function createTerminalCanvas(doc: Document): HTMLCanvasElement { + const canvas = doc.createElement('canvas'); + canvas.style.display = 'block'; + canvas.style.cursor = mouseCursorFor(undefined); + return canvas; +} + +/** + * The hidden textarea keyboard input goes to. It lives inside the container so its events + * bubble there, and is clipped away, caret included, while staying focusable for mobile + * keyboards. + */ +export function createInputTextarea(doc: Document): HTMLTextAreaElement { + const textarea = doc.createElement('textarea'); + textarea.setAttribute('autocorrect', 'off'); + textarea.setAttribute('autocapitalize', 'off'); + textarea.setAttribute('spellcheck', 'false'); + textarea.setAttribute('tabindex', '0'); + textarea.setAttribute('aria-label', 'Terminal input'); + const style = textarea.style; + style.position = 'absolute'; + style.left = '0'; + style.top = '0'; + style.width = '1px'; + style.height = '1px'; + style.padding = '0'; + style.border = 'none'; + style.margin = '0'; + style.opacity = '0'; + style.clipPath = 'inset(50%)'; + style.overflow = 'hidden'; + style.whiteSpace = 'nowrap'; + style.resize = 'none'; + return textarea; +} + +/** + * Focuses the textarea on a press (desktop) or a tap (mobile) on the canvas, with the + * default prevented first; on iOS that keeps the native caret away. + */ +export function focusTextareaOnPress( + canvas: HTMLCanvasElement, + textarea: HTMLTextAreaElement, +): void { + // leaks until ADR 065 + canvas.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + textarea.focus(); + }); + // leaks until ADR 065 + canvas.addEventListener('touchend', (ev) => { + ev.preventDefault(); + textarea.focus(); + }); +} + +/** Sizes the canvas element to `cols` × `rows` cells of the renderer's metrics. */ +export function sizeCanvasElement( + canvas: HTMLCanvasElement, + metrics: { width: number; height: number }, + cols: number, + rows: number, +): void { + canvas.style.width = `${metrics.width * cols}px`; + canvas.style.height = `${metrics.height * rows}px`; +} + +/** + * Moves the textarea onto the cursor cell, one cell high, writing only styles that + * changed. macOS places the dictation microphone and IME candidate window by its caret, so + * it stays anchored before a composition starts too. (webtty) + */ +export function anchorTextarea( + textarea: HTMLTextAreaElement, + spot: { left: number; top: number }, + cellHeight: number, +): void { + // The right-click copy flow repositions the textarea while it holds the selection; + // leave it alone until that flow restores it. + if (textarea.style.position === 'fixed' && textarea.style.pointerEvents === 'auto') return; + if (textarea.style.position !== 'absolute') textarea.style.position = 'absolute'; + setStylePx(textarea, 'left', spot.left); + setStylePx(textarea, 'top', spot.top); + setStylePx(textarea, 'width', 1); + setStylePx(textarea, 'height', cellHeight); +} diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index 8874343..0ed7fe6 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -252,3 +252,30 @@ export interface WheelHost { encodeMouse(event: MouseEventReport): string; send(data: string): void; } + +/** + * The clock the scrollbar's auto-hide and fade run on, injected so tests run them by hand; + * production passes the browser's (`browserScrollbarClock` in scrollbar-controller.ts). + */ +export interface ScrollbarClock extends TimeoutScheduler { + /** Wall-clock milliseconds, as `Date.now()`. */ + now(): number; + requestAnimationFrame(callback: () => void): number; +} + +/** + * What the ScrollbarController reads of the terminal when a press, drag or scroll arrives: + * the canvas and how far row 0 sits below its top (both undefined before open() and after + * dispose()), the scrollback and view it moves, and the frame that shows a new opacity. + */ +export interface ScrollbarHost { + canvas(): HTMLCanvasElement | undefined; + /** The renderer's scroll offset (ADR 043), or undefined without a renderer. */ + scrollOffset(): number | undefined; + getScrollbackLength(): number; + rows(): number; + viewportY(): number; + scrollToLine(line: number): void; + /** Renders a frame with the scrollbar at `opacity`, if the terminal is open. */ + render(opacity: number): void; +} diff --git a/src/client/vendor/ghostty-web/core/opened-parts.ts b/src/client/vendor/ghostty-web/core/opened-parts.ts new file mode 100644 index 0000000..1e04d2c --- /dev/null +++ b/src/client/vendor/ghostty-web/core/opened-parts.ts @@ -0,0 +1,53 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// What Terminal.open() creates (types + util): the parts held together while the terminal +// is open, and their teardown, which also takes down a set left partial by a failed +// open() (ADR 061). + +import type { GhosttyTerminal } from '../engine/engine-types'; +import type { InputHandler } from '../input-handler'; +import type { LinkDetector } from '../link-detector'; +import type { CanvasRenderer } from '../renderer'; +import type { SelectionManager } from '../selection-manager'; + +/** + * Everything open() creates, made in one place and held together, so a method that needs + * an open terminal takes them from one check instead of asserting each field. + */ +export interface OpenedParts { + readonly element: HTMLElement; + /** reset() replaces it with a new engine. */ + engine: GhosttyTerminal; + readonly canvas: HTMLCanvasElement; + readonly textarea: HTMLTextAreaElement; + readonly renderer: CanvasRenderer; + readonly input: InputHandler; + readonly selection: SelectionManager; + readonly links: LinkDetector; +} + +/** The parts made so far while open() runs, noted one by one for a failure's teardown. */ +export type PartsMade = { -readonly [K in keyof OpenedParts]?: OpenedParts[K] }; + +/** The parts' teardown calls, narrowed so tests pass fakes. */ +export type DisposableParts = { + [K in keyof OpenedParts]?: K extends 'element' | 'canvas' | 'textarea' + ? Node + : K extends 'engine' + ? Pick<GhosttyTerminal, 'free'> + : { dispose(): void }; +}; + +/** + * Disposes the selection, input and renderer, takes the canvas and textarea out of the + * container, disposes the links and frees the engine; parts not made are skipped. + */ +export function disposeParts(parts: DisposableParts): void { + parts.selection?.dispose(); + parts.input?.dispose(); + parts.renderer?.dispose(); + for (const node of [parts.canvas, parts.textarea]) { + if (node?.parentNode) node.parentNode.removeChild(node); + } + parts.links?.dispose(); + parts.engine?.free(); +} diff --git a/src/client/vendor/ghostty-web/core/option-changes.ts b/src/client/vendor/ghostty-web/core/option-changes.ts new file mode 100644 index 0000000..c92b7e7 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/option-changes.ts @@ -0,0 +1,63 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// What an option set after open() changes (util): the engine's default cursor, the +// renderer's font, or the grid size; xterm.js lets options change at runtime (ADR 061). + +import type { GhosttyTerminal } from '../engine/engine-types'; +import type { TerminalOptionValues } from './options-proxy'; + +/** What an option change reaches: the open terminal's engine and renderer, and its steps. */ +export interface OptionChangeHost { + /** The options, the changed one already set. */ + options(): Pick< + TerminalOptionValues, + 'cursorStyle' | 'cursorBlink' | 'fontSize' | 'fontFamily' | 'cols' | 'rows' + >; + engine(): Pick<GhosttyTerminal, 'setDefaultCursor'> | undefined; + renderer(): { setFontSize(size: number): void; setFontFamily(family: string): void } | undefined; + /** The cell size changed: the canvas follows it and every row is drawn again. */ + fontChanged(): void; + resize(cols: number, rows: number): void; +} + +/** + * Applies `key`'s change from `oldValue` to `newValue`; setting the value an option already + * has does nothing. `disableStdin` and the options read as they are used need nothing. + */ +export function applyOptionChange( + key: keyof TerminalOptionValues, + newValue: unknown, + oldValue: unknown, + host: OptionChangeHost, +): void { + if (newValue === oldValue) return; + const options = host.options(); + const renderer = host.renderer(); + switch (key) { + case 'cursorBlink': + case 'cursorStyle': + // The engine's default; the renderer draws what the engine reports (webtty, ADR 049) + host.engine()?.setDefaultCursor(options.cursorStyle, options.cursorBlink); + break; + case 'theme': + if (renderer) { + console.warn('ghostty-web: theme changes after open() are not yet fully supported'); + } + break; + case 'fontSize': + if (renderer) { + renderer.setFontSize(options.fontSize); + host.fontChanged(); + } + break; + case 'fontFamily': + if (renderer) { + renderer.setFontFamily(options.fontFamily); + host.fontChanged(); + } + break; + case 'cols': + case 'rows': + host.resize(options.cols, options.rows); + break; + } +} diff --git a/src/client/vendor/ghostty-web/core/options-proxy.ts b/src/client/vendor/ghostty-web/core/options-proxy.ts index d61eabf..9dbe03a 100644 --- a/src/client/vendor/ghostty-web/core/options-proxy.ts +++ b/src/client/vendor/ghostty-web/core/options-proxy.ts @@ -5,6 +5,7 @@ import type { GhosttyTerminalConfig } from '../engine/engine-types'; import type { ITerminalOptions } from '../interfaces'; +import type { RendererOptions } from '../render/renderable'; /** The options a Terminal keeps: every option but the engine, defaults filled in. */ export type TerminalOptionValues = Required< @@ -149,3 +150,16 @@ export function toEngineConfig(options: EngineOptions): GhosttyTerminalConfig { desktopNotifications: options.desktopNotifications, }; } + +/** The renderer's options at open(): the font, the cursor and the theme. */ +export function rendererOptions( + options: Pick<TerminalOptionValues, 'fontSize' | 'fontFamily' | 'cursorStyle' | 'cursorBlink' | 'theme'> +): RendererOptions { + return { + fontSize: options.fontSize, + fontFamily: options.fontFamily, + cursorStyle: options.cursorStyle, + cursorBlink: options.cursorBlink, + theme: options.theme, + }; +} diff --git a/src/client/vendor/ghostty-web/core/scrollbar-controller.ts b/src/client/vendor/ghostty-web/core/scrollbar-controller.ts new file mode 100644 index 0000000..e757db5 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/scrollbar-controller.ts @@ -0,0 +1,204 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The terminal's scrollbar (controller): a press on the track jumps, a press on the thumb +// drags, and each scroll fades it in, then out after a pause; the hit test is the +// painter's geometry (render/scrollbar-geometry.ts). Moved out of terminal.ts (ADR 061). + +import { DisposableStore, listen, type ListenerTarget } from '../lifecycle/disposable-store'; +import { + inScrollbarColumn, + SCROLLBAR_PADDING, + scrollbarThumbHeight, + scrollbarThumbY, + scrollbarTrackHeight, +} from '../render/scrollbar-geometry'; +import type { ScrollbarClock, ScrollbarHost } from './host-types'; +import { SCROLLBAR_FADE_DURATION_MS, SCROLLBAR_HIDE_DELAY_MS } from './terminal-constants'; + +/** The browser's clock, timeouts and frames, read when called. */ +export const browserScrollbarClock: ScrollbarClock = { + now: () => Date.now(), + setTimeout: (callback, ms) => window.setTimeout(callback, ms), + clearTimeout: (id) => window.clearTimeout(id), + requestAnimationFrame: (callback) => requestAnimationFrame(callback), +}; + +/** + * The scrollbar's press, drag and visibility. The press listener is added in the capture + * phase, before SelectionManager's, so a press on the scrollbar never starts a selection. + */ +export class ScrollbarController { + private dragging = false; + private dragStartY: number | null = null; + private dragStartViewportY = 0; + private visible = false; + private shownOpacity = 0; + private hideTimeout?: number; + private listeners?: DisposableStore; + private releaseTarget?: ListenerTarget; + + constructor( + private readonly host: ScrollbarHost, + private readonly clock: ScrollbarClock = browserScrollbarClock, + ) {} + + /** The opacity every render paints the scrollbar at: 0 hidden, 1 shown. */ + get opacity(): number { + return this.shownOpacity; + } + + /** Adds the container's capture press, at the position Terminal.open() always added it. */ + attach(container: HTMLElement): void { + const listeners = new DisposableStore(); + this.listeners = listeners; + listeners.add(listen(container, 'mousedown', this.press, { capture: true })); + } + + /** + * Adds the document's mouseup, so a drag ends even when released outside the canvas; + * open() adds it after the mouse controller's listeners. + */ + watchRelease(doc: ListenerTarget): void { + this.releaseTarget = doc; + // leaks until ADR 065 + doc.addEventListener('mouseup', this.release); + } + + /** + * Removes the document's mouseup. Terminal calls it only while open, which its teardown + * never is, so today it leaks (ADR 065). + */ + unwatchRelease(): void { + this.releaseTarget?.removeEventListener('mouseup', this.release); + } + + /** Removes the press listener and the pending auto-hide. Calling it again does nothing. */ + dispose(): void { + this.listeners?.dispose(); + this.cancelHide(); + } + + /** Moves a drag in progress to `e`; true when there was one and the move is used up. */ + drag(e: MouseEvent): boolean { + if (!this.dragging) return false; + this.dragTo(e); + return true; + } + + /** Fades the scrollbar in, or keeps it shown, and hides it after a pause unless dragged. */ + show(): void { + this.cancelHide(); + if (!this.visible) { + this.visible = true; + this.shownOpacity = 0; + this.fade('in'); + } else { + this.shownOpacity = 1; + } + if (!this.dragging) { + this.hideTimeout = this.clock.setTimeout(() => this.hide(), SCROLLBAR_HIDE_DELAY_MS); + } + } + + /** A press in the scrollbar's column: on the thumb it starts a drag, on the track it jumps. */ + private readonly press = (e: MouseEvent): void => { + const canvas = this.host.canvas(); + const scrollOffset = this.host.scrollOffset(); + if (!canvas || scrollOffset === undefined) return; + const scrollbackLength = this.host.getScrollbackLength(); + if (scrollbackLength === 0) return; + + const rect = canvas.getBoundingClientRect(); + // The scrollbar is painted over the rows in view (webtty, ADR 043) + const mouseY = e.clientY - rect.top - scrollOffset; + if (!inScrollbarColumn(e.clientX - rect.left, rect.width)) return; + e.preventDefault(); + e.stopPropagation(); + // SelectionManager never sees a press on the scrollbar + e.stopImmediatePropagation(); + + const trackHeight = scrollbarTrackHeight(rect.height); + const thumbHeight = scrollbarThumbHeight(this.host.rows(), scrollbackLength, trackHeight); + const viewportY = this.host.viewportY(); + const thumbY = scrollbarThumbY(viewportY, scrollbackLength, trackHeight, thumbHeight); + if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) { + this.dragging = true; + this.dragStartY = mouseY; + this.dragStartViewportY = viewportY; + // No text selection while dragging + canvas.style.userSelect = 'none'; + canvas.style.webkitUserSelect = 'none'; + } else { + // The track: top is the start of the scrollback, bottom the current output + const scrollFraction = 1 - (mouseY - SCROLLBAR_PADDING) / trackHeight; + const target = Math.round(scrollFraction * scrollbackLength); + this.host.scrollToLine(Math.max(0, Math.min(scrollbackLength, target))); + } + }; + + /** The end of a drag: text selection comes back and the auto-hide starts again. */ + private readonly release = (): void => { + if (!this.dragging) return; + this.dragging = false; + this.dragStartY = null; + const canvas = this.host.canvas(); + if (canvas) { + canvas.style.userSelect = ''; + canvas.style.webkitUserSelect = ''; + } + if (this.visible && this.host.getScrollbackLength() > 0) this.show(); + }; + + /** Moves the view by the thumb's travel since the press, in proportion to the scrollback. */ + private dragTo(e: MouseEvent): void { + const canvas = this.host.canvas(); + const scrollOffset = this.host.scrollOffset(); + if (!canvas || scrollOffset === undefined || this.dragStartY === null) return; + const scrollbackLength = this.host.getScrollbackLength(); + if (scrollbackLength === 0) return; + + const rect = canvas.getBoundingClientRect(); + const deltaY = e.clientY - rect.top - scrollOffset - this.dragStartY; + const trackHeight = scrollbarTrackHeight(rect.height); + const thumbHeight = scrollbarThumbHeight(this.host.rows(), scrollbackLength, trackHeight); + // The thumb moves against the view: thumb down is viewportY down + const scrollFraction = -deltaY / (trackHeight - thumbHeight); + const target = this.dragStartViewportY + Math.round(scrollFraction * scrollbackLength); + this.host.scrollToLine(Math.max(0, Math.min(scrollbackLength, target))); + } + + private hide(): void { + this.cancelHide(); + if (this.visible) this.fade('out'); + } + + private cancelHide(): void { + if (this.hideTimeout) { + this.clock.clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + } + } + + /** + * Fades in from 0, or out from the current opacity, over SCROLLBAR_FADE_DURATION_MS, + * rendering each frame. A fade out ends hidden, with one more render at opacity 0 to clear + * it. The frames are not cancelled on dispose(): they run out within the fade, rendering + * nothing once the terminal is closed. + */ + private fade(direction: 'in' | 'out'): void { + const start = this.clock.now(); + const from = this.shownOpacity; + const animate = () => { + const progress = Math.min((this.clock.now() - start) / SCROLLBAR_FADE_DURATION_MS, 1); + this.shownOpacity = direction === 'in' ? progress : from * (1 - progress); + this.host.render(this.shownOpacity); + if (progress < 1) { + this.clock.requestAnimationFrame(animate); + } else if (direction === 'out') { + this.visible = false; + this.shownOpacity = 0; + this.host.render(0); + } + }; + animate(); + } +} diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts new file mode 100644 index 0000000..a3e5dca --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -0,0 +1,250 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Terminal's controllers, made and wired in one place (composition): focus, find, writes, +// the render loop, the viewport, the scrollbar, the mouse and the wheel, each reaching the +// terminal and the others only through the hosts given here (ADR 061). + +import type { ListenerTarget } from '../lifecycle/disposable-store'; +import type { RightClickBehavior } from '../mouse-route'; +import { FindController } from './find-controller'; +import { FocusTracker } from './focus-tracker'; +import type { + FindHost, + FocusHost, + FrameScheduler, + MouseHost, + RenderLoopHost, + ScrollbarHost, + TimeoutScheduler, + WheelHost, + WriteHost, +} from './host-types'; +import { MouseController } from './mouse-controller'; +import type { OpenedParts } from './opened-parts'; +import { RenderLoop } from './render-loop'; +import { ScrollbarController } from './scrollbar-controller'; +import type { TerminalEmitters } from './terminal-emitters'; +import { Viewport } from './viewport'; +import { WheelController } from './wheel-controller'; +import { WritePipeline } from './write-pipeline'; + +/** What the controllers read of Terminal's public surface, each read made when needed. */ +export interface ControllersHost { + readonly options: { + readonly disableStdin: boolean; + readonly rightClickBehavior: RightClickBehavior; + readonly mouseScrollSpeed: number; + }; + readonly cols: number; + readonly rows: number; + readonly viewportY: number; + getScrollbackLength(): number; + scrollToLine(line: number): void; + scrollToBottom(): void; +} + +/** Terminal's private state and steps the controllers reach. */ +export interface ControllersInternalsHost { + readonly emit: TerminalEmitters; + /** The parts open() made; undefined before it and after dispose(). */ + parts(): OpenedParts | undefined; + /** Open and not disposed. */ + live(): boolean; + /** The embedder's callback (attachBeforeRender). */ + beforeRender(now: number): void; + /** Fires onTitleChange if the title changed. */ + setTitle(title: string): void; + /** Renders now if open, the scrollbar at `opacity` (its current one when not given). */ + render(force: boolean, opacity?: number): void; + anchorTextarea(): void; + /** The renderer's cell in whole device pixels, or null before open() (ADR 042). */ + cellPixelSize(): { width: number; height: number } | null; +} + +/** + * One clock for every controller's frames, timeouts and time, injected so tests run them + * by hand; without it each controller uses the browser's. + */ +export interface ControllersClock extends FrameScheduler, TimeoutScheduler { + now(): number; +} + +/** Terminal's controllers, built in the constructor, attached in open(), disposed together. */ +export class TerminalControllers { + /** Focus kept off the container and reported to the app under mode 1004 (ADR 053). */ + readonly focus: FocusTracker; + /** Find in the scrollback, stepped by the render loop (ADR 055). */ + readonly finder: FindController; + /** Output into the engine and every step after it, in a fixed order. */ + readonly writes: WritePipeline; + /** One frame after another while open, each in a fixed order. */ + readonly renderLoop: RenderLoop; + /** Where the view is in the scrollback; each move fires onScroll and shows the scrollbar. */ + readonly viewport: Viewport; + /** The scrollbar's press, drag and fade (ADR 043). */ + readonly scrollbar: ScrollbarController; + /** Link hover and clicks, the mouse report encoder and the right-click route (ADR 049, ADR 051). */ + readonly mouse: MouseController; + /** Wheel events to the embedder, or to the app as ticks or arrows (ADR 045, ADR 049). */ + readonly wheel: WheelController; + + constructor( + private readonly term: ControllersHost, + private readonly inner: ControllersInternalsHost, + clock?: ControllersClock, + ) { + this.focus = new FocusTracker(focusHost(term, inner)); + this.finder = new FindController(findHost(term, inner), clock?.now); + this.writes = new WritePipeline(writeHost(term, inner, this), clock); + this.renderLoop = new RenderLoop(renderLoopHost(inner, this), clock, clock?.now); + this.viewport = new Viewport({ + getScrollbackLength: () => term.getScrollbackLength(), + scrolled: (viewportY) => inner.emit.scroll.fire(viewportY), + showScrollbar: () => this.scrollbar.show(), + }); + this.scrollbar = new ScrollbarController(scrollbarHost(term, inner), clock); + this.mouse = new MouseController(mouseHost(term, inner, this), clock); + this.wheel = new WheelController(wheelHost(term, inner, this)); + } + + /** + * Input from the InputHandler. Keys and text clear the selection and scroll to the + * bottom; a mouse report leaves both alone, so a Shift selection outlives the app's + * hover and clicks. (webtty, ADR 051, ADR 055) + */ + input(data: string, mouseReport = false): void { + if (this.term.options.disableStdin) return; + if (!mouseReport) { + this.inner.parts()?.selection.clearSelection(); + // Ghostty's `scroll-to-bottom = keystroke`, which output no longer does while a + // match is selected (webtty, ADR 055) + this.term.scrollToBottom(); + } + this.writes.awaitEcho(); + this.inner.emit.data.fire(data); + } + + /** + * Adds the listeners open() adds after its parts, in the order it always has: the + * scrollbar press, the mouse's, the document's mouseup that ends a scrollbar drag, then + * the wheel. + */ + attach(container: HTMLElement, doc: ListenerTarget): void { + // Scrollbar presses, in the capture phase, before SelectionManager sees them + this.scrollbar.attach(container); + // Link hover and clicks, then the right-click route (webtty, ADR 051) + this.mouse.attach(container); + // A scrollbar drag ends wherever the button is released + this.scrollbar.watchRelease(doc); + // Wheel events, in the capture phase before the browser scrolls + this.wheel.attach(container); + } + + /** + * Removes the wheel's, the mouse's and the scrollbar's container listeners, with the + * wheel's queued ticks, the mouse's throttled move and the scrollbar's auto-hide. + */ + detach(): void { + this.wheel.dispose(); + this.mouse.dispose(); + this.scrollbar.dispose(); + } +} + +// Each controller's host, read when called: the parts are undefined before open() and +// after dispose(), and the other controllers are reached through `core` once all exist. + +type Inner = ControllersInternalsHost; + +function focusHost(term: ControllersHost, inner: Inner): FocusHost { + return { + element: () => inner.parts()?.element, + textarea: () => inner.parts()?.textarea, + engine: () => inner.parts()?.engine, + disableStdin: () => term.options.disableStdin, + send: (data) => inner.emit.data.fire(data), + }; +} + +function findHost(term: ControllersHost, inner: Inner): FindHost { + return { + engine: () => inner.parts()?.engine, + highlight: (highlights) => inner.parts()?.renderer.setSearchHighlights(highlights), + getScrollbackLength: () => term.getScrollbackLength(), + viewportY: () => term.viewportY, + rows: () => term.rows, + scrollToLine: (line) => term.scrollToLine(line), + changed: (state) => inner.emit.findChange.fire(state), + }; +} + +function writeHost(term: ControllersHost, inner: Inner, core: TerminalControllers): WriteHost { + return { + engine: () => inner.parts()?.engine, + disableStdin: () => term.options.disableStdin, + send: (data) => inner.emit.data.fire(data), + followFocusMode: () => core.focus.followMode(), + bell: () => inner.emit.bell.fire(), + title: (title) => inner.setTitle(title), + clipboardWrite: (text) => inner.emit.clipboardWrite.fire(text), + notify: (notification) => inner.emit.desktopNotification.fire(notification), + outputChanged: () => inner.parts()?.links.invalidateCache(), + viewportY: () => term.viewportY, + findAnchored: () => core.finder.anchored, + scrollToBottom: () => term.scrollToBottom(), + renderEcho: () => inner.render(false), + }; +} + +function renderLoopHost(inner: Inner, core: TerminalControllers): RenderLoopHost { + return { + live: () => inner.live(), + beforeRender: (now) => inner.beforeRender(now), + stepFind: (now) => core.finder.step(now), + render: () => inner.render(false), + hasReplies: () => inner.parts()?.engine.hasResponse() ?? false, + drainReplies: () => core.writes.drainReplies(), + anchorTextarea: () => inner.anchorTextarea(), + }; +} + +function scrollbarHost(term: ControllersHost, inner: Inner): ScrollbarHost { + return { + canvas: () => inner.parts()?.canvas, + scrollOffset: () => inner.parts()?.renderer.scrollOffset, + getScrollbackLength: () => term.getScrollbackLength(), + rows: () => term.rows, + viewportY: () => term.viewportY, + scrollToLine: (line) => term.scrollToLine(line), + render: (opacity) => inner.render(false, opacity), + }; +} + +function mouseHost(term: ControllersHost, inner: Inner, core: TerminalControllers): MouseHost { + return { + element: () => inner.parts()?.element, + canvas: () => inner.parts()?.canvas, + engine: () => inner.parts()?.engine, + renderer: () => inner.parts()?.renderer, + links: () => inner.parts()?.links, + selection: () => inner.parts()?.selection, + rows: () => term.rows, + cols: () => term.cols, + viewportY: () => term.viewportY, + cellPixelSize: () => inner.cellPixelSize(), + rightClickBehavior: () => term.options.rightClickBehavior, + dragScrollbar: (e) => core.scrollbar.drag(e), + requestPaste: () => inner.emit.pasteRequest.fire(), + }; +} + +function wheelHost(term: ControllersHost, inner: Inner, core: TerminalControllers): WheelHost { + return { + engine: () => inner.parts()?.engine, + cellHeight: () => inner.parts()?.renderer.getMetrics()?.height, + hasCanvas: () => !!inner.parts()?.canvas, + rows: () => term.rows, + mouseScrollSpeed: () => term.options.mouseScrollSpeed, + encodeMouse: (event) => core.mouse.encode(event), + send: (data) => inner.emit.data.fire(data), + }; +} diff --git a/src/client/vendor/ghostty-web/core/terminal-emitters.ts b/src/client/vendor/ghostty-web/core/terminal-emitters.ts new file mode 100644 index 0000000..e16deff --- /dev/null +++ b/src/client/vendor/ghostty-web/core/terminal-emitters.ts @@ -0,0 +1,32 @@ +// Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Terminal's event emitters (object): one per xterm-style `onX` event, disposed together +// when the terminal is (ADR 061). + +import type { DesktopNotification, SearchState } from '../engine/engine-types'; +import { EventEmitter } from '../event-emitter'; + +/** The emitters behind Terminal's `onX` events; the terminal and its controllers fire them. */ +export class TerminalEmitters { + readonly data = new EventEmitter<string>(); + readonly resize = new EventEmitter<{ cols: number; rows: number }>(); + readonly bell = new EventEmitter<void>(); + readonly titleChange = new EventEmitter<string>(); + readonly scroll = new EventEmitter<number>(); + readonly pasteRequest = new EventEmitter<void>(); + readonly clipboardWrite = new EventEmitter<string>(); + readonly desktopNotification = new EventEmitter<DesktopNotification>(); + readonly findChange = new EventEmitter<SearchState>(); + + /** Drops every listener of every emitter. */ + dispose(): void { + this.data.dispose(); + this.resize.dispose(); + this.bell.dispose(); + this.titleChange.dispose(); + this.scroll.dispose(); + this.pasteRequest.dispose(); + this.clipboardWrite.dispose(); + this.desktopNotification.dispose(); + this.findChange.dispose(); + } +} diff --git a/src/client/vendor/ghostty-web/core/terminal-geometry.ts b/src/client/vendor/ghostty-web/core/terminal-geometry.ts index 07d8421..ee9c108 100644 --- a/src/client/vendor/ghostty-web/core/terminal-geometry.ts +++ b/src/client/vendor/ghostty-web/core/terminal-geometry.ts @@ -133,6 +133,19 @@ export function deviceCellPixels(cssCell: number, dpr: number): number { return Math.max(1, Math.round(cssCell * dpr)); } +/** The page's device pixel ratio, 1 outside a browser or when the window reports none. */ +export function pageDevicePixelRatio(): number { + return (typeof window !== 'undefined' && window.devicePixelRatio) || 1; +} + +/** A `cell` of CSS pixels in whole device pixels (deviceCellPixels on each side). (webtty, ADR 042) */ +export function cellDevicePixels( + cell: { width: number; height: number }, + dpr: number = pageDevicePixelRatio() +): { width: number; height: number } { + return { width: deviceCellPixels(cell.width, dpr), height: deviceCellPixels(cell.height, dpr) }; +} + /** * The cursor cell's top-left corner, in CSS pixels relative to the container's * padding box. `cursor.y` is relative to the bottom screen; when scrolled back by diff --git a/src/client/vendor/ghostty-web/core/viewport.ts b/src/client/vendor/ghostty-web/core/viewport.ts index 55174be..9170103 100644 --- a/src/client/vendor/ghostty-web/core/viewport.ts +++ b/src/client/vendor/ghostty-web/core/viewport.ts @@ -2,6 +2,7 @@ // The view's position in the scrollback (object): `y` lines back from the bottom, moved // only within the scrollback, each move announced and the scrollbar shown (ADR 061). +import type { GhosttyTerminal } from '../engine/engine-types'; import type { ViewportHost } from './host-types'; /** Where the view is: `y` = 0 shows the bottom screen, `y` > 0 is scrolled into history. */ @@ -34,6 +35,18 @@ export class Viewport { if (this.host.getScrollbackLength() > 0) this.host.showScrollbar(); } + /** + * Ghostty's `jump_to_prompt`: moves by `delta` OSC 133 prompts (negative is up), the + * prompt landing as the top row; nothing without marks or past the last one. (webtty, + * ADR 057) + */ + jumpToPrompt(delta: number, engine: Pick<GhosttyTerminal, 'promptJumpRow'>): void { + const scrollback = this.host.getScrollbackLength(); + const top = scrollback - Math.floor(this.y); + const row = engine.promptJumpRow(top, delta); + if (row !== top) this.scrollToLine(scrollback - row); + } + private moveTo(y: number, scrollbackLength: number): void { if (y === this.y) return; this.y = y; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 9277745..20cd610 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -4,30 +4,28 @@ // `new Terminal({ ghostty: await Ghostty.load(url), ... })` and `open(container)` (ADR 061). import { BufferNamespace } from './buffer'; -import { FindController } from './core/find-controller'; -import { FocusTracker } from './core/focus-tracker'; -import { MouseController } from './core/mouse-controller'; -import { RenderLoop } from './core/render-loop'; import { - SCROLLBAR_FADE_DURATION_MS, - SCROLLBAR_HIDE_DELAY_MS, -} from './core/terminal-constants'; + anchorTextarea, + createInputTextarea, + createTerminalCanvas, + focusTextareaOnPress, + labelContainer, + makeContainerEditable, + sizeCanvasElement, + unlabelContainer, +} from './core/dom-setup'; +import { disposeParts, type OpenedParts, type PartsMade } from './core/opened-parts'; +import { applyOptionChange, type OptionChangeHost } from './core/option-changes'; import { createOptionsProxy, + rendererOptions, type TerminalOptionValues, toEngineConfig, withDefaults, } from './core/options-proxy'; -import { - cursorCellOffset, - deviceCellPixels, - mouseCursorFor, - setStylePx, -} from './core/terminal-geometry'; -import { Viewport } from './core/viewport'; -import { WheelController } from './core/wheel-controller'; -import { WritePipeline } from './core/write-pipeline'; -import { EventEmitter } from './event-emitter'; +import { TerminalControllers } from './core/terminal-controllers'; +import { TerminalEmitters } from './core/terminal-emitters'; +import { cellDevicePixels, cursorCellOffset } from './core/terminal-geometry'; import type { DesktopNotification, Ghostty, @@ -36,7 +34,7 @@ import type { SearchState, } from './ghostty'; import type { Pace } from './glide'; -import { InputHandler, type MouseTrackingConfig } from './input-handler'; +import { InputHandler } from './input-handler'; import type { IBufferNamespace, IEvent, @@ -48,13 +46,6 @@ import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; -import { - inScrollbarColumn, - SCROLLBAR_PADDING, - scrollbarThumbHeight, - scrollbarThumbY, - scrollbarTrackHeight, -} from './render/scrollbar-geometry'; import { SelectionManager } from './selection-manager'; // The facade's former contents, re-exported so deep imports keep working (ADR 061) @@ -72,446 +63,204 @@ export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; public rows: number; - public element?: HTMLElement; - public textarea?: HTMLTextAreaElement; - - // Buffer API (xterm.js compatibility) public readonly buffer: IBufferNamespace; - - // Options (public for xterm.js compatibility); a change after open() is applied + /** The options; a change after open() is applied (xterm.js compatibility). */ public readonly options: TerminalOptionValues; - // Components (created on open()) private ghostty?: Ghostty; - public wasmTerm?: GhosttyTerminal; // Made public for link providers - public renderer?: CanvasRenderer; // Made public for FitAddon - private inputHandler?: InputHandler; - private selectionManager?: SelectionManager; - private canvas?: HTMLCanvasElement; - - // Link detection system - private linkDetector?: LinkDetector; + private parts?: OpenedParts; + private isOpen = false; + private isDisposed = false; + private addons: ITerminalAddon[] = []; + // The title last reported through onTitleChange + private currentTitle = ''; + private beforeRender?: (now: number) => void; - // Event emitters - private dataEmitter = new EventEmitter<string>(); - private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); - private bellEmitter = new EventEmitter<void>(); - private titleChangeEmitter = new EventEmitter<string>(); - private scrollEmitter = new EventEmitter<number>(); - private pasteRequestEmitter = new EventEmitter<void>(); - private clipboardWriteEmitter = new EventEmitter<string>(); - private desktopNotificationEmitter = new EventEmitter<DesktopNotification>(); - private findChangeEmitter = new EventEmitter<SearchState>(); + private readonly emit = new TerminalEmitters(); // Public event accessors (xterm.js compatibility) - public readonly onData: IEvent<string> = this.dataEmitter.event; - public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; - public readonly onBell: IEvent<void> = this.bellEmitter.event; - public readonly onTitleChange: IEvent<string> = this.titleChangeEmitter.event; - public readonly onScroll: IEvent<number> = this.scrollEmitter.event; + public readonly onData: IEvent<string> = this.emit.data.event; + public readonly onResize: IEvent<{ cols: number; rows: number }> = this.emit.resize.event; + public readonly onBell: IEvent<void> = this.emit.bell.event; + public readonly onTitleChange: IEvent<string> = this.emit.titleChange.event; + public readonly onScroll: IEvent<number> = this.emit.scroll.event; /** * A right-click asks to paste the clipboard (`rightClickBehavior: "paste"`). The * embedder reads the clipboard, as for Ctrl+V. (webtty, ADR 051) */ - public readonly onPasteRequest: IEvent<void> = this.pasteRequestEmitter.event; + public readonly onPasteRequest: IEvent<void> = this.emit.pasteRequest.event; /** * The program wrote the clipboard (OSC 52, OSC 1337 Copy, OSC 5522), with the text to * write, `''` to clear it. Fires only while the `clipboardWrite` option is on; the * embedder writes the browser clipboard. (webtty, ADR 052) */ - public readonly onClipboardWrite: IEvent<string> = this.clipboardWriteEmitter.event; + public readonly onClipboardWrite: IEvent<string> = this.emit.clipboardWrite.event; /** * The program asked for a desktop notification (OSC 9, OSC 777 `notify`), with its * title (`''` for none) and body as plain text. Fires only while the * `desktopNotifications` option is on; the embedder shows it. (webtty, ADR 056) */ public readonly onDesktopNotification: IEvent<DesktopNotification> = - this.desktopNotificationEmitter.event; + this.emit.desktopNotification.event; /** The match count or the selected match changed (webtty, ADR 055) */ - public readonly onFindChange: IEvent<SearchState> = this.findChangeEmitter.event; + public readonly onFindChange: IEvent<SearchState> = this.emit.findChange.event; - // Lifecycle state - private isOpen = false; - private isDisposed = false; - - // Addons - private addons: ITerminalAddon[] = []; - - // The title last reported through onTitleChange - private currentTitle: string = ''; - - // Focus kept off the container and reported to the app under mode 1004 (webtty, ADR 053) - private readonly focusTracker = new FocusTracker({ - element: () => this.element, - textarea: () => this.textarea, - engine: () => this.wasmTerm, - disableStdin: () => this.options.disableStdin, - send: (data) => this.dataEmitter.fire(data), - }); - - // Find in the scrollback, stepped by the render loop (webtty, ADR 055) - private readonly finder = new FindController({ - engine: () => this.wasmTerm, - highlight: (highlights) => this.renderer?.setSearchHighlights(highlights), - getScrollbackLength: () => this.getScrollbackLength(), - viewportY: () => this.viewportY, - rows: () => this.rows, - scrollToLine: (line) => this.scrollToLine(line), - changed: (state) => this.findChangeEmitter.fire(state), - }); - - // Output into the engine and every step after it, in a fixed order (ADR 061) - private readonly writes = new WritePipeline({ - engine: () => this.wasmTerm, - disableStdin: () => this.options.disableStdin, - send: (data) => this.dataEmitter.fire(data), - followFocusMode: () => this.focusTracker.followMode(), - bell: () => this.bellEmitter.fire(), - title: (title) => this.setTitle(title), - clipboardWrite: (text) => this.clipboardWriteEmitter.fire(text), - notify: (notification) => this.desktopNotificationEmitter.fire(notification), - outputChanged: () => this.linkDetector?.invalidateCache(), - viewportY: () => this.viewportY, - findAnchored: () => this.finder.anchored, - scrollToBottom: () => this.scrollToBottom(), - renderEcho: () => { - if (this.renderer && this.wasmTerm) { - this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); - } - }, - }); - - // One frame after another while open, each in a fixed order (ADR 061) - private readonly renderLoop = new RenderLoop({ + // Focus, find, writes, the render loop, the view, the scrollbar, the mouse and the wheel + private readonly core = new TerminalControllers(this, { + emit: this.emit, + parts: () => this.parts, live: () => !this.isDisposed && this.isOpen, beforeRender: (now) => this.beforeRender?.(now), - stepFind: (now) => this.finder.step(now), - render: () => - this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity), - hasReplies: () => this.wasmTerm!.hasResponse(), - drainReplies: () => this.writes.drainReplies(), + setTitle: (title) => this.setTitle(title), + render: (force, opacity) => this.renderFrame(force, opacity), anchorTextarea: () => this.anchorTextarea(), - }); - - // Where the view is in the scrollback; each move fires onScroll and shows the scrollbar - private readonly viewport = new Viewport({ - getScrollbackLength: () => this.getScrollbackLength(), - scrolled: (viewportY) => this.scrollEmitter.fire(viewportY), - showScrollbar: () => this.showScrollbar(), - }); - - // Link hover and clicks, the mouse report encoder and the right-click route (ADR 049, ADR 051) - private readonly mouse = new MouseController({ - element: () => this.element, - canvas: () => this.canvas, - engine: () => this.wasmTerm, - renderer: () => this.renderer, - links: () => this.linkDetector, - selection: () => this.selectionManager, - rows: () => this.rows, - cols: () => this.cols, - viewportY: () => this.getViewportY(), cellPixelSize: () => this.cellPixelSize(), - rightClickBehavior: () => this.options.rightClickBehavior, - dragScrollbar: (e) => { - if (!this.isDraggingScrollbar) return false; - this.processScrollbarDrag(e); - return true; - }, - requestPaste: () => this.pasteRequestEmitter.fire(), }); - // Wheel events to the embedder, or to the app as ticks or arrows (ADR 045, ADR 049) - private readonly wheel = new WheelController({ + // What an option set after open() reaches + private readonly optionChangeHost: OptionChangeHost = { + options: () => this.options, engine: () => this.wasmTerm, - cellHeight: () => this.renderer?.getMetrics()?.height, - hasCanvas: () => !!this.canvas, - rows: () => this.rows, - mouseScrollSpeed: () => this.options.mouseScrollSpeed, - encodeMouse: (event) => this.mouse.encode(event), - send: (data) => this.dataEmitter.fire(data), - }); - private beforeRender?: (now: number) => void; - - // Scrollbar interaction state - private isDraggingScrollbar: boolean = false; - private scrollbarDragStart: number | null = null; - private scrollbarDragStartViewportY: number = 0; - - // Scrollbar visibility/auto-hide state - private scrollbarVisible: boolean = false; - private scrollbarOpacity: number = 0; - private scrollbarHideTimeout?: number; + renderer: () => this.renderer, + fontChanged: () => this.handleFontChange(), + resize: (cols, rows) => this.resize(cols, rows), + }; constructor(options: ITerminalOptions) { this.ghostty = options.ghostty; // Options set after open() are applied (xterm.js compatibility) this.options = createOptionsProxy(withDefaults(options), (key, value, oldValue) => { - if (this.isOpen) this.handleOptionChange(key, value, oldValue); + if (this.isOpen) applyOptionChange(key, value, oldValue, this.optionChangeHost); }); - this.cols = this.options.cols; this.rows = this.options.rows; - - // Initialize buffer API this.buffer = new BufferNamespace(this); } + /** The container open() was given; undefined before it and after dispose(). */ + get element(): HTMLElement | undefined { + return this.parts?.element; + } + + /** The hidden textarea keyboard input goes to; undefined before open(). */ + get textarea(): HTMLTextAreaElement | undefined { + return this.parts?.textarea; + } + + /** The engine, public for the link providers, the buffer API and webtty's features. */ + get wasmTerm(): GhosttyTerminal | undefined { + return this.parts?.engine; + } + + /** The renderer, public for FitAddon and webtty's features. */ + get renderer(): CanvasRenderer | undefined { + return this.parts?.renderer; + } + /** * Lines scrolled back from the bottom of the scrollback (0 = at the bottom); fractional * while smooth scrolling. Setting it moves the view without firing onScroll. */ get viewportY(): number { - return this.viewport.y; + return this.core.viewport.y; } set viewportY(y: number) { - this.viewport.y = y; - } - - /** Applies an option set after open(): the cursor, the font, or the grid size. */ - private handleOptionChange( - key: keyof TerminalOptionValues, - newValue: unknown, - oldValue: unknown - ): void { - if (newValue === oldValue) return; - - switch (key) { - case 'disableStdin': - // Input handler already checks this.options.disableStdin dynamically - // No action needed - break; - - case 'cursorBlink': - case 'cursorStyle': - // The engine's default; the renderer draws what the engine reports (webtty, ADR 049) - this.wasmTerm?.setDefaultCursor(this.options.cursorStyle, this.options.cursorBlink); - break; - - case 'theme': - if (this.renderer) { - console.warn('ghostty-web: theme changes after open() are not yet fully supported'); - } - break; - - case 'fontSize': - if (this.renderer) { - this.renderer.setFontSize(this.options.fontSize); - this.handleFontChange(); - } - break; - - case 'fontFamily': - if (this.renderer) { - this.renderer.setFontFamily(this.options.fontFamily); - this.handleFontChange(); - } - break; - - case 'cols': - case 'rows': - // Redirect to resize method - this.resize(this.options.cols, this.options.rows); - break; - } + this.core.viewport.y = y; } /** - * Handle font changes (fontSize or fontFamily) - * Updates canvas size to match new font metrics and forces a full re-render + * After a font change: the selection's pixels are stale, so it is cleared; the canvas + * takes the new cell size and every row is drawn again. */ private handleFontChange(): void { - if (!this.renderer || !this.wasmTerm || !this.canvas) return; - - // Clear any active selection since pixel positions have changed - if (this.selectionManager) { - this.selectionManager.clearSelection(); - } - - // Resize canvas to match new font metrics - this.renderer.resize(this.cols, this.rows); - - // Update canvas element dimensions to match renderer. The element draws nothing: the - // renderer draws on a canvas of its own (webtty, ADR 043) - const metrics = this.renderer.getMetrics(); - this.canvas.style.width = `${metrics.width * this.cols}px`; - this.canvas.style.height = `${metrics.height * this.rows}px`; - - // Force full re-render with new font, the scrollbar at its current opacity: a frame - // that drops it repaints every row (webtty, ADR 043) - this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); + const parts = this.parts; + if (!parts) return; + parts.selection.clearSelection(); + parts.renderer.resize(this.cols, this.rows); + sizeCanvasElement(parts.canvas, parts.renderer.getMetrics(), this.cols, this.rows); + // The scrollbar at its current opacity: a frame that drops it repaints every row + // (webtty, ADR 043) + this.renderFrame(true); } /** - * Open terminal in a parent element - * - * Initializes all components and starts rendering. - * Requires a pre-loaded Ghostty instance passed to the constructor. + * Opens the terminal in `parent`: builds its parts, adds the listeners in their fixed + * order, renders the first frame and starts the render loop. The engine comes from the + * Ghostty instance given to the constructor. */ open(parent: HTMLElement): void { - if (this.isOpen) { - throw new Error('Terminal is already open'); - } - if (this.isDisposed) { - throw new Error('Terminal has been disposed'); - } - - // Store parent element - this.element = parent; + if (this.isOpen) throw new Error('Terminal is already open'); + if (this.isDisposed) throw new Error('Terminal has been disposed'); this.isOpen = true; - + const made: PartsMade = { element: parent }; try { - // Make parent focusable if it isn't already - if (!parent.hasAttribute('tabindex')) { - parent.setAttribute('tabindex', '0'); - } - - // Mark as contenteditable so browser extensions (Vimium, etc.) recognize - // this as an input element and don't intercept keyboard events. - parent.setAttribute('contenteditable', 'true'); - // Prevent actual content editing - we handle input ourselves - parent.addEventListener('beforeinput', (e) => { - if (e.target === parent) { - e.preventDefault(); - } - }); - // Focus is followed, and kept on the textarea, by the focus tracker (webtty, ADR 053) - this.focusTracker.attach(parent); - - // Add accessibility attributes for screen readers and extensions - parent.setAttribute('role', 'textbox'); - parent.setAttribute('aria-label', 'Terminal input'); - parent.setAttribute('aria-multiline', 'true'); - - // Create WASM terminal with current dimensions and config - const config = toEngineConfig(this.options); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); - - // Create canvas element - this.canvas = document.createElement('canvas'); - this.canvas.style.display = 'block'; - this.canvas.style.cursor = mouseCursorFor(undefined); - - parent.appendChild(this.canvas); - - // Create hidden textarea for keyboard input (must be inside parent for event bubbling) - this.textarea = document.createElement('textarea'); - this.textarea.setAttribute('autocorrect', 'off'); - this.textarea.setAttribute('autocapitalize', 'off'); - this.textarea.setAttribute('spellcheck', 'false'); - this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard - this.textarea.setAttribute('aria-label', 'Terminal input'); - // Use clip-path to completely hide the textarea and its caret - this.textarea.style.position = 'absolute'; - this.textarea.style.left = '0'; - this.textarea.style.top = '0'; - this.textarea.style.width = '1px'; - this.textarea.style.height = '1px'; - this.textarea.style.padding = '0'; - this.textarea.style.border = 'none'; - this.textarea.style.margin = '0'; - this.textarea.style.opacity = '0'; - this.textarea.style.clipPath = 'inset(50%)'; // Clip everything including caret - this.textarea.style.overflow = 'hidden'; - this.textarea.style.whiteSpace = 'nowrap'; - this.textarea.style.resize = 'none'; - parent.appendChild(this.textarea); - - // Focus textarea on interaction - preventDefault before focus - const textarea = this.textarea; - // Desktop: mousedown - this.canvas.addEventListener('mousedown', (ev) => { - ev.preventDefault(); - textarea.focus(); - }); - // Mobile: touchend with preventDefault to suppress iOS caret - this.canvas.addEventListener('touchend', (ev) => { - ev.preventDefault(); - textarea.focus(); - }); - - // Create renderer - this.renderer = new CanvasRenderer(this.canvas, { - fontSize: this.options.fontSize, - fontFamily: this.options.fontFamily, - cursorStyle: this.options.cursorStyle, - cursorBlink: this.options.cursorBlink, - theme: this.options.theme, - }); - - // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) - this.renderer.resize(this.cols, this.rows); - - // Cell pixels for Ghostty's size reports, before the first write, - // so a reply never says 0 px. Mode 2048 cannot be on yet. (webtty, ADR 042) - this.syncCellPixelSize(); - - // Create mouse tracking configuration. Reports are encoded by Ghostty (webtty, ADR 049) - const mouseConfig: MouseTrackingConfig = { - hasMouseTracking: () => this.wasmTerm?.hasMouseTracking() ?? false, - encode: (event) => this.mouse.encode(event), - }; - - // Create input handler - this.inputHandler = new InputHandler({ - ghostty: this.ghostty!, - container: parent, - onData: this.handleInput, - // Query terminal mode state (e.g., mode 1 for application cursor mode) - getMode: (mode: number) => this.wasmTerm?.getMode(mode, false) ?? false, - // Handle Cmd+C copy - returns true if there was a selection to copy - onCopy: () => this.copySelection(), - inputElement: this.textarea, - mouseConfig, - }); - - // Create selection manager (pass textarea for context menu positioning) - this.selectionManager = new SelectionManager( - this, - this.renderer, - this.wasmTerm, - this.textarea - ); - - // Connect selection manager to renderer - this.renderer.setSelectionManager(this.selectionManager); - - // Initialize link detection system - this.linkDetector = new LinkDetector(this); - - // Register link providers - // OSC8 first (explicit hyperlinks take precedence) - this.linkDetector.registerProvider(new OSC8LinkProvider(this)); - // URL regex second (fallback for plain text URLs) - this.linkDetector.registerProvider(new UrlRegexProvider(this)); - - // Scrollbar clicks, in the capture phase to intercept them before SelectionManager - parent.addEventListener('mousedown', this.handleMouseDown, { capture: true }); - // Link hover and clicks, then the right-click route (webtty, ADR 051) - this.mouse.attach(parent); - - // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) - document.addEventListener('mouseup', this.handleMouseUp); - - // Wheel events, in the capture phase before the browser scrolls - this.wheel.attach(parent); - - // Render initial blank screen (force full redraw) - this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); - - // Start render loop - this.renderLoop.start(); - - // Focus input (auto-focus so user can start typing immediately) + const parts = this.createParts(parent, made); + this.parts = parts; + this.core.attach(parent, document); + this.renderFrame(true); + this.core.renderLoop.start(); this.focus(); } catch (error) { - // Clean up on error this.isOpen = false; - this.cleanupComponents(); + this.cleanupComponents(this.parts ?? made); throw new Error(`Failed to open terminal: ${error}`); } } + /** + * Builds the DOM, the engine, the renderer, input, selection and links, in the order + * open() has always made them (their listeners' order depends on it). Each part is noted + * in `made` as it exists, so a failure part way cleans up what was made. + */ + private createParts(parent: HTMLElement, made: PartsMade): OpenedParts { + makeContainerEditable(parent); + // Focus is followed, and kept on the textarea, by the focus tracker (webtty, ADR 053) + this.core.focus.attach(parent); + labelContainer(parent); + const engine = this.ghostty!.createTerminal(this.cols, this.rows, toEngineConfig(this.options)); + made.engine = engine; + const canvas = createTerminalCanvas(document); + made.canvas = canvas; + parent.appendChild(canvas); + const textarea = createInputTextarea(document); + made.textarea = textarea; + parent.appendChild(textarea); + focusTextareaOnPress(canvas, textarea); + const renderer = new CanvasRenderer(canvas, rendererOptions(this.options)); + made.renderer = renderer; + // Sized with the renderer's resize, for its device-pixel scaling + renderer.resize(this.cols, this.rows); + // Cell pixels for Ghostty's size reports, before the first write, so a reply never + // says 0 px. Mode 2048 cannot be on yet, so there is no reply to drain. (webtty, ADR 042) + const px = cellDevicePixels(renderer.getMetrics()); + engine.setCellPixelSize(px.width, px.height); + const input = new InputHandler({ + ghostty: this.ghostty!, + container: parent, + onData: (data, mouseReport) => this.core.input(data, mouseReport), + // Application cursor mode and the like, read from the engine as each key arrives + getMode: (mode: number) => this.wasmTerm?.getMode(mode, false) ?? false, + // Cmd+C copies the selection; true when there was one + onCopy: () => this.copySelection(), + inputElement: textarea, + // Reports are encoded by Ghostty (webtty, ADR 049) + mouseConfig: { + hasMouseTracking: () => this.wasmTerm?.hasMouseTracking() ?? false, + encode: (event) => this.core.mouse.encode(event), + }, + }); + made.input = input; + const selection = new SelectionManager(this, renderer, engine, textarea); + made.selection = selection; + renderer.setSelectionManager(selection); + const links = new LinkDetector(this); + made.links = links; + // OSC 8 hyperlinks take precedence over plain-text URLs + links.registerProvider(new OSC8LinkProvider(this)); + links.registerProvider(new UrlRegexProvider(this)); + return { element: parent, engine, canvas, textarea, renderer, input, selection, links }; + } + /** * Pastes text as Ghostty does (`ghostty_terminal_paste`): bracketed while the app has * mode 2004 on, unsafe control bytes replaced with spaces, and newlines sent as CR @@ -519,12 +268,12 @@ export class Terminal implements ITerminalCore { * as it was, and only with 2004 on; ADR 049) */ paste(text: string): void { - this.assertOpen(); + const parts = this.openParts(); if (this.options.disableStdin) return; // Ghostty always scrolls to the bottom on a paste (webtty, ADR 055) this.scrollToBottom(); - this.wasmTerm!.paste(text); - this.writes.drainReplies(); + parts.engine.paste(text); + this.core.writes.drainReplies(); } /** @@ -534,7 +283,7 @@ export class Terminal implements ITerminalCore { * (webtty, ADR 055) */ find(needle: string): void { - this.finder.find(needle); + this.core.finder.find(needle); } /** Ends the search and clears its highlights (Ghostty's `end_search`). */ @@ -544,7 +293,7 @@ export class Terminal implements ITerminalCore { /** Whether a search is open. */ get finding(): boolean { - return this.finder.finding; + return this.core.finder.finding; } /** @@ -552,12 +301,12 @@ export class Terminal implements ITerminalCore { * scrolls to it if none of its rows is in view. False with no search open. */ findNext(): boolean { - return this.finder.select('next'); + return this.core.finder.select('next'); } /** Selects the previous match, toward newer output. False with no search open. */ findPrevious(): boolean { - return this.finder.select('previous'); + return this.core.finder.select('previous'); } /** @@ -566,11 +315,8 @@ export class Terminal implements ITerminalCore { * (webtty, ADR 057) */ jumpToPrompt(delta: number): void { - if (!this.wasmTerm) return; - const scrollback = this.getScrollbackLength(); - const top = scrollback - Math.floor(this.viewportY); - const row = this.wasmTerm.promptJumpRow(top, delta); - if (row !== top) this.scrollToLine(scrollback - row); + const engine = this.wasmTerm; + if (engine) this.core.viewport.jumpToPrompt(delta, engine); } /** @@ -582,77 +328,46 @@ export class Terminal implements ITerminalCore { * it. (webtty, ADR 054) */ clearScreen(): boolean { - this.assertOpen(); - if (this.wasmTerm!.isAlternateScreen()) return false; + if (this.openParts().engine.isAlternateScreen()) return false; this.clearSelection(); - this.writes.clearAtGround(); + this.core.writes.clearAtGround(); return true; } - /** - * Write data to terminal - */ + /** Writes output to the terminal; `callback` runs on the next frame, after its render. */ write(data: string | Uint8Array, callback?: () => void): void { - this.assertOpen(); - - // Handle convertEol option - if (this.options.convertEol && typeof data === 'string') { - data = data.replace(/\n/g, '\r\n'); - } - - this.writes.write(data, callback); + this.openParts(); + if (this.options.convertEol && typeof data === 'string') data = data.replace(/\n/g, '\r\n'); + this.core.writes.write(data, callback); } - /** - * Resize terminal - */ + /** Resizes the grid, the engine and the canvas, fires onResize and renders every row. */ resize(cols: number, rows: number): void { - this.assertOpen(); - - if (cols === this.cols && rows === this.rows) { - return; // No change - } - - // Cancel render loop before resize to prevent accessing detached TypedArray - // views while WASM reallocates buffers. We restart it after resize completes. - // This avoids the background-tab regression of using an isResizing flag - // cleared via requestAnimationFrame (rAF is throttled/paused in background tabs). - this.renderLoop.cancel(); - + const parts = this.openParts(); + if (cols === this.cols && rows === this.rows) return; + // The loop stops while the engine reallocates its buffers, which detaches the typed + // array views a frame reads, and starts again after. A flag cleared by an animation + // frame would stall in background tabs, where frames are throttled. + this.core.renderLoop.cancel(); try { - // Update dimensions this.cols = cols; this.rows = rows; - - // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views). - // The device-pixel cell size goes in the same call, so a change of grid and - // pixels is one size report. (webtty, ADR 042) + // The device-pixel cell size goes in the same call, so a change of grid and pixels + // is one size report. (webtty, ADR 042) const px = this.cellPixelSize(); - this.wasmTerm!.resize(cols, rows, px?.width, px?.height); - - // Resize renderer - this.renderer!.resize(cols, rows); - - // Update canvas dimensions. The element draws nothing: the renderer draws on a - // canvas of its own (webtty, ADR 043) - const metrics = this.renderer!.getMetrics(); - this.canvas!.style.width = `${metrics.width * cols}px`; - this.canvas!.style.height = `${metrics.height * rows}px`; - - // Fire resize event - this.resizeEmitter.fire({ cols, rows }); - + parts.engine.resize(cols, rows, px?.width, px?.height); + parts.renderer.resize(cols, rows); + sizeCanvasElement(parts.canvas, parts.renderer.getMetrics(), cols, rows); + this.emit.resize.fire({ cols, rows }); // A mode 2048 size report from the resize, sent after the PTY resize message // (webtty, ADR 042) - this.writes.drainReplies(); - - // Force full render, the scrollbar at its current opacity (webtty, ADR 043) - this.renderer!.render(this.wasmTerm!, true, this.viewportY, this, this.scrollbarOpacity); + this.core.writes.drainReplies(); + // Every row, the scrollbar at its current opacity (webtty, ADR 043) + this.renderFrame(true); } catch (e) { console.error('Terminal resize failed:', e); } - - this.renderLoop.start(); + this.core.renderLoop.start(); } /** @@ -665,111 +380,71 @@ export class Terminal implements ITerminalCore { const px = this.cellPixelSize(); if (!this.wasmTerm || !px) return; this.wasmTerm.setCellPixelSize(px.width, px.height); - this.writes.drainReplies(); + this.core.writes.drainReplies(); } /** The renderer's cell in whole device pixels, or null before open(). (webtty) */ private cellPixelSize(): { width: number; height: number } | null { - if (!this.renderer) return null; - const metrics = this.renderer.getMetrics(); - const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; - return { - width: deviceCellPixels(metrics.width, dpr), - height: deviceCellPixels(metrics.height, dpr), - }; + return this.renderer ? cellDevicePixels(this.renderer.getMetrics()) : null; } - /** - * Reset terminal state - */ + /** Starts over on a new engine: the title, focus reports and a waiting clear reset. */ reset(): void { - this.assertOpen(); - - // Free old WASM terminal and create new one - if (this.wasmTerm) { - this.wasmTerm.free(); - } - const config = toEngineConfig(this.options); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + const parts = this.openParts(); + parts.engine.free(); + parts.engine = this.ghostty!.createTerminal(this.cols, this.rows, toEngineConfig(this.options)); this.syncCellPixelSize(); - - // Clear renderer - this.renderer!.clear(); - - // Reset title + parts.renderer.clear(); this.currentTitle = ''; // The new engine starts with mode 1004 off (webtty, ADR 053) - this.focusTracker.reset(); + this.core.focus.reset(); // A clear waiting for the old engine's output is not for the new one (webtty, ADR 054) - this.writes.reset(); + this.core.writes.reset(); // A search goes on in the new engine (webtty, ADR 055) - this.finder.restart(); + this.core.finder.restart(); } /** - * Focus terminal input + * Focuses the terminal for keyboard and wheel input, now and once more after the DOM + * settles, which some browsers need for the focus to stick. */ focus(): void { - if (this.isOpen && this.element) { - // Focus immediately for immediate keyboard/wheel event handling - this.element.focus(); - - // Also schedule a delayed focus as backup to ensure it sticks - // (some browsers may need this if DOM isn't fully settled) - setTimeout(() => { - this.element?.focus(); - }, 0); - } + if (!this.isOpen || !this.element) return; + this.element.focus(); + setTimeout(() => this.element?.focus(), 0); } - /** - * Load an addon - */ + /** Activates an addon; dispose() disposes it. */ loadAddon(addon: ITerminalAddon): void { addon.activate(this); this.addons.push(addon); } - /** - * Get the selected text as a string - */ + /** The selected text, or '' with none. */ public getSelection(): string { - return this.selectionManager?.getSelection() || ''; + return this.parts?.selection.getSelection() || ''; } - /** - * Clear the current selection - */ + /** Clears the selection. */ public clearSelection(): void { - this.selectionManager?.clearSelection(); + this.parts?.selection.clearSelection(); } - /** - * Copy the current selection to clipboard - * @returns true if there was text to copy, false otherwise - */ + /** Copies the selection to the clipboard; true when there was text to copy. */ public copySelection(): boolean { - return this.selectionManager?.copySelection() || false; + return this.parts?.selection.copySelection() || false; } - /** - * Get the current viewport Y position. - * - * This is the number of lines scrolled back from the bottom of the - * scrollback buffer. It may be fractional during smooth scrolling. - */ + /** viewportY, as a method (xterm.js compatibility). */ public getViewportY(): number { return this.viewportY; } - /** - * Attach a custom wheel event handler - * Returns true to prevent default handling - */ + /** Sets the embedder's wheel handler; when it returns true, the terminal does nothing. */ public attachCustomWheelEventHandler( - customWheelEventHandler?: (event: WheelEvent) => boolean + customWheelEventHandler?: (event: WheelEvent) => boolean, ): void { - this.wheel.setCustomHandler(customWheelEventHandler); + this.core.wheel.setCustomHandler(customWheelEventHandler); } /** @@ -778,7 +453,7 @@ export class Terminal implements ITerminalCore { * Called once for each such event. (webtty, ADR 045) */ public attachWheelPace(pace?: (event: WheelEvent) => Pace): void { - this.wheel.setPace(pace); + this.core.wheel.setPace(pace); } /** @@ -789,63 +464,32 @@ export class Terminal implements ITerminalCore { this.beforeRender = callback; } - /** - * Scroll viewport by a number of lines - * @param amount Number of lines to scroll (positive = down, negative = up) - */ + /** Scrolls the view by `amount` lines: positive is down, negative up into history. */ public scrollLines(amount: number): void { - if (!this.wasmTerm) { - throw new Error('Terminal not open'); - } - this.viewport.scrollLines(amount); + if (!this.wasmTerm) throw new Error('Terminal not open'); + this.core.viewport.scrollLines(amount); } - /** Scroll viewport to the bottom (current output) */ + /** Scrolls the view to the bottom, the current output. */ public scrollToBottom(): void { - this.viewport.scrollToBottom(); + this.core.viewport.scrollToBottom(); } - /** - * Scroll viewport to a specific line in the buffer - * @param line Lines back from the bottom (0 = the bottom, scrollbackLength = the top) - */ + /** Scrolls the view to `line` lines back from the bottom (the scrollback length is the top). */ public scrollToLine(line: number): void { - this.viewport.scrollToLine(line); + this.core.viewport.scrollToLine(line); } - /** - * Dispose terminal and clean up resources - */ + /** Disposes the addons, the parts and the emitters. Calling it again does nothing. */ dispose(): void { - if (this.isDisposed) { - return; - } - + if (this.isDisposed) return; this.isDisposed = true; this.isOpen = false; - - // Stop render loop - this.renderLoop.cancel(); - - // Dispose addons - for (const addon of this.addons) { - addon.dispose(); - } + this.core.renderLoop.cancel(); + for (const addon of this.addons) addon.dispose(); this.addons = []; - - // Clean up components - this.cleanupComponents(); - - // Dispose event emitters - this.dataEmitter.dispose(); - this.resizeEmitter.dispose(); - this.bellEmitter.dispose(); - this.titleChangeEmitter.dispose(); - this.scrollEmitter.dispose(); - this.pasteRequestEmitter.dispose(); - this.clipboardWriteEmitter.dispose(); - this.desktopNotificationEmitter.dispose(); - this.findChangeEmitter.dispose(); + this.cleanupComponents(this.parts); + this.emit.dispose(); } /** @@ -853,7 +497,7 @@ export class Terminal implements ITerminalCore { * sent while there was none were dropped. (webtty, ADR 053) */ resyncFocus(): void { - this.focusTracker.resync(); + this.core.focus.resync(); } /** @@ -861,388 +505,92 @@ export class Terminal implements ITerminalCore { * padding box, or undefined before open(). (webtty) */ getCursorCellOffset(): { left: number; top: number } | undefined { - if (!this.canvas || !this.renderer || !this.wasmTerm) return undefined; - const metrics = this.renderer.getMetrics(); + const parts = this.parts; + if (!parts) return undefined; return cursorCellOffset( - this.wasmTerm.getCursor(), + parts.engine.getCursor(), this.viewportY, { cols: this.cols, rows: this.rows }, - metrics, - { left: this.canvas.offsetLeft, top: this.canvas.offsetTop } + parts.renderer.getMetrics(), + { left: parts.canvas.offsetLeft, top: parts.canvas.offsetTop }, ); } - /** - * Moves the textarea onto the cursor cell, one cell high, writing only styles - * that changed. macOS places the dictation microphone and IME candidate window - * by its caret, so it stays anchored before a composition starts too. (webtty) - */ + /** Keeps the input textarea on the cursor cell, for the IME and dictation (webtty). */ private anchorTextarea(): void { - const textarea = this.textarea; + const parts = this.parts; const spot = this.getCursorCellOffset(); - if (!textarea || !spot) return; - // The right-click copy flow repositions the textarea while it holds the - // selection; leave it alone until that flow restores it. - if (textarea.style.position === 'fixed' && textarea.style.pointerEvents === 'auto') return; - if (textarea.style.position !== 'absolute') textarea.style.position = 'absolute'; - setStylePx(textarea, 'left', spot.left); - setStylePx(textarea, 'top', spot.top); - setStylePx(textarea, 'width', 1); - setStylePx(textarea, 'height', this.renderer!.getMetrics().height); + if (!parts || !spot) return; + anchorTextarea(parts.textarea, spot, parts.renderer.getMetrics().height); } - /** - * Get a line from native WASM scrollback buffer - * Implements IScrollbackProvider - */ + /** A scrollback line's cells, `offset` lines from its top (IScrollbackProvider). */ public getScrollbackLine(offset: number): GhosttyCell[] | null { - if (!this.wasmTerm) return null; - return this.wasmTerm.getScrollbackLine(offset); + return this.wasmTerm?.getScrollbackLine(offset) ?? null; } - /** - * Get scrollback length from native WASM - * Implements IScrollbackProvider - */ + /** The number of scrollback lines, 0 before open() (IScrollbackProvider). */ public getScrollbackLength(): number { - if (!this.wasmTerm) return 0; - return this.wasmTerm.getScrollbackLength(); + return this.wasmTerm?.getScrollbackLength() ?? 0; } - /** - * Get the full grapheme string for a scrollback cell - * Implements IScrollbackProvider - */ + /** A scrollback cell's whole grapheme (IScrollbackProvider). */ public getScrollbackGraphemeString(offset: number, col: number): string { - if (!this.wasmTerm) return ' '; - return this.wasmTerm.getScrollbackGraphemeString(offset, col); + return this.wasmTerm?.getScrollbackGraphemeString(offset, col) ?? ' '; } /** - * Clean up components (called on dispose or error) + * Takes down what open() made, on dispose or after a failed open(): `parts` may be + * partial. The listeners marked as leaking in the listener snapshot stay (ADR 065). */ - private cleanupComponents(): void { - // Dispose selection manager - if (this.selectionManager) { - this.selectionManager.dispose(); - this.selectionManager = undefined; - } - - // Dispose input handler - if (this.inputHandler) { - this.inputHandler.dispose(); - this.inputHandler = undefined; - } - - // Dispose renderer - if (this.renderer) { - this.renderer.dispose(); - this.renderer = undefined; - } - - // Remove canvas from DOM - if (this.canvas && this.canvas.parentNode) { - this.canvas.parentNode.removeChild(this.canvas); - this.canvas = undefined; - } - - // Remove textarea from DOM - if (this.textarea && this.textarea.parentNode) { - this.textarea.parentNode.removeChild(this.textarea); - this.textarea = undefined; - } - - // Remove event listeners; the wheel's queued ticks and the mouse's throttled move go - // with theirs - this.wheel.dispose(); - this.mouse.dispose(); - if (this.element) { - this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true }); - this.focusTracker.dispose(); - - // Remove contenteditable and accessibility attributes added in open() - this.element.removeAttribute('contenteditable'); - this.element.removeAttribute('role'); - this.element.removeAttribute('aria-label'); - this.element.removeAttribute('aria-multiline'); + private cleanupComponents(parts: Partial<OpenedParts> | undefined): void { + this.core.detach(); + if (parts?.element) { + this.core.focus.dispose(); + unlabelContainer(parts.element); } - - // Remove document-level listeners (only if opened) + // Never true here: both callers clear isOpen first (ADR 065) if (this.isOpen && typeof document !== 'undefined') { - document.removeEventListener('mouseup', this.handleMouseUp); - this.focusTracker.detachWindow(); - } - - // Clean up scrollbar timers - if (this.scrollbarHideTimeout) { - window.clearTimeout(this.scrollbarHideTimeout); - this.scrollbarHideTimeout = undefined; - } - - // Dispose link detector - if (this.linkDetector) { - this.linkDetector.dispose(); - this.linkDetector = undefined; + this.core.scrollbar.unwatchRelease(); + this.core.focus.detachWindow(); } - - // Free WASM terminal - if (this.wasmTerm) { - this.wasmTerm.free(); - this.wasmTerm = undefined; - } - - // Clear references + if (parts) disposeParts(parts); + this.parts = undefined; this.ghostty = undefined; - this.element = undefined; - this.textarea = undefined; } - /** - * Assert terminal is open (throw if not) - */ - private assertOpen(): void { - if (this.isDisposed) { - throw new Error('Terminal has been disposed'); - } - if (!this.isOpen) { + /** The parts of an open terminal; throws before open() and after dispose(). */ + private openParts(): OpenedParts { + if (this.isDisposed) throw new Error('Terminal has been disposed'); + if (!this.isOpen || !this.parts) { throw new Error('Terminal must be opened before use. Call terminal.open(parent) first.'); } + return this.parts; } /** - * Input from the InputHandler. Keys and text clear the selection and scroll to the - * bottom; a mouse report leaves both alone, so a Shift selection outlives the app's - * hover and clicks. (webtty, ADR 051, ADR 055) + * Renders the current view now, if open, with the scrollbar at `opacity` (its current + * one by default); `force` repaints every row. */ - private handleInput = (data: string, mouseReport = false): void => { - if (this.options.disableStdin) return; - if (!mouseReport) { - this.selectionManager?.clearSelection(); - // Ghostty's `scroll-to-bottom = keystroke`, which output no longer does while a - // match is selected (webtty, ADR 055) - this.scrollToBottom(); - } - this.writes.awaitEcho(); - this.dataEmitter.fire(data); - }; - - /** - * Handle mouse down for scrollbar interaction - */ - private handleMouseDown = (e: MouseEvent): void => { - if (!this.canvas || !this.renderer || !this.wasmTerm) return; - - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - if (scrollbackLength === 0) return; // No scrollbar if no scrollback - - const rect = this.canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - // The scrollbar is painted over the rows in view (webtty, ADR 043) - const mouseY = e.clientY - rect.top - this.renderer.scrollOffset; - - // Check if click is in scrollbar area: the renderer's geometry, in the rect's CSS pixels - if (inScrollbarColumn(mouseX, rect.width)) { - // Prevent default and stop propagation to prevent text selection - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event - - // Calculate scrollbar thumb position and size - const trackHeight = scrollbarTrackHeight(rect.height); - const thumbHeight = scrollbarThumbHeight(this.rows, scrollbackLength, trackHeight); - const thumbY = scrollbarThumbY(this.viewportY, scrollbackLength, trackHeight, thumbHeight); - - // Check if click is on thumb - if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) { - // Start dragging thumb - this.isDraggingScrollbar = true; - this.scrollbarDragStart = mouseY; - this.scrollbarDragStartViewportY = this.viewportY; - - // Prevent text selection during drag - if (this.canvas) { - this.canvas.style.userSelect = 'none'; - this.canvas.style.webkitUserSelect = 'none'; - } - } else { - // Click on track - jump to position - const relativeY = mouseY - SCROLLBAR_PADDING; - const scrollFraction = 1 - relativeY / trackHeight; // Inverted: top = 1, bottom = 0 - const targetViewportY = Math.round(scrollFraction * scrollbackLength); - this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY))); - } - } - }; - - /** - * Handle mouse up for scrollbar drag - */ - private handleMouseUp = (): void => { - if (this.isDraggingScrollbar) { - this.isDraggingScrollbar = false; - this.scrollbarDragStart = null; - - // Restore text selection - if (this.canvas) { - this.canvas.style.userSelect = ''; - this.canvas.style.webkitUserSelect = ''; - } - - // Schedule auto-hide after drag ends - if (this.scrollbarVisible && this.getScrollbackLength() > 0) { - this.showScrollbar(); // Reset the hide timer - } - } - }; - - /** - * Process scrollbar drag movement - */ - private processScrollbarDrag(e: MouseEvent): void { - if (!this.canvas || !this.renderer || !this.wasmTerm || this.scrollbarDragStart === null) - return; - - const scrollbackLength = this.wasmTerm.getScrollbackLength(); - if (scrollbackLength === 0) return; - - const rect = this.canvas.getBoundingClientRect(); - const mouseY = e.clientY - rect.top - this.renderer.scrollOffset; - - // Calculate how much the mouse moved - const deltaY = mouseY - this.scrollbarDragStart; - - // Convert mouse delta to viewport delta - // Use rect height which is already in CSS pixels - const trackHeight = scrollbarTrackHeight(rect.height); - const thumbHeight = scrollbarThumbHeight(this.rows, scrollbackLength, trackHeight); - - // Calculate scroll fraction from thumb movement - // Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases) - const scrollFraction = -deltaY / (trackHeight - thumbHeight); - const viewportDelta = Math.round(scrollFraction * scrollbackLength); - - const newViewportY = this.scrollbarDragStartViewportY + viewportDelta; - this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY))); - } - - /** - * Show scrollbar with fade-in and schedule auto-hide - */ - private showScrollbar(): void { - // Clear any existing hide timeout - if (this.scrollbarHideTimeout) { - window.clearTimeout(this.scrollbarHideTimeout); - this.scrollbarHideTimeout = undefined; - } - - // If not visible, start fade-in - if (!this.scrollbarVisible) { - this.scrollbarVisible = true; - this.scrollbarOpacity = 0; - this.fadeInScrollbar(); - } else { - // Already visible, just ensure it's fully opaque - this.scrollbarOpacity = 1; - } - - // Schedule auto-hide (unless dragging) - if (!this.isDraggingScrollbar) { - this.scrollbarHideTimeout = window.setTimeout(() => { - this.hideScrollbar(); - }, SCROLLBAR_HIDE_DELAY_MS); - } - } - - /** - * Hide scrollbar with fade-out - */ - private hideScrollbar(): void { - if (this.scrollbarHideTimeout) { - window.clearTimeout(this.scrollbarHideTimeout); - this.scrollbarHideTimeout = undefined; - } - - if (this.scrollbarVisible) { - this.fadeOutScrollbar(); - } - } - - /** - * Fade in scrollbar - */ - private fadeInScrollbar(): void { - const startTime = Date.now(); - const animate = () => { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / SCROLLBAR_FADE_DURATION_MS, 1); - this.scrollbarOpacity = progress; - - // Trigger render to show updated opacity - if (this.renderer && this.wasmTerm) { - this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); - } - - if (progress < 1) { - requestAnimationFrame(animate); - } - }; - animate(); - } - - /** - * Fade out scrollbar - */ - private fadeOutScrollbar(): void { - const startTime = Date.now(); - const startOpacity = this.scrollbarOpacity; - const animate = () => { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / SCROLLBAR_FADE_DURATION_MS, 1); - this.scrollbarOpacity = startOpacity * (1 - progress); - - // Trigger render to show updated opacity - if (this.renderer && this.wasmTerm) { - this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); - } - - if (progress < 1) { - requestAnimationFrame(animate); - } else { - this.scrollbarVisible = false; - this.scrollbarOpacity = 0; - // Final render to clear scrollbar completely - if (this.renderer && this.wasmTerm) { - this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0); - } - } - }; - animate(); + private renderFrame(force: boolean, opacity = this.core.scrollbar.opacity): void { + const parts = this.parts; + if (parts) parts.renderer.render(parts.engine, force, this.viewportY, this, opacity); } /** Fires onTitleChange when the title differs from the current one. */ private setTitle(title: string): void { if (title === this.currentTitle) return; this.currentTitle = title; - this.titleChangeEmitter.fire(title); + this.emit.titleChange.fire(title); } - /** - * Query terminal mode state - * - * @param mode Mode number (e.g., 2004 for bracketed paste) - * @param isAnsi True for ANSI modes, false for DEC modes (default: false) - * @returns true if mode is enabled - */ + /** Whether a mode is on: `isAnsi` for an ANSI mode, else DEC private (2004: bracketed paste). */ public getMode(mode: number, isAnsi: boolean = false): boolean { - this.assertOpen(); - return this.wasmTerm!.getMode(mode, isAnsi); + return this.openParts().engine.getMode(mode, isAnsi); } - /** - * Check if mouse tracking is enabled - */ + /** Whether the app tracks the mouse. */ public hasMouseTracking(): boolean { - this.assertOpen(); - return this.wasmTerm!.hasMouseTracking(); + return this.openParts().engine.hasMouseTracking(); } } diff --git a/src/client/vendor/tests/core/dom-setup.test.ts b/src/client/vendor/tests/core/dom-setup.test.ts new file mode 100644 index 0000000..e99d941 --- /dev/null +++ b/src/client/vendor/tests/core/dom-setup.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { recordListeners } from '../../../test-support/recording-target'; +import { + anchorTextarea, + createInputTextarea, + createTerminalCanvas, + focusTextareaOnPress, + labelContainer, + makeContainerEditable, + sizeCanvasElement, + unlabelContainer, +} from '../../ghostty-web/core/dom-setup'; + +// The DOM Terminal.open() builds, on a happy-dom document. + +let dom: DomWindow; +afterEach(async () => { + await dom.close(); +}); + +function setup() { + dom = createDomWindow(); + const container = dom.document.createElement('div'); + dom.document.body.appendChild(container); + return { document: dom.document, window: dom.window, container }; +} + +describe('the container', () => { + test('becomes focusable, contenteditable and labelled; unlabelling keeps the tabindex', () => { + const { container } = setup(); + makeContainerEditable(container); + labelContainer(container); + const attributes = () => + Object.fromEntries([...container.attributes].map((a) => [a.name, a.value])); + expect(attributes()).toEqual({ + tabindex: '0', + contenteditable: 'true', + role: 'textbox', + 'aria-label': 'Terminal input', + 'aria-multiline': 'true', + }); + unlabelContainer(container); + expect(attributes()).toEqual({ tabindex: '0' }); + }); + + test('a tabindex already set is kept', () => { + const { container } = setup(); + container.setAttribute('tabindex', '-1'); + makeContainerEditable(container); + expect(container.getAttribute('tabindex')).toBe('-1'); + }); + + test("the browser's own editing of the container is cancelled, a child's is not", () => { + const { container, document, window } = setup(); + const child = document.createElement('span'); + container.appendChild(child); + makeContainerEditable(container); + const own = new window.InputEvent('beforeinput', { cancelable: true, bubbles: true }); + container.dispatchEvent(own); + const childs = new window.InputEvent('beforeinput', { cancelable: true, bubbles: true }); + child.dispatchEvent(childs); + expect([own.defaultPrevented, childs.defaultPrevented]).toEqual([true, false]); + }); +}); + +describe('the canvas and textarea', () => { + test('the canvas is a block with the default arrow', () => { + const { document } = setup(); + const canvas = createTerminalCanvas(document); + expect([canvas.tagName, canvas.style.display, canvas.style.cursor]).toEqual([ + 'CANVAS', + 'block', + 'default', + ]); + }); + + test('the textarea is focusable, unassisted and clipped away', () => { + const { document } = setup(); + const textarea = createInputTextarea(document); + expect( + ['autocorrect', 'autocapitalize', 'spellcheck', 'tabindex', 'aria-label'].map((name) => + textarea.getAttribute(name), + ), + ).toEqual(['off', 'off', 'false', '0', 'Terminal input']); + const { style } = textarea; + expect([style.position, style.width, style.height, style.opacity, style.clipPath]).toEqual([ + 'absolute', + '1px', + '1px', + '0', + 'inset(50%)', + ]); + expect([style.overflow, style.whiteSpace, style.resize]).toEqual(['hidden', 'nowrap', 'none']); + }); + + test('a press or a tap on the canvas focuses the textarea, default prevented', () => { + const { document, window, container } = setup(); + const canvas = createTerminalCanvas(document); + const textarea = createInputTextarea(document); + container.append(canvas, textarea); + const recorded = recordListeners(canvas, 'canvas'); + focusTextareaOnPress(canvas, textarea); + expect(recorded.added.map((r) => r.type)).toEqual(['mousedown', 'touchend']); + const press = new window.MouseEvent('mousedown', { cancelable: true }); + canvas.dispatchEvent(press); + expect([press.defaultPrevented, document.activeElement]).toEqual([true, textarea]); + textarea.blur(); + const tap = new window.Event('touchend', { cancelable: true }); + canvas.dispatchEvent(tap); + expect([tap.defaultPrevented, document.activeElement]).toEqual([true, textarea]); + }); + + test('sizeCanvasElement sizes the element to the grid', () => { + const { document } = setup(); + const canvas = createTerminalCanvas(document); + sizeCanvasElement(canvas, { width: 8, height: 16 }, 80, 24); + expect([canvas.style.width, canvas.style.height]).toEqual(['640px', '384px']); + }); +}); + +describe('anchorTextarea', () => { + test('moves the textarea onto the cell, one cell high', () => { + const { document } = setup(); + const textarea = createInputTextarea(document); + anchorTextarea(textarea, { left: 16, top: 32 }, 16); + const { style } = textarea; + expect([style.position, style.left, style.top, style.width, style.height]).toEqual([ + 'absolute', + '16px', + '32px', + '1px', + '16px', + ]); + }); + + test('puts back an absolute position the textarea lost', () => { + const { document } = setup(); + const textarea = createInputTextarea(document); + textarea.style.position = 'fixed'; + anchorTextarea(textarea, { left: 0, top: 0 }, 16); + expect(textarea.style.position).toBe('absolute'); + }); + + test('leaves the textarea alone while the right-click copy flow holds it', () => { + const { document } = setup(); + const textarea = createInputTextarea(document); + textarea.style.position = 'fixed'; + textarea.style.pointerEvents = 'auto'; + textarea.style.left = '300px'; + anchorTextarea(textarea, { left: 16, top: 32 }, 16); + expect([textarea.style.position, textarea.style.left]).toEqual(['fixed', '300px']); + }); +}); diff --git a/src/client/vendor/tests/core/opened-parts.test.ts b/src/client/vendor/tests/core/opened-parts.test.ts new file mode 100644 index 0000000..51b4af7 --- /dev/null +++ b/src/client/vendor/tests/core/opened-parts.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { type DisposableParts, disposeParts } from '../../ghostty-web/core/opened-parts'; + +// The teardown of what Terminal.open() made: the whole set, or the part a failed open() +// got to. + +let dom: DomWindow; +afterEach(async () => { + await dom.close(); +}); + +/** Every part on fakes that log their teardown, the DOM ones in a happy-dom container. */ +function parts() { + dom = createDomWindow(); + const log: string[] = []; + const container = dom.document.createElement('div'); + const canvas = dom.document.createElement('canvas'); + const textarea = dom.document.createElement('textarea'); + container.append(canvas, textarea); + const disposable = (name: string) => ({ dispose: () => void log.push(name) }); + const all: DisposableParts = { + element: container, + engine: { free: () => void log.push('engine') }, + canvas, + textarea, + renderer: disposable('renderer'), + input: disposable('input'), + selection: disposable('selection'), + links: disposable('links'), + }; + return { all, log, container }; +} + +describe('disposeParts', () => { + test('disposes each part in order and takes the canvas and textarea out', () => { + const { all, log, container } = parts(); + disposeParts(all); + expect(log).toEqual(['selection', 'input', 'renderer', 'links', 'engine']); + expect(container.children.length).toBe(0); + }); + + test('skips the parts a failed open() never made', () => { + const { all, log, container } = parts(); + disposeParts({ element: all.element, engine: all.engine, canvas: all.canvas }); + expect(log).toEqual(['engine']); + expect([...container.children].map((c) => c.tagName)).toEqual(['TEXTAREA']); + }); + + test('a canvas already out of the DOM is left as it is', () => { + const { all, log, container } = parts(); + const canvas = container.firstChild; + canvas?.remove(); + disposeParts({ canvas: canvas ?? undefined, links: all.links }); + expect(log).toEqual(['links']); + }); +}); diff --git a/src/client/vendor/tests/core/option-changes.test.ts b/src/client/vendor/tests/core/option-changes.test.ts new file mode 100644 index 0000000..15838be --- /dev/null +++ b/src/client/vendor/tests/core/option-changes.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, spyOn, test } from 'bun:test'; +import { applyOptionChange, type OptionChangeHost } from '../../ghostty-web/core/option-changes'; +import type { TerminalOptionValues } from '../../ghostty-web/core/options-proxy'; + +// What an option set after open() changes, on a host that logs each call. + +const OPTIONS = { + cursorStyle: 'bar', + cursorBlink: true, + fontSize: 18, + fontFamily: 'serif', + cols: 100, + rows: 30, +} as const; + +function host(opened = true) { + const log: string[] = []; + const target: OptionChangeHost = { + options: () => OPTIONS, + engine: () => + opened + ? { setDefaultCursor: (style, blink) => void log.push(`cursor ${style} ${blink}`) } + : undefined, + renderer: () => + opened + ? { + setFontSize: (size) => void log.push(`size ${size}`), + setFontFamily: (family) => void log.push(`family ${family}`), + } + : undefined, + fontChanged: () => void log.push('font changed'), + resize: (cols, rows) => void log.push(`resize ${cols}x${rows}`), + }; + return { target, log }; +} + +describe('applyOptionChange', () => { + test.each<[keyof TerminalOptionValues, string[]]>([ + ['cursorStyle', ['cursor bar true']], + ['cursorBlink', ['cursor bar true']], + ['fontSize', ['size 18', 'font changed']], + ['fontFamily', ['family serif', 'font changed']], + ['cols', ['resize 100x30']], + ['rows', ['resize 100x30']], + ['disableStdin', []], + ['copyOnSelect', []], + ['mouseScrollSpeed', []], + ])('a change of %s applies %j', (key, applied) => { + const { target, log } = host(); + applyOptionChange(key, 'new', 'old', target); + expect(log).toEqual(applied); + }); + + test('setting the value an option already has does nothing', () => { + const { target, log } = host(); + applyOptionChange('fontSize', 18, 18, target); + expect(log).toEqual([]); + }); + + test('a theme change warns that it is not fully supported', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + const { target, log } = host(); + applyOptionChange('theme', {}, {}, target); + const warnings = warn.mock.calls.length; + warn.mockRestore(); + expect([warnings, log]).toEqual([1, []]); + }); + + test('without a renderer, font and theme changes do nothing; the size still resizes', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + const { target, log } = host(false); + for (const key of ['fontSize', 'fontFamily', 'theme', 'cursorStyle', 'cols'] as const) { + applyOptionChange(key, 1, 2, target); + } + const warnings = warn.mock.calls.length; + warn.mockRestore(); + expect([warnings, log]).toEqual([0, ['resize 100x30']]); + }); +}); diff --git a/src/client/vendor/tests/core/options-proxy.test.ts b/src/client/vendor/tests/core/options-proxy.test.ts index 4c0d9cc..4c71d43 100644 --- a/src/client/vendor/tests/core/options-proxy.test.ts +++ b/src/client/vendor/tests/core/options-proxy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { createOptionsProxy, parseThemeColor, + rendererOptions, TERMINAL_DEFAULTS, toEngineConfig, withDefaults, @@ -154,3 +155,17 @@ describe('toEngineConfig', () => { }); }); }); + +describe('rendererOptions', () => { + test("are the terminal's font, cursor and theme", () => { + const theme = { background: '#000' }; + const options = withDefaults({ ghostty: {} as never, fontSize: 18, cursorBlink: true, theme }); + expect(rendererOptions(options)).toEqual({ + fontSize: 18, + fontFamily: 'monospace', + cursorStyle: 'block', + cursorBlink: true, + theme, + }); + }); +}); diff --git a/src/client/vendor/tests/core/scrollbar-controller.test.ts b/src/client/vendor/tests/core/scrollbar-controller.test.ts new file mode 100644 index 0000000..6df2952 --- /dev/null +++ b/src/client/vendor/tests/core/scrollbar-controller.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { recordListeners } from '../../../test-support/recording-target'; +import type { ScrollbarHost } from '../../ghostty-web/core/host-types'; +import { + browserScrollbarClock, + ScrollbarController, +} from '../../ghostty-web/core/scrollbar-controller'; +import { + SCROLLBAR_FADE_DURATION_MS, + SCROLLBAR_HIDE_DELAY_MS, +} from '../../ghostty-web/core/terminal-constants'; +import { + scrollbarThumbHeight, + scrollbarThumbY, + scrollbarTrackHeight, + scrollbarX, +} from '../../ghostty-web/render/scrollbar-geometry'; + +// The scrollbar's press, drag and fade, on a happy-dom container and canvas 800 x 400 px at +// the page origin, a terminal of 24 rows over 100 lines of scrollback, and a hand-run clock. + +const RECT = { left: 0, top: 0, right: 800, bottom: 400, width: 800, height: 400 }; +const ROWS = 24; +const TRACK = scrollbarTrackHeight(RECT.height); +const THUMB = scrollbarThumbHeight(ROWS, 100, TRACK); +/** The middle of the scrollbar's column. */ +const COLUMN_X = scrollbarX(RECT.width) + 4; + +let dom: DomWindow | undefined; +afterEach(async () => { + await dom?.close(); + dom = undefined; +}); + +function scrollbar(options: { scrollback?: number; opened?: boolean; scrollOffset?: number } = {}) { + dom = createDomWindow(); + const { document, window } = dom; + const container = document.createElement('div'); + const canvas = document.createElement('canvas'); + container.appendChild(canvas); + document.body.appendChild(container); + canvas.getBoundingClientRect = () => RECT as DOMRect; + const clock = new ManualClock(); + const state = { viewportY: 0, scrollback: options.scrollback ?? 100 }; + const lines: number[] = []; + const renders: number[] = []; + const opened = options.opened ?? true; + const host: ScrollbarHost = { + canvas: () => (opened ? canvas : undefined), + scrollOffset: () => (opened ? (options.scrollOffset ?? 0) : undefined), + getScrollbackLength: () => state.scrollback, + rows: () => ROWS, + viewportY: () => state.viewportY, + scrollToLine: (line) => { + lines.push(line); + state.viewportY = line; + }, + render: (opacity) => void renders.push(opacity), + }; + const bar = new ScrollbarController(host, clock); + bar.attach(container); + bar.watchRelease(document); + // A listener added after the scrollbar's, as SelectionManager's is + const later: string[] = []; + container.addEventListener('mousedown', () => later.push('selection'), { capture: true }); + const press = (clientX: number, clientY: number) => { + const event = new window.MouseEvent('mousedown', { + clientX, + clientY, + bubbles: true, + cancelable: true, + }); + canvas.dispatchEvent(event); + return event; + }; + const move = (clientY: number) => + new window.MouseEvent('mousemove', { clientX: COLUMN_X, clientY }); + const release = () => document.dispatchEvent(new window.MouseEvent('mouseup')); + return { bar, clock, state, lines, renders, later, press, move, release, canvas }; +} + +describe('ScrollbarController press', () => { + test('a press outside its column is left to the others', () => { + const { press, lines, later } = scrollbar(); + const event = press(100, 200); + expect([event.defaultPrevented, lines, later]).toEqual([false, [], ['selection']]); + }); + + test('a press on the track jumps there and nobody else sees it', () => { + const { press, lines, later } = scrollbar(); + // The top of the track is the start of the scrollback + const event = press(COLUMN_X, 4); + expect([event.defaultPrevented, lines, later]).toEqual([true, [100], []]); + // Halfway down the track is halfway + press(COLUMN_X, 4 + TRACK / 2); + expect(lines).toEqual([100, 50]); + }); + + test('the press is measured from the rows in view, below the scroll offset (ADR 043)', () => { + const { press, lines } = scrollbar({ scrollOffset: 6 }); + press(COLUMN_X, 10); + expect(lines).toEqual([100]); + }); + + test('without scrollback, or before open(), a press does nothing', () => { + for (const options of [{ scrollback: 0 }, { opened: false }]) { + const { press, lines, later } = scrollbar(options); + press(COLUMN_X, 4); + expect([lines, later]).toEqual([[], ['selection']]); + } + }); +}); + +describe('ScrollbarController drag', () => { + test('a press on the thumb drags it, moving the view by the thumb travel', () => { + const { bar, press, move, release, lines, canvas } = scrollbar(); + const thumbY = scrollbarThumbY(0, 100, TRACK, THUMB); + press(COLUMN_X, thumbY + 10); + expect(lines).toEqual([]); + expect(canvas.style.userSelect).toBe('none'); + // A tenth of the travel up is a tenth of the scrollback back + expect(bar.drag(move(thumbY + 10 - (TRACK - THUMB) / 10))).toBe(true); + expect(lines).toEqual([10]); + // Past the end, the view stays at the bottom + bar.drag(move(thumbY + 500)); + expect(lines).toEqual([10, 0]); + release(); + expect(canvas.style.userSelect).toBe(''); + expect(bar.drag(move(0))).toBe(false); + }); + + test('a release with no drag, and a drag with nothing open, do nothing', () => { + const { bar, release, move, lines } = scrollbar({ opened: false }); + release(); + expect(bar.drag(move(0))).toBe(false); + expect(lines).toEqual([]); + }); + + test('a drag holds the scrollbar shown; the release hides it after the pause', () => { + const { bar, press, release, clock, renders } = scrollbar(); + bar.show(); + clock.frame(SCROLLBAR_FADE_DURATION_MS); + press(COLUMN_X, scrollbarThumbY(0, 100, TRACK, THUMB) + 1); + bar.show(); + clock.advance(SCROLLBAR_HIDE_DELAY_MS * 2); + expect(bar.opacity).toBe(1); + release(); + clock.advance(SCROLLBAR_HIDE_DELAY_MS); + clock.frame(SCROLLBAR_FADE_DURATION_MS); + expect([bar.opacity, renders[renders.length - 1]]).toEqual([0, 0]); + }); +}); + +describe('ScrollbarController fade', () => { + test('show() fades in over the fade time, rendering each frame', () => { + const { bar, clock, renders } = scrollbar(); + bar.show(); + expect([bar.opacity, renders]).toEqual([0, [0]]); + clock.frame(SCROLLBAR_FADE_DURATION_MS / 2); + expect(bar.opacity).toBe(0.5); + clock.frame(SCROLLBAR_FADE_DURATION_MS / 2); + expect(renders).toEqual([0, 0.5, 1]); + // The fade is over: no frame is waiting + expect(clock.pending.frames).toBe(0); + }); + + test('after the pause it fades out from where it is, then renders once more at 0', () => { + const { bar, clock, renders } = scrollbar(); + bar.show(); + clock.frame(SCROLLBAR_FADE_DURATION_MS); + renders.length = 0; + clock.advance(SCROLLBAR_HIDE_DELAY_MS - SCROLLBAR_FADE_DURATION_MS); + expect(renders).toEqual([1]); + clock.frame(SCROLLBAR_FADE_DURATION_MS / 2); + clock.frame(SCROLLBAR_FADE_DURATION_MS / 2); + expect(renders).toEqual([1, 0.5, 0, 0]); + expect(bar.opacity).toBe(0); + }); + + test('another scroll while shown keeps it opaque and starts the pause again', () => { + const { bar, clock } = scrollbar(); + bar.show(); + clock.frame(SCROLLBAR_FADE_DURATION_MS); + clock.advance(SCROLLBAR_HIDE_DELAY_MS - 300); + bar.show(); + clock.advance(SCROLLBAR_HIDE_DELAY_MS - 100); + expect(bar.opacity).toBe(1); + expect(clock.pending.frames).toBe(0); + clock.advance(100); + expect(clock.pending.frames).toBe(1); + }); + + test('a show during the fade in keeps fading in, then holds at 1', () => { + const { bar, clock } = scrollbar(); + bar.show(); + bar.show(); + clock.frame(SCROLLBAR_FADE_DURATION_MS); + expect(bar.opacity).toBe(1); + }); +}); + +describe('ScrollbarController teardown', () => { + test('dispose() removes the press and the pending hide; again does nothing', () => { + const { bar, clock, press, lines } = scrollbar(); + bar.show(); + clock.frame(SCROLLBAR_FADE_DURATION_MS); + bar.dispose(); + bar.dispose(); + press(COLUMN_X, 4); + expect(lines).toEqual([]); + clock.advance(SCROLLBAR_HIDE_DELAY_MS); + expect([bar.opacity, clock.pending]).toEqual([1, { timers: 0, frames: 0 }]); + }); + + test('the document mouseup stays after dispose() until unwatchRelease() (ADR 065)', () => { + dom = createDomWindow(); + const doc = recordListeners(dom.document, 'document'); + const bar = new ScrollbarController( + { + canvas: () => undefined, + scrollOffset: () => undefined, + getScrollbackLength: () => 0, + rows: () => 1, + viewportY: () => 0, + scrollToLine: () => {}, + render: () => {}, + }, + new ManualClock(), + ); + bar.unwatchRelease(); + bar.watchRelease(doc); + bar.dispose(); + expect(doc.active.map((r) => r.type)).toEqual(['mouseup']); + bar.unwatchRelease(); + expect(doc.active).toEqual([]); + }); +}); + +test('the browser clock reads the wall clock', () => { + const before = Date.now(); + expect(browserScrollbarClock.now()).toBeGreaterThanOrEqual(before); +}); diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts new file mode 100644 index 0000000..0f38624 --- /dev/null +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'bun:test'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { type ListenerRecord, RecordingTarget } from '../../../test-support/recording-target'; +import type { OpenedParts } from '../../ghostty-web/core/opened-parts'; +import { + type ControllersHost, + TerminalControllers, +} from '../../ghostty-web/core/terminal-controllers'; +import { TerminalEmitters } from '../../ghostty-web/core/terminal-emitters'; + +// Terminal's controllers as one unit: the order attach() adds their listeners in, what +// detach() takes away, and the input route. Each controller has its own tests; the +// wiring is exercised whole by vendor/tests/terminal.test.ts. + +function controllers(options: { disableStdin?: boolean } = {}) { + const log: string[] = []; + const clock = new ManualClock(); + const term: ControllersHost = { + options: { + disableStdin: options.disableStdin ?? false, + rightClickBehavior: 'contextMenu', + mouseScrollSpeed: 1, + }, + cols: 20, + rows: 5, + viewportY: 3, + getScrollbackLength: () => 10, + scrollToLine: (line) => void log.push(`scroll to ${line}`), + scrollToBottom: () => void log.push('scroll to bottom'), + }; + const emit = new TerminalEmitters(); + emit.data.event((data) => void log.push(`data ${JSON.stringify(data)}`)); + // The input route reaches the selection, the render loop the engine's replies + const parts = { + selection: { clearSelection: () => void log.push('clear selection') }, + engine: { hasResponse: () => false }, + }; + const core = new TerminalControllers( + term, + { + emit, + parts: () => parts as unknown as OpenedParts, + live: () => true, + beforeRender: () => {}, + setTitle: (title) => void log.push(`title ${title}`), + render: (force, opacity) => void log.push(`render ${force} ${opacity}`), + anchorTextarea: () => {}, + cellPixelSize: () => null, + }, + clock, + ); + return { core, log, emit, clock }; +} + +/** A container and a document logging into one timeline. */ +function targets() { + const timeline: ListenerRecord[] = []; + const container = new RecordingTarget('container'); + const doc = new RecordingTarget('document'); + container.timeline = timeline; + doc.timeline = timeline; + const rows = () => timeline.map((r) => `${r.target} ${r.type}${r.capture ? ' capture' : ''}`); + return { container, doc, rows }; +} + +describe('TerminalControllers attach and detach', () => { + test('attach() adds the listeners in the order open() always has', () => { + const { core } = controllers(); + const { container, doc, rows } = targets(); + core.attach(container as unknown as HTMLElement, doc); + expect(rows()).toEqual([ + 'container mousedown capture', + 'container mousemove', + 'container mouseleave', + 'container click', + 'container mousedown capture', + 'container keydown capture', + 'container contextmenu capture', + 'document mouseup', + 'container wheel capture', + ]); + }); + + test("detach() removes the container's listeners; the document's mouseup stays (ADR 065)", () => { + const { core } = controllers(); + const { container, doc } = targets(); + core.attach(container as unknown as HTMLElement, doc); + core.detach(); + core.detach(); + expect(container.active).toEqual([]); + expect(doc.active.map((r) => r.type)).toEqual(['mouseup']); + }); +}); + +describe('TerminalControllers input', () => { + // Keys and text clear the selection and scroll to the bottom; a mouse report leaves both, + // so a Shift selection outlives the app's hover and clicks (ADR 051, ADR 055). + test('a key clears the selection, scrolls to the bottom, then goes out', () => { + const { core, log } = controllers(); + core.input('a'); + expect(log).toEqual(['clear selection', 'scroll to bottom', 'data "a"']); + }); + + test('a mouse report only goes out', () => { + const { core, log } = controllers(); + core.input('\x1b[M !!', true); + expect(log).toEqual(['data "\\u001b[M !!"']); + }); + + test('nothing goes out with stdin disabled', () => { + const { core, log } = controllers({ disableStdin: true }); + core.input('a'); + expect(log).toEqual([]); + }); +}); + +describe('TerminalControllers wiring', () => { + test('a view move fires onScroll and shows the scrollbar, rendering it', () => { + const { core, log, emit } = controllers(); + emit.scroll.event((y) => void log.push(`onScroll ${y}`)); + core.viewport.scrollToLine(4); + expect(log).toEqual(['onScroll 4', 'render false 0']); + }); + + test('the render loop runs frames on the injected clock until stopped', () => { + const { core, log, clock } = controllers(); + core.renderLoop.start(); + clock.frame(); + core.renderLoop.cancel(); + clock.frame(); + expect(log).toEqual(['render false undefined', 'render false undefined']); + }); + + test('a mouse move goes to a scrollbar drag only while there is one', () => { + const { core } = controllers(); + expect(core.scrollbar.drag({ clientY: 0 } as MouseEvent)).toBe(false); + }); +}); diff --git a/src/client/vendor/tests/core/terminal-emitters.test.ts b/src/client/vendor/tests/core/terminal-emitters.test.ts new file mode 100644 index 0000000..8276b0b --- /dev/null +++ b/src/client/vendor/tests/core/terminal-emitters.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { TerminalEmitters } from '../../ghostty-web/core/terminal-emitters'; + +// Terminal's emitters, one per onX event, disposed together. + +const NAMES = [ + 'data', + 'resize', + 'bell', + 'titleChange', + 'scroll', + 'pasteRequest', + 'clipboardWrite', + 'desktopNotification', + 'findChange', +] as const; + +describe('TerminalEmitters', () => { + test('each emitter calls its own listeners', () => { + const emit = new TerminalEmitters(); + const heard: string[] = []; + for (const name of NAMES) emit[name].event(() => heard.push(name)); + emit.bell.fire(); + emit.data.fire('x'); + expect(heard).toEqual(['bell', 'data']); + }); + + test('dispose() drops every listener of every emitter; again does nothing', () => { + const emit = new TerminalEmitters(); + const heard: string[] = []; + for (const name of NAMES) emit[name].event(() => heard.push(name)); + emit.dispose(); + emit.dispose(); + for (const name of NAMES) (emit[name].fire as (arg?: unknown) => void)(); + expect(heard).toEqual([]); + }); +}); diff --git a/src/client/vendor/tests/core/terminal-geometry.test.ts b/src/client/vendor/tests/core/terminal-geometry.test.ts index 0e53d99..962f97b 100644 --- a/src/client/vendor/tests/core/terminal-geometry.test.ts +++ b/src/client/vendor/tests/core/terminal-geometry.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'bun:test'; import { + cellDevicePixels, cursorCellOffset, deviceCellPixels, linkViewportRange, mouseCursorFor, mouseSurface, + pageDevicePixelRatio, pointerCell, setStylePx, viewportRowToBufferRow, @@ -162,6 +164,30 @@ describe('deviceCellPixels', () => { }); }); +describe('cellDevicePixels', () => { + test('is both sides of the cell in whole device pixels', () => { + expect(cellDevicePixels({ width: 9.6, height: 19.2 }, 1.25)).toEqual({ width: 12, height: 24 }); + }); + + test("defaults to the page's ratio, which is 1 without a window", () => { + expect(pageDevicePixelRatio()).toBe(1); + expect(cellDevicePixels({ width: 8, height: 16 })).toEqual({ width: 8, height: 16 }); + }); + + test("reads the window's ratio, 1 when it reports none", () => { + const g = globalThis as Record<string, unknown>; + const saved = g.window; + try { + g.window = { devicePixelRatio: 2 }; + expect(pageDevicePixelRatio()).toBe(2); + g.window = { devicePixelRatio: 0 }; + expect(pageDevicePixelRatio()).toBe(1); + } finally { + g.window = saved; + } + }); +}); + describe('mouseSurface', () => { // A 9.6 x 19.2 px CSS cell at DPR 1.25 is 12 x 24 device pixels. const grid = { left: 110, top: 57, cols: 100, rows: 30, cellWidth: 9.6, cellHeight: 19.2 }; diff --git a/src/client/vendor/tests/core/viewport.test.ts b/src/client/vendor/tests/core/viewport.test.ts index a408fd3..f461eaa 100644 --- a/src/client/vendor/tests/core/viewport.test.ts +++ b/src/client/vendor/tests/core/viewport.test.ts @@ -82,3 +82,32 @@ describe('Viewport', () => { expect(calls).toEqual([]); }); }); + +describe('Viewport.jumpToPrompt', () => { + // Ghostty's jump_to_prompt (ADR 057): the engine finds the row, the view puts it on top. + /** An engine whose prompts are on buffer rows `rows`, as promptJumpRow reports them. */ + const engine = (rows: number[], calls: string[]) => ({ + promptJumpRow: (top: number, delta: number) => { + calls.push(`from ${top} by ${delta}`); + const above = rows.filter((r) => (delta < 0 ? r < top : r > top)); + const target = delta < 0 ? above[above.length + delta] : above[delta - 1]; + return target ?? top; + }, + }); + + test('moves a prompt to the top row, from the whole row the view starts on', () => { + const { view, calls } = viewport(17); + view.y = 2.5; + view.jumpToPrompt(-1, engine([0, 7, 14], calls)); + // The top row is 17 - floor(2.5) = 15; prompt 14 is two rows of scrollback further back + expect(calls).toEqual(['from 15 by -1', 'scroll 3', 'show']); + expect(view.y).toBe(3); + }); + + test('stays where it is with no prompt to go to', () => { + const { view, calls } = viewport(17); + view.jumpToPrompt(1, engine([0, 7, 14], calls)); + expect(calls).toEqual(['from 17 by 1']); + expect(view.y).toBe(0); + }); +}); diff --git a/src/client/vendor/tests/support/fake-event-target.ts b/src/client/vendor/tests/support/fake-event-target.ts index 9f74ea7..f19d9be 100644 --- a/src/client/vendor/tests/support/fake-event-target.ts +++ b/src/client/vendor/tests/support/fake-event-target.ts @@ -13,6 +13,8 @@ export class FakeElement extends RecordingTarget { parentNode: FakeElement | null = null; width = 0; height = 0; + offsetLeft = 0; + offsetTop = 0; /** Set on a canvas only: a context that draws nothing. */ getContext?: () => CanvasRenderingContext2D; private readonly attributes = new Map<string, string>(); @@ -64,6 +66,15 @@ export class FakeElement extends RecordingTarget { focus(): void { this.ownerDocument.activeElement = this; } + + contains(node: unknown): boolean { + return node === this || this.children.some((child) => child.contains(node)); + } + + /** An 800 × 400 box at the page's top-left corner. */ + getBoundingClientRect() { + return { left: 0, top: 0, right: 800, bottom: 400, width: 800, height: 400 }; + } } /** diff --git a/src/client/vendor/tests/support/terminal-harness.ts b/src/client/vendor/tests/support/terminal-harness.ts new file mode 100644 index 0000000..7ab73f3 --- /dev/null +++ b/src/client/vendor/tests/support/terminal-harness.ts @@ -0,0 +1,129 @@ +// A whole vendored Terminal opened on fakes (test support, ADR 064): the fake document, +// window and container of the listener snapshot, the real wasm engine and a manual clock +// for frames and timeouts, so a test drives the terminal through its public API only. The +// vendored code reads globals; they are swapped in by openTerminal and put back by close(). + +import { ManualClock } from '../../../test-support/manual-clock'; +import type { ListenerRecord } from '../../../test-support/recording-target'; +import type { GhosttyTerminal } from '../../ghostty-web/ghostty'; +import type { ITerminalOptions } from '../../ghostty-web/interfaces'; +import type { CanvasRenderer } from '../../ghostty-web/renderer'; +import { Terminal } from '../../ghostty-web/terminal'; +import { FakeDocument, FakeElement, FakeWindow } from './fake-event-target'; +import { rowText, scrollbackText } from './terminal-text'; +import { loadGhostty } from './wasm'; + +/** An open terminal on fakes, what it sent through onData, and its teardown. */ +export interface TerminalHarness { + term: Terminal; + container: FakeElement; + doc: FakeDocument; + win: FakeWindow; + /** Runs the render loop's frames (`clock.frame()`) and the timeouts set on the window. */ + clock: ManualClock; + /** Everything onData fired, in order. */ + sent: string[]; + /** The terminal's canvas element, made by open(). */ + canvas: FakeElement; + /** Disposes the terminal (if the test did not) and restores the globals. */ + close(): void; +} + +const GLOBALS = ['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'] as const; + +/** The fakes in the vendored code's globals, and the call that puts the real ones back. */ +export interface StubbedGlobals { + doc: FakeDocument; + win: FakeWindow; + clock: ManualClock; + /** A new container element in the fake document. */ + element(): FakeElement; + restore(): void; +} + +/** + * Puts a fake document and window, and the clock's animation frames, in the globals the + * vendored code reads; the window's timeouts run on the clock too. + */ +export function stubGlobals(): StubbedGlobals { + const g = globalThis as Record<string, unknown>; + const saved = GLOBALS.map((key) => [key, g[key]] as const); + const timeline: ListenerRecord[] = []; + const doc = new FakeDocument(timeline); + const win = new FakeWindow(timeline); + const clock = new ManualClock(1000); + // The scrollbar's auto-hide and the mouse throttle run on the window's timeouts + Object.assign(win, { setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout }); + g.document = doc; + g.window = win; + g.requestAnimationFrame = clock.requestAnimationFrame; + g.cancelAnimationFrame = clock.cancelAnimationFrame; + return { + doc, + win, + clock, + element: () => new FakeElement('container', 'div', doc), + restore: () => { + for (const [key, value] of saved) g[key] = value; + }, + }; +} + +/** Opens a terminal on fakes with `options` over a fresh engine; 20 × 5 cells by default. */ +export async function openTerminal( + options: Omit<ITerminalOptions, 'ghostty'> = {}, +): Promise<TerminalHarness> { + const ghostty = await loadGhostty(); + const globals = stubGlobals(); + const container = globals.element(); + const term = new Terminal({ ghostty, cols: 20, rows: 5, ...options }); + const sent: string[] = []; + term.onData((data) => sent.push(data)); + try { + term.open(container as unknown as HTMLElement); + } catch (error) { + globals.restore(); + throw error; + } + const canvas = container.children.find((child) => child.tagName === 'canvas'); + if (!canvas) throw new Error('open() made no canvas'); + return { + term, + container, + doc: globals.doc, + win: globals.win, + clock: globals.clock, + sent, + canvas, + close() { + term.dispose(); + globals.restore(); + }, + }; +} + +/** The text of the engine's visible rows, as `rowText` reads them. */ +export function screenText(term: Terminal): string[] { + return Array.from({ length: term.rows }, (_, y) => rowText(engineOf(term), y)); +} + +/** The text of every scrollback line, oldest first. */ +export function scrollbackLines(term: Terminal): string[] { + return Array.from({ length: term.getScrollbackLength() }, (_, i) => + scrollbackText(engineOf(term), i), + ); +} + +/** The open terminal's engine; throws before open() and after dispose(). */ +export function engineOf(term: Terminal): GhosttyTerminal { + const engine = term.wasmTerm; + if (!engine) throw new Error('the terminal is not open'); + return engine; +} + +/** The open terminal's renderer; throws before open() and after dispose(). */ +export function rendererOf(term: Terminal): CanvasRenderer { + const renderer = term.renderer; + if (!renderer) throw new Error('the terminal is not open'); + return renderer; +} diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index 6229de4..ab65c9b 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -1,28 +1,75 @@ -import { describe, expect, test } from 'bun:test'; -import type { TerminalEffects } from '../ghostty-web/ghostty'; -import { InputHandler } from '../ghostty-web/input-handler'; +import { afterEach, describe, expect, test } from 'bun:test'; +import type { ITerminalAddon } from '../ghostty-web/interfaces'; import { Terminal } from '../ghostty-web/terminal'; -import { loadGhostty } from './support/wasm'; +import { + engineOf, + openTerminal, + rendererOf, + screenText, + scrollbackLines, + stubGlobals, + type TerminalHarness, +} from './support/terminal-harness'; +import { markedSession } from './support/terminal-text'; + +// The Terminal facade through its public API, opened on fakes over the real engine. Its +// collaborators have their own tests in core/ (ADR 061). + +let h: TerminalHarness | undefined; + +/** Opens the terminal this test uses; afterEach closes it. */ +async function open(options: Parameters<typeof openTerminal>[0] = {}): Promise<TerminalHarness> { + h = await openTerminal(options); + return h; +} + +afterEach(() => { + h?.close(); + h = undefined; +}); + +/** A printable key as the browser delivers it to the container. */ +function keydown(key: string) { + return { + type: 'keydown', + key, + code: `Key${key.toUpperCase()}`, + keyCode: key.toUpperCase().charCodeAt(0), + isComposing: false, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: () => {}, + }; +} describe('Terminal options', () => { - // The configured cursor shape is the engine's default, which DECSCUSR Ps 0 and RIS - // restore; the output is not scanned (ADR 049). - function cursorTerm() { + test('fills every option it is not given with its default', () => { + const term = new Terminal({ ghostty: {} as never, fontSize: 20 }); + expect([term.options.fontSize, term.options.fontFamily, term.cols, term.rows]).toEqual([ + 20, + 'monospace', + 80, + 24, + ]); + expect(term.options.rightClickBehavior).toBe('contextMenu'); + }); + + test('a change before open() is kept and applied by nothing', () => { const term = new Terminal({ ghostty: {} as never }); - const defaults: unknown[] = []; - const internals = term as unknown as { isOpen: boolean; wasmTerm: object }; - internals.wasmTerm = { - setDefaultCursor: (style: string, blink: boolean) => defaults.push([style, blink]), - }; - return { term, defaults, internals }; - } + term.options.cursorStyle = 'bar'; + term.options.cols = 100; + expect([term.options.cursorStyle, term.cols]).toEqual(['bar', 80]); + }); - test('a cursor option change after open() sets the engine default', () => { - const { term, defaults, internals } = cursorTerm(); - internals.isOpen = true; + test('a cursor option change after open() sets the engine default', async () => { + // The configured shape is the engine's default, which DECSCUSR 0 and RIS restore (ADR 049) + const { term } = await open(); + const defaults: unknown[] = []; + engineOf(term).setDefaultCursor = (style, blink) => void defaults.push([style, blink]); term.options.cursorStyle = 'underline'; term.options.cursorBlink = true; - // Setting the value it already has applies nothing term.options.cursorBlink = true; expect(defaults).toEqual([ ['underline', false], @@ -30,272 +77,363 @@ describe('Terminal options', () => { ]); }); - test('a change before open() is kept and applied by nothing', () => { - const { term, defaults } = cursorTerm(); - term.options.cursorStyle = 'bar'; - expect(term.options.cursorStyle).toBe('bar'); - expect(defaults).toEqual([]); + test('a size change after open() resizes, a font change re-sizes the canvas element', async () => { + const { term, canvas } = await open(); + const width = rendererOf(term).getMetrics().width; + term.options.cols = 30; + expect([term.cols, canvas.style.width]).toEqual([30, `${width * 30}px`]); + canvas.style.width = ''; + term.options.fontFamily = 'serif'; + expect(canvas.style.width).toBe(`${width * 30}px`); + canvas.style.width = ''; + term.options.fontSize = 20; + expect(canvas.style.width).toBe(`${rendererOf(term).getMetrics().width * 30}px`); }); +}); - test('fills every option it is not given with its default', () => { - const term = new Terminal({ ghostty: {} as never, fontSize: 20 }); - expect([term.options.fontSize, term.options.fontFamily, term.cols, term.rows]).toEqual([ - 20, - 'monospace', - 80, - 24, +describe('Terminal lifecycle', () => { + test('open() builds the DOM and its parts; dispose() takes them down', async () => { + const { term, container } = await open(); + // The terminal's canvas, the renderer's drawing canvas (ADR 043), the input textarea + expect(container.children.map((c) => c.tagName)).toEqual(['canvas', 'canvas', 'textarea']); + expect(container.hasAttribute('role')).toBe(true); + expect([term.element, term.textarea, term.wasmTerm, term.renderer].every(Boolean)).toBe(true); + term.dispose(); + expect(container.children).toEqual([]); + expect(container.hasAttribute('role')).toBe(false); + // The tabindex stays, as it always has + expect(container.hasAttribute('tabindex')).toBe(true); + expect([term.element, term.textarea, term.wasmTerm, term.renderer]).toEqual([ + undefined, + undefined, + undefined, + undefined, ]); }); - test('a right-click defaults to contextMenu', () => { - expect(new Terminal({ ghostty: {} as never }).options.rightClickBehavior).toBe('contextMenu'); + test('opening twice, or after dispose, throws; so does use before open', async () => { + const { term, container } = await open(); + const parent = container as unknown as HTMLElement; + expect(() => term.open(parent)).toThrow('already open'); + term.dispose(); + expect(() => term.open(parent)).toThrow('disposed'); + expect(() => term.write('x')).toThrow('disposed'); + const closed = new Terminal({ ghostty: {} as never }); + expect(() => closed.write('x')).toThrow('must be opened'); + expect(() => closed.getMode(1)).toThrow('must be opened'); + expect(() => closed.scrollLines(1)).toThrow('Terminal not open'); + }); + + test('a failed open() takes down what it made and throws', () => { + const globals = stubGlobals(); + try { + const container = globals.element(); + const term = new Terminal({ + ghostty: { + createTerminal: () => { + throw new Error('no engine'); + }, + } as never, + }); + expect(() => term.open(container as unknown as HTMLElement)).toThrow( + 'Failed to open terminal: Error: no engine', + ); + expect(container.children).toEqual([]); + expect(container.hasAttribute('role')).toBe(false); + expect(term.element).toBeUndefined(); + term.dispose(); + } finally { + globals.restore(); + } + }); + + test('addons are activated on load and disposed with the terminal, once', async () => { + const { term } = await open(); + const calls: string[] = []; + const addon: ITerminalAddon = { + activate: (t) => void calls.push(t === term ? 'activate' : 'other'), + dispose: () => void calls.push('dispose'), + }; + term.loadAddon(addon); + term.dispose(); + term.dispose(); + expect(calls).toEqual(['activate', 'dispose']); + }); + + test('focus() focuses the container now and again after a tick', async () => { + const { term, container, doc } = await open(); + const focused = () => doc.activeElement; + doc.activeElement = null; + term.focus(); + expect(focused()).toBe(container); + doc.activeElement = null; + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(focused()).toBe(container); + }); + + test('before open() the read-only API answers empty', () => { + const term = new Terminal({ ghostty: {} as never }); + expect(term.getScrollbackLength()).toBe(0); + expect(term.getScrollbackLine(0)).toBeNull(); + expect(term.getScrollbackGraphemeString(0, 0)).toBe(' '); + expect(term.getCursorCellOffset()).toBeUndefined(); + expect(term.getSelection()).toBe(''); + expect(term.copySelection()).toBe(false); + term.syncCellPixelSize(); + term.jumpToPrompt(-1); + term.focus(); + }); +}); + +describe('Terminal write, resize and reset', () => { + test('write() converts LF to CRLF with convertEol and runs its callback after a frame', async () => { + const { term, clock } = await open({ convertEol: true }); + let called = 0; + term.write('a\nb', () => called++); + expect(screenText(term).slice(0, 2)).toEqual(['a', 'b']); + expect(called).toBe(0); + clock.frame(); + expect(called).toBe(1); + }); + + test('resize() fires onResize and sizes the canvas; the same size does nothing', async () => { + const { term, canvas } = await open(); + const sizes: unknown[] = []; + term.onResize((size) => sizes.push(size)); + term.resize(20, 5); + term.resize(30, 8); + expect(sizes).toEqual([{ cols: 30, rows: 8 }]); + expect(engineOf(term).getDimensions()).toEqual({ cols: 30, rows: 8 }); + expect(canvas.style.width).toBe(`${rendererOf(term).getMetrics().width * 30}px`); + }); + + test('a resize sends the size report the app asked for (mode 2048)', async () => { + const { term, sent } = await open(); + term.write('\x1b[?2048h'); + sent.length = 0; + term.resize(30, 8); + expect(sent.join('')).toContain('\x1b[48;8;30;'); + }); + + test('reset() starts a new engine and reports the same title again', async () => { + const { term } = await open(); + const titles: string[] = []; + term.onTitleChange((t) => titles.push(t)); + term.write('hello\x1b]2;vim\x07\x1b]2;vim\x07'); + const before = term.wasmTerm; + term.reset(); + expect(term.wasmTerm).not.toBe(before); + expect(screenText(term)[0]).toBe(''); + term.write('\x1b]2;vim\x07'); + expect(titles).toEqual(['vim', 'vim']); + }); + + test('getMode and hasMouseTracking read the engine', async () => { + const { term } = await open(); + term.write('\x1b[?2004h\x1b[?1000h'); + expect([term.getMode(2004), term.getMode(4, true), term.hasMouseTracking()]).toEqual([ + true, + false, + true, + ]); + }); + + test('the cursor cell offset follows the cursor and the view', async () => { + const { term } = await open(); + const { width, height } = rendererOf(term).getMetrics(); + term.write('abc\r\nde'); + expect(term.getCursorCellOffset()).toEqual({ left: 2 * width, top: height }); + }); + + test('syncCellPixelSize sends a size report when only the pixels change', async () => { + const { term, sent } = await open(); + term.write('\x1b[?2048h'); + sent.length = 0; + term.syncCellPixelSize(); + // The same pixels: Ghostty makes no update + expect(sent).toEqual([]); }); }); describe('Terminal paste', () => { // Pastes are encoded by ghostty_terminal_paste and leave through onData (ADR 049). - async function pasteTerm(options: object = {}) { - const ghostty = await loadGhostty(); - const wasmTerm = ghostty.createTerminal(40, 10); - const term = new Terminal({ ghostty: {} as never, ...options }); - const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; - internals.wasmTerm = wasmTerm; - internals.isOpen = true; - const sent: string[] = []; - term.onData((data) => sent.push(data)); - return { term, wasmTerm, sent }; - } - test('sends the text as Ghostty encodes it for the mode', async () => { - const { term, wasmTerm, sent } = await pasteTerm(); + const { term, sent } = await open(); + sent.length = 0; term.paste('one\ntwo\x1b'); - wasmTerm.write('\x1b[?2004h'); + term.write('\x1b[?2004h'); term.paste('three'); expect(sent).toEqual(['one\rtwo ', '\x1b[200~three\x1b[201~']); - wasmTerm.free(); }); test('sends nothing with stdin disabled', async () => { - const { term, wasmTerm, sent } = await pasteTerm({ disableStdin: true }); + const { term, sent } = await open({ disableStdin: true }); term.paste('hello'); expect(sent).toEqual([]); - expect(wasmTerm.hasResponse()).toBe(false); - wasmTerm.free(); + expect(engineOf(term).hasResponse()).toBe(false); }); }); describe('Terminal clearScreen', () => { // Ghostty's clear_screen with what libghostty-vt has: the erasing is written to the // terminal, and only FF, at a prompt, goes to the app (ADR 054). - async function clearTerm(options: object = {}) { - const ghostty = await loadGhostty(); - const wasmTerm = ghostty.createTerminal(20, 5); - const term = new Terminal({ ghostty: {} as never, ...options }); - const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; - internals.wasmTerm = wasmTerm; - internals.isOpen = true; - const sent: string[] = []; - term.onData((data) => sent.push(data)); - const text = (cells: { codepoint: number }[] | null) => - (cells ?? []) - .map((c) => (c.codepoint ? String.fromCodePoint(c.codepoint) : ' ')) - .join('') - .trimEnd(); - const screen = () => Array.from({ length: 5 }, (_, y) => text(wasmTerm.getLine(y))); - const scrollback = () => - Array.from({ length: wasmTerm.getScrollbackLength() }, (_, i) => - text(wasmTerm.getScrollbackLine(i)), - ); - const fill = () => { - for (let i = 0; i < 8; i++) term.write(`line${i}\r\n`); - }; - return { term, wasmTerm, sent, screen, scrollback, fill }; - } + const fill = (term: Terminal) => { + for (let i = 0; i < 8; i++) term.write(`line${i}\r\n`); + }; test('away from a prompt, drops the scrollback and the rows above the cursor', async () => { - const { term, wasmTerm, sent, screen, scrollback, fill } = await clearTerm(); - fill(); + const { term, sent } = await open(); + fill(term); term.write('$ make'); expect(term.clearScreen()).toBe(true); - expect(screen()).toEqual(['$ make', '', '', '', '']); - expect(scrollback()).toEqual([]); - expect(wasmTerm.getCursor()).toMatchObject({ x: 6, y: 0 }); + expect(screenText(term)).toEqual(['$ make', '', '', '', '']); + expect(scrollbackLines(term)).toEqual([]); + expect(engineOf(term).getCursor()).toMatchObject({ x: 6, y: 0 }); // Output in progress carries on below the cursor's row term.write('\r\nok'); - expect(screen()).toEqual(['$ make', 'ok', '', '', '']); + expect(screenText(term)).toEqual(['$ make', 'ok', '', '', '']); expect(sent).toEqual([]); - wasmTerm.free(); }); test('with the cursor on the top row, drops only the scrollback', async () => { - const { term, wasmTerm, screen, scrollback, fill } = await clearTerm(); - fill(); + const { term } = await open(); + fill(term); term.write('\x1b[H'); expect(term.clearScreen()).toBe(true); - expect(screen()).toEqual(['line4', 'line5', 'line6', 'line7', '']); - expect(scrollback()).toEqual([]); - wasmTerm.free(); + expect(screenText(term)).toEqual(['line4', 'line5', 'line6', 'line7', '']); + expect(scrollbackLines(term)).toEqual([]); }); test('at an OSC 133 prompt, erases everything and sends FF for the shell to redraw', async () => { - const { term, wasmTerm, sent, screen, scrollback, fill } = await clearTerm(); - fill(); + const { term, sent } = await open(); + fill(term); term.write('\x1b]133;A\x07~ > \x1b]133;B\x07ls'); - expect(wasmTerm.cursorAtPrompt()).toBe(true); + expect(engineOf(term).cursorAtPrompt()).toBe(true); expect(term.clearScreen()).toBe(true); - expect(screen()).toEqual(['', '', '', '', '']); - expect(scrollback()).toEqual([]); + expect(screenText(term)).toEqual(['', '', '', '', '']); + expect(scrollbackLines(term)).toEqual([]); expect(sent).toEqual(['\x0c']); - wasmTerm.free(); }); test('sends no FF with stdin disabled', async () => { - const { term, wasmTerm, sent } = await clearTerm({ disableStdin: true }); + const { term, sent } = await open({ disableStdin: true }); term.write('\x1b]133;A\x07$ \x1b]133;B\x07'); expect(term.clearScreen()).toBe(true); expect(sent).toEqual([]); - wasmTerm.free(); }); test('does nothing on the alternate screen, where the key goes to the app', async () => { - const { term, wasmTerm, sent, screen, fill } = await clearTerm(); - fill(); + const { term, sent } = await open(); + fill(term); term.write('\x1b[?1049h\x1b[Hvim'); expect(term.clearScreen()).toBe(false); - expect(screen()[0]).toBe('vim'); + expect(screenText(term)[0]).toBe('vim'); term.write('\x1b[?1049l'); - expect(wasmTerm.getScrollbackLength()).toBe(4); + expect(term.getScrollbackLength()).toBe(4); expect(sent).toEqual([]); - wasmTerm.free(); }); test('clears the selection', async () => { - const { term, wasmTerm, fill } = await clearTerm(); - fill(); + const { term } = await open(); + fill(term); let cleared = 0; - (term as unknown as { selectionManager: object }).selectionManager = { - clearSelection: () => cleared++, - }; + term.clearSelection = () => void cleared++; term.clearScreen(); expect(cleared).toBe(1); - wasmTerm.free(); }); test('waits for a sequence cut off by the end of a write, and clears where it ends', async () => { - const { term, wasmTerm, screen, scrollback, fill } = await clearTerm(); - fill(); + const { term } = await open(); + fill(term); term.write('build \x1b[3'); expect(term.clearScreen()).toBe(true); - expect(scrollback().length).toBeGreaterThan(0); + expect(scrollbackLines(term).length).toBeGreaterThan(0); // The rest of the SGR finishes it; the clear goes in there, before the text after it term.write('1mred\r\nnext'); - expect(screen()).toEqual(['build red', 'next', '', '', '']); - expect(scrollback()).toEqual([]); - const red = wasmTerm.getLine(0)?.[6]; + expect(screenText(term)).toEqual(['build red', 'next', '', '', '']); + expect(scrollbackLines(term)).toEqual([]); + const red = engineOf(term).getLine(0)?.[6]; expect(red && red.fg_r > red.fg_g).toBe(true); - wasmTerm.free(); }); test('a cut-off OSC keeps its effect', async () => { - const { term, wasmTerm, scrollback, fill } = await clearTerm(); - fill(); + const { term } = await open(); + fill(term); const titles: string[] = []; term.onTitleChange((t) => titles.push(t)); term.write('\x1b]2;bui'); term.clearScreen(); term.write('ld\x07'); expect(titles).toEqual(['build']); - expect(scrollback()).toEqual([]); - wasmTerm.free(); + expect(scrollbackLines(term)).toEqual([]); }); test('a write that ends inside another sequence still gets the clear', async () => { - const { term, wasmTerm, screen, scrollback, fill } = await clearTerm(); - fill(); + const { term } = await open(); + fill(term); term.write('a\x1b[3'); term.clearScreen(); term.write('1mb\x1b[0'); - expect(scrollback()).toEqual([]); + expect(scrollbackLines(term)).toEqual([]); term.write('mc'); - expect(screen()).toEqual(['abc', '', '', '', '']); - wasmTerm.free(); + expect(screenText(term)).toEqual(['abc', '', '', '', '']); }); test('a waiting clear at a prompt checks the prompt and sends FF once it goes in', async () => { - const { term, wasmTerm, sent, screen, fill } = await clearTerm(); - fill(); + const { term, sent } = await open(); + fill(term); term.write('\x1b]133;A\x07$ \x1b]133;B\x07\x1b[3'); term.clearScreen(); expect(sent).toEqual([]); term.write('1m'); - expect(screen()).toEqual(['', '', '', '', '']); + expect(screenText(term)).toEqual(['', '', '', '', '']); expect(sent).toEqual(['\x0c']); - wasmTerm.free(); }); test('a waiting clear is dropped if the screen has turned alternate', async () => { - const { term, wasmTerm, scrollback, fill } = await clearTerm(); - fill(); + const { term } = await open(); + fill(term); term.write('\x1b[?1049'); term.clearScreen(); term.write('h\x1b[Hvim'); term.write('\x1b[?1049l'); - expect(scrollback().length).toBeGreaterThan(0); - wasmTerm.free(); + expect(scrollbackLines(term).length).toBeGreaterThan(0); }); }); describe('Terminal find', () => { // Find with libghostty-vt's search, run in the render loop (ADR 055). async function findTerm() { - const ghostty = await loadGhostty(); - const term = new Terminal({ ghostty, cols: 20, rows: 4, scrollbackLines: 1000 }); + const harness = await open({ rows: 4, scrollbackLines: 1000 }); + const { term, clock } = harness; const highlights: unknown[] = []; - const renderer = { - clear: () => {}, - getMetrics: () => ({ width: 8, height: 16, baseline: 12 }), - devicePixelRatio: 1, - setSearchHighlights: (h: unknown) => highlights.push(h), - render: () => {}, - }; - const internals = term as unknown as { - isOpen: boolean; - wasmTerm: ReturnType<typeof ghostty.createTerminal>; - ghostty: typeof ghostty; - renderer: typeof renderer; - showScrollbar: () => void; - finder: { step(now: number): void }; - }; - internals.wasmTerm = ghostty.createTerminal(20, 4, { scrollbackLines: 1000 }); - internals.ghostty = ghostty; - internals.renderer = renderer; - internals.isOpen = true; - internals.showScrollbar = () => {}; - let now = 0; - /** Render-loop frames: the search steps before each. */ - const frames = (n = 1) => { - for (let i = 0; i < n; i++) { - now += 1000; - internals.finder.step(now); - } + const renderer = rendererOf(term); + const setSearchHighlights = renderer.setSearchHighlights.bind(renderer); + renderer.setSearchHighlights = (hl) => { + highlights.push(hl); + setSearchHighlights(hl); }; const states: unknown[] = []; term.onFindChange((s) => states.push(s)); - const last = () => - highlights[highlights.length - 1] as { - matches: Int32Array; - selected: unknown; - } | null; - return { term, internals, frames, states, last }; + /** Render-loop frames, a second apart: the search steps before each render. */ + const frames = (n = 1) => { + for (let i = 0; i < n; i++) clock.frame(1000); + }; + return { ...harness, frames, states, last: () => highlights[highlights.length - 1] }; } - test('output keeps a scrolled view on its rows while a match is selected', async () => { - const { term, internals, frames } = await findTerm(); + const needleAbove = (term: Terminal) => { term.write('needle\r\n'); for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + }; + + test('output keeps a scrolled view on its rows while a match is selected', async () => { + const { term, frames } = await findTerm(); + needleAbove(term); term.find('needle'); frames(2); - term.findNext(); + expect(term.finding).toBe(true); + expect(term.findNext()).toBe(true); const view = term.viewportY; expect(view).toBeGreaterThan(0); term.write('more\r\nmore\r\nmore\r\n'); @@ -304,35 +442,28 @@ describe('Terminal find', () => { expect(term.viewportY).toBe(view + 3); // The view still starts at the match expect(term.getScrollbackLength() - term.viewportY).toBe(0); - internals.wasmTerm.free(); + expect(term.findPrevious()).toBe(true); }); test('without a selected match, output scrolls to the bottom as before', async () => { - const { term, internals, frames } = await findTerm(); + const { term, frames } = await findTerm(); for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); term.find('line'); frames(2); term.scrollToLine(5); term.write('x\r\n'); expect(term.viewportY).toBe(0); - internals.wasmTerm.free(); }); test('a key or a paste scrolls a view on a selected match to the bottom', async () => { // Ghostty's `scroll-to-bottom = keystroke` (ADR 055) - const { term, internals, frames } = await findTerm(); - const input = term as unknown as { handleInput: (data: string, mouse?: boolean) => void }; - term.write('needle\r\n'); - for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + const { term, frames, container } = await findTerm(); + needleAbove(term); term.find('needle'); frames(2); term.findNext(); - const view = term.viewportY; - expect(view).toBeGreaterThan(0); - // A mouse report leaves the view alone - input.handleInput('\x1b[M !!', true); - expect(term.viewportY).toBe(view); - input.handleInput('l'); + expect(term.viewportY).toBeGreaterThan(0); + container.dispatch(keydown('l')); expect(term.viewportY).toBe(0); // and the echo is followed term.write('l\r\n'); @@ -342,13 +473,11 @@ describe('Terminal find', () => { expect(term.viewportY).toBeGreaterThan(0); term.paste('ls'); expect(term.viewportY).toBe(0); - internals.wasmTerm.free(); }); test('output that switches to the alternate screen scrolls to the bottom', async () => { - const { term, internals, frames } = await findTerm(); - term.write('needle\r\n'); - for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); + const { term, frames } = await findTerm(); + needleAbove(term); term.find('needle'); frames(2); term.findNext(); @@ -357,11 +486,10 @@ describe('Terminal find', () => { expect(term.viewportY).toBe(0); frames(); expect(term.viewportY).toBe(0); - internals.wasmTerm.free(); }); test('ending the search clears the highlights and the count', async () => { - const { term, internals, frames, states, last } = await findTerm(); + const { term, frames, states, last } = await findTerm(); term.write('abc abc'); term.find('abc'); frames(); @@ -373,11 +501,11 @@ describe('Terminal find', () => { expect(states[states.length - 1]).toEqual({ total: 0, selected: null }); frames(); expect(last()).toBeNull(); - internals.wasmTerm.free(); + expect(term.findNext()).toBe(false); }); test('a reset (reconnect) searches the new engine', async () => { - const { term, internals, frames, states } = await findTerm(); + const { term, frames, states } = await findTerm(); term.write('abc'); term.find('abc'); frames(); @@ -388,205 +516,90 @@ describe('Terminal find', () => { term.write('abc abc'); frames(); expect(states[states.length - 1]).toEqual({ total: 2, selected: null }); - internals.wasmTerm.free(); }); }); -describe('Terminal bell and title', () => { - // Bell and title come from the engine's readEffects, not from scanning the output - // (ADR 047). - test('a bell and a title reported by readEffects reach onBell and onTitleChange', () => { - const term = new Terminal({ ghostty: {} as never }); - let effects: TerminalEffects = { - bell: true, - title: 'vim', - clipboard: null, - notifications: [], - }; - (term as unknown as { wasmTerm: object }).wasmTerm = { - write: () => {}, - hasResponse: () => false, - readResponse: () => null, - getMode: () => false, - readEffects: () => { - const e = effects; - effects = { bell: false, title: null, clipboard: null, notifications: [] }; - return e; - }, - }; - const write = (data: string) => - (term as unknown as { writes: { write(data: string): void } }).writes.write(data); +describe('Terminal output effects', () => { + // Bell, title, clipboard writes and notifications come from the engine's readEffects, + // not from scanning the output (ADR 047, ADR 052, ADR 056). + test('a bell and a new title reach onBell and onTitleChange, a repeated title does not', async () => { + const { term } = await open(); let bells = 0; const titles: string[] = []; term.onBell(() => bells++); term.onTitleChange((title) => titles.push(title)); - - write('\x1b]2;vim\x07\x07'); - expect(bells).toBe(1); - expect(titles).toEqual(['vim']); - // A BEL or OSC 2 the engine did not report fires nothing. - write('\x07\x1b]2;other\x07'); - expect(bells).toBe(1); - expect(titles).toEqual(['vim']); + term.write('\x1b]2;vim\x07\x07'); + term.write('\x1b]2;vim\x07'); + expect([bells, titles]).toEqual([1, ['vim']]); }); -}); - -describe('Terminal focus reports', () => { - // The focus tracker hears of each write and of a new connection (ADR 053); its own - // tests are in core/focus-tracker.test.ts. - test('reports when a write sets mode 1004, and on resyncFocus, the focus it has', async () => { - const ghostty = await loadGhostty(); - const wasmTerm = ghostty.createTerminal(20, 2); - const term = new Terminal({ ghostty: {} as never }); - const internals = term as unknown as { isOpen: boolean; wasmTerm: unknown }; - internals.wasmTerm = wasmTerm; - internals.isOpen = true; - const sent: string[] = []; - term.onData((data) => sent.push(data)); - term.resyncFocus(); - expect(sent).toEqual([]); - term.write('\x1b[?1004h'); - term.resyncFocus(); - expect(sent).toEqual(['\x1b[O', '\x1b[O']); - wasmTerm.free(); - }); -}); -describe('Terminal clipboard writes', () => { - // Program clipboard writes come from the engine's readEffects and reach the embedder - // through onClipboardWrite (ADR 052). test('OSC 52 output fires onClipboardWrite with the decoded text, once', async () => { - const ghostty = await loadGhostty(); - const term = new Terminal({ ghostty: {} as never }); - (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); - const write = (data: string) => - (term as unknown as { writes: { write(data: string): void } }).writes.write(data); + const { term } = await open(); const copied: string[] = []; term.onClipboardWrite((text) => copied.push(text)); - - write(`\x1b]52;c;${Buffer.from('héllo 中文').toString('base64')}\x07`); + term.write(`\x1b]52;c;${Buffer.from('héllo 中文').toString('base64')}\x07`); expect(copied).toEqual(['héllo 中文']); - write('plain output'); - write('\x1b]52;c;\x07'); + term.write('plain output'); + term.write('\x1b]52;c;\x07'); expect(copied).toEqual(['héllo 中文', '']); }); - test('dispose drops onClipboardWrite listeners', () => { - const term = new Terminal({ ghostty: {} as never }); - const copied: string[] = []; - term.onClipboardWrite((text) => copied.push(text)); - term.dispose(); - ( - term as unknown as { clipboardWriteEmitter: { fire(t: string): void } } - ).clipboardWriteEmitter.fire('x'); - expect(copied).toEqual([]); - }); -}); - -describe('Terminal desktop notifications', () => { - // OSC 9 and OSC 777 come from the engine's readEffects and reach the embedder through - // onDesktopNotification (ADR 056). test('OSC 9 and OSC 777 output fire onDesktopNotification, each once, in order', async () => { - const ghostty = await loadGhostty(); - const term = new Terminal({ ghostty: {} as never }); - (term as unknown as { wasmTerm: unknown }).wasmTerm = ghostty.createTerminal(20, 2); - const write = (data: string) => - (term as unknown as { writes: { write(data: string): void } }).writes.write(data); + const { term } = await open(); const shown: { title: string; body: string }[] = []; term.onDesktopNotification((n) => shown.push(n)); - - write('\x1b]9;Build done\x07\x1b]777;notify;Claude Code;Needs your input\x1b\\'); + term.write('\x1b]9;Build done\x07\x1b]777;notify;Claude Code;Needs your input\x1b\\'); expect(shown).toEqual([ { title: '', body: 'Build done' }, { title: 'Claude Code', body: 'Needs your input' }, ]); - write('plain output'); + term.write('plain output'); expect(shown).toHaveLength(2); }); + + test('reports when a write sets mode 1004, and on resyncFocus, the focus it has', async () => { + // The focus tracker's own tests are in core/focus-tracker.test.ts (ADR 053) + const { term, sent, doc } = await open(); + doc.activeElement = null; + sent.length = 0; + term.resyncFocus(); + expect(sent).toEqual([]); + term.write('\x1b[?1004h'); + term.resyncFocus(); + expect(sent).toEqual(['\x1b[O', '\x1b[O']); + }); }); -describe('Terminal input and the selection', () => { - // Keys and text clear the selection; mouse reports leave it, so a Shift selection in an - // app with any-motion mode 1003 survives the next hover or click (ADR 051). - test('a mouse report keeps the selection, a key clears it', async () => { - const ghostty = await loadGhostty(); - const term = new Terminal({ ghostty: {} as never }); - const internals = term as unknown as { selectionManager: unknown }; - let selection = 'hello'; - internals.selectionManager = { - clearSelection: () => { - selection = ''; - }, - }; - const sent: string[] = []; - term.onData((data) => sent.push(data)); - type Listener = (e: object) => void; - const listeners = new Map<string, Listener[]>(); - const container = { - ownerDocument: undefined, - contains: () => true, - addEventListener: (type: string, l: Listener) => - listeners.set(type, [...(listeners.get(type) ?? []), l]), - removeEventListener: () => {}, - }; - const fire = (type: string, e: object) => { - for (const l of listeners.get(type) ?? []) l(e); - }; - const handler = new InputHandler({ - ghostty, - container: container as unknown as HTMLElement, - onData: (term as unknown as { handleInput(d: string, m?: boolean): void }).handleInput, - mouseConfig: { hasMouseTracking: () => true, encode: () => '\x1b[<35;4;4M' }, - }); - const mods = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }; - fire('mousemove', { ...mods, target: container, buttons: 0, clientX: 1, clientY: 1 }); - fire('mousedown', { - ...mods, - target: container, - button: 0, - buttons: 1, - clientX: 1, - clientY: 1, - }); - expect(sent.length).toBe(2); - expect(selection).toBe('hello'); - fire('keydown', { - ...mods, - key: 'a', - code: 'KeyA', - isComposing: false, - preventDefault: () => {}, - }); - expect(sent[2]).toBe('a'); - expect(selection).toBe(''); - handler.dispose(); +describe('Terminal input', () => { + // The input route's own tests are in core/terminal-controllers.test.ts (ADR 051). + test('a key goes to onData and scrolls to the bottom', async () => { + const { term, sent, container } = await open(); + term.write('x\r\n'.repeat(10)); + term.scrollToLine(3); + container.dispatch(keydown('a')); + expect(sent).toContain('a'); + expect(term.viewportY).toBe(0); }); }); -describe('Terminal jumpToPrompt', () => { - // Ghostty's jump_to_prompt on libghostty-vt's DELTA_PROMPT, in webtty's view (ADR 057). - async function promptTerm() { - const ghostty = await loadGhostty(); - const term = new Terminal({ ghostty, cols: 20, rows: 5 }); - const internals = term as unknown as { - isOpen: boolean; - wasmTerm: ReturnType<typeof ghostty.createTerminal>; - showScrollbar: () => void; - }; - internals.wasmTerm = ghostty.createTerminal(20, 5); - internals.isOpen = true; - internals.showScrollbar = () => {}; - return { term, internals }; - } - const command = (name: string) => - `\x1b]133;A\x07$ \x1b]133;B\x07${name}\r\n\x1b]133;C\x07` + - Array.from({ length: 6 }, (_, i) => `${name} ${i}`).join('\r\n') + - '\r\n\x1b]133;D;0\x07'; +describe('Terminal scrolling', () => { + test('scrollLines, scrollToLine and scrollToBottom move the view and fire onScroll', async () => { + const { term } = await open(); + term.write('x\r\n'.repeat(10)); + const scrolls: number[] = []; + term.onScroll((y) => scrolls.push(y)); + term.scrollLines(-2); + term.scrollToLine(100); + term.scrollToBottom(); + expect(scrolls).toEqual([2, term.getScrollbackLength(), 0]); + expect(term.getViewportY()).toBe(0); + }); - test('scrolls a prompt to the top row, up and down, and back to the bottom', async () => { - const { term, internals } = await promptTerm(); + test('jumpToPrompt scrolls a prompt to the top row, up and down, and back to the bottom', async () => { + // Ghostty's jump_to_prompt on libghostty-vt's DELTA_PROMPT (ADR 057) + const { term } = await open(); // Prompts on rows 0, 7 and 14; 17 rows of scrollback - term.write(`${command('one')}${command('two')}${command('three')}\x1b]133;A\x07$ `); + term.write(markedSession()); const scrolls: number[] = []; term.onScroll((y) => scrolls.push(y)); term.jumpToPrompt(-1); @@ -601,16 +614,72 @@ describe('Terminal jumpToPrompt', () => { term.jumpToPrompt(2); expect(term.viewportY).toBe(0); expect(scrolls).toEqual([3, 10, 17, 10, 0]); - internals.wasmTerm.free(); }); - test('leaves a fractional view alone when there is nowhere to go', async () => { - const { term, internals } = await promptTerm(); + test('jumpToPrompt leaves a fractional view alone when there is nowhere to go', async () => { + const { term } = await open(); term.write(Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\r\n')); term.viewportY = 2.5; term.jumpToPrompt(-1); term.jumpToPrompt(1); expect(term.viewportY).toBe(2.5); - internals.wasmTerm.free(); + }); + + test('a scroll shows the scrollbar: later frames paint it, then it fades away', async () => { + const { term, clock } = await open(); + term.write('x\r\n'.repeat(10)); + const opacities: number[] = []; + const renderer = rendererOf(term); + const render = renderer.render.bind(renderer); + renderer.render = (...args) => { + opacities.push(args[4] ?? 1); + render(...args); + }; + term.scrollLines(-1); + expect(opacities.length).toBeGreaterThan(0); + clock.advance(5000); + for (let i = 0; i < 30; i++) clock.frame(50); + expect(opacities[opacities.length - 1]).toBe(0); + }); + + test("the embedder's wheel handler and pace, and the before-render callback, are used", async () => { + const { term, container, clock, sent } = await open(); + const wheel = (deltaY: number) => ({ + type: 'wheel', + deltaY, + deltaX: 0, + deltaMode: 1, + ctrlKey: false, + shiftKey: false, + clientX: 10, + clientY: 10, + preventDefault: () => {}, + stopPropagation: () => {}, + }); + const handled: number[] = []; + term.attachCustomWheelEventHandler((e) => handled.push(e.deltaY) === 1); + const paced: number[] = []; + term.attachWheelPace((e) => { + paced.push(e.deltaY); + return { duration: 0 }; + }); + // Taken by the handler + container.dispatch(wheel(3)); + // Left to the terminal: on the alternate screen, arrow keys at the pace given + term.write('\x1b[?1049h'); + container.dispatch(wheel(-2)); + expect([handled, paced]).toEqual([[3, -2], [-2]]); + expect(sent.join('')).toBe('\x1b[A\x1b[A'); + const frames: number[] = []; + term.attachBeforeRender((now) => frames.push(now)); + clock.frame(10); + expect(frames.length).toBe(1); + }); + + test('the scrollback provider reads the engine', async () => { + const { term } = await open(); + term.write('x\r\n'.repeat(10)); + expect(term.getScrollbackLine(0)?.[0].codepoint).toBe('x'.codePointAt(0)); + expect(term.getScrollbackGraphemeString(0, 0)).toBe('x'); }); }); From 032accfa7e6f3e1a9c2a5a437a1a5f5ecaa8002e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:30:37 -0400 Subject: [PATCH 253/295] docs(arch): the core/ tree lists the terminal's controllers, parts and option changes, and the terminal harness --- docs/specs/architecture.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 1351efd..c8b9545 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -163,7 +163,8 @@ src/ tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/, addons/) support/ wasm.ts (the one WASM path, a cached loadGhostty(), countedGhostty()), fake-canvas.ts, fake-event-target.ts, engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours), - selection-fixture.ts (a SelectionManager on a recording canvas and document) + selection-fixture.ts (a SelectionManager on a recording canvas and document), + terminal-harness.ts (a whole Terminal opened on the fakes, driven through its public API) fixtures/ PTY captures (was vendor/fixtures/) ghostty-web/ Biome-excluded; see "The vendored layer" scripts/ @@ -203,9 +204,11 @@ ghostty-web/ engine-effects.ts (FnTable callbacks, clipboard write, readTitle) grid-refs.ts (grid and screen refs, row wrap, hyperlinkAt) engine-config.ts (applyConfig, colour writes, the DECSCUSR blink) cursor-blink.ts text-mime.ts kitty-keyboard-reply.ts core/ Terminal's collaborators; import core/host-types.ts, never terminal.ts - host-types.ts terminal-constants.ts terminal-geometry.ts options-proxy.ts dom-setup.ts + host-types.ts terminal-constants.ts terminal-geometry.ts options-proxy.ts option-changes.ts + dom-setup.ts opened-parts.ts (OpenedParts, disposeParts) terminal-emitters.ts viewport.ts focus-tracker.ts find-controller.ts write-pipeline.ts render-loop.ts mouse-controller.ts wheel-controller.ts scrollbar-controller.ts + terminal-controllers.ts (the controllers above made and wired in one place, the input route) render/ renderable.ts default-theme.ts device-pixel-ratio.ts frame-lines.ts cell-range.ts row-band.ts font-metrics.ts cell-painter.ts row-repainter.ts cursor-painter.ts scrollbar-geometry.ts scrollbar-painter.ts scroll-tracker.ts decoration-tracker.ts viewport-clip.ts From c452e529c889395f6a1be5de6ca25db295321342 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:37:30 -0400 Subject: [PATCH 254/295] refactor(client): opened parts typed by narrow part interfaces; split parts may not import a facade core/opened-parts.ts imported the CanvasRenderer, InputHandler, SelectionManager and LinkDetector classes as types, a core -> facade edge the checker let through. OpenedParts now holds RendererPart, InputPart, SelectionPart and LinksPart (built on host-types' MouseRenderer, MenuSelection and LinkSource) and is generic over them; the facade passes its classes (TerminalParts), so terminal-controllers sees only the narrow interfaces. The import rule facade-value-import becomes facade-import: a split part may not import a facade at all, type or value. SPEC import table updated. --- docs/specs/architecture.md | 2 +- scripts/architecture/import-rules.test.ts | 15 ++++- scripts/architecture/import-rules.ts | 13 +++-- scripts/architecture/source-file.ts | 2 +- .../vendor/ghostty-web/core/opened-parts.ts | 56 ++++++++++++++----- src/client/vendor/ghostty-web/terminal.ts | 11 ++-- 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index c8b9545..516d0de 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -46,7 +46,7 @@ src/client: | App code (`src/client/**` outside `vendor/`) imports vendor code only through `vendor/ghostty-web` (the barrel), never a deep path. Tests may deep-import. | same | | `src/client/features/<a>/**` never imports `src/client/features/<b>/**`. Features reach each other only through handles the composition root passes in. | same | | `src/client/app/**` (except the composition root `app/start-client.ts`), `connection/**` and `layout/**` never import `src/client/features/**`: they sit below the features, so a feature importing them cannot reach another feature through them. | same | -| Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`), never the facade class as a value, so no runtime import cycle can form. | same (a value import of a facade, and any runtime cycle), plus review | +| Inside the vendored tree, the parts of a split facade (`engine/`, `core/`, `render/`, `input/`, `selection/`) import narrow interfaces (`engine/engine-types.ts`, `core/host-types.ts`, `render/renderable.ts`) with `import type`, never a facade (`ghostty.ts`, `terminal.ts`, `renderer.ts`, `input-handler.ts`, `selection-manager.ts`), not even as a type, so a part depends on no facade class and no runtime import cycle can form. The facade passes its classes where a part holds them (`OpenedParts<CanvasRenderer, …>`). | same (any import of a facade from a part, and any runtime cycle), plus review | | A file under a `test-support/` folder is never named `*.test.ts`, and no file imports a `*.test.ts` file. | same | | Test files (`*.test.ts`) may cross the client/server line (contract tests); the import rules above apply to source files only. | same | | The checker counts `.ts` files only, excluding `*.d.ts`; fixtures, the wasm, JSON, Markdown, HTML and CSS are not source. | same | diff --git a/scripts/architecture/import-rules.test.ts b/scripts/architecture/import-rules.test.ts index 28fce2d..5191c66 100644 --- a/scripts/architecture/import-rules.test.ts +++ b/scripts/architecture/import-rules.test.ts @@ -17,6 +17,7 @@ const TREE = [ file('src/client/vendor/ghostty-web/index.ts'), file('src/client/vendor/ghostty-web/glide.ts'), file('src/client/vendor/ghostty-web/terminal.ts'), + file('src/client/vendor/ghostty-web/renderer.ts'), file('src/client/vendor/ghostty-web/core/host-types.ts'), file('src/client/features/find/find-bar.ts'), file('src/helpers.test.ts'), @@ -163,12 +164,24 @@ describe('checkImports', () => { 'split part imports a facade', 'src/client/vendor/ghostty-web/core/a.ts', "import { Terminal } from '../terminal';", - ['facade-value-import'], + ['facade-import'], ], [ 'split part imports a facade type', 'src/client/vendor/ghostty-web/core/a.ts', "import type { Terminal } from '../terminal';", + ['facade-import'], + ], + [ + 'render part imports the renderer facade type', + 'src/client/vendor/ghostty-web/render/a.ts', + "import type { CanvasRenderer } from '../renderer';", + ['facade-import'], + ], + [ + 'a facade imports another facade', + 'src/client/vendor/ghostty-web/a.ts', + "import { CanvasRenderer } from './renderer';", [], ], [ diff --git a/scripts/architecture/import-rules.ts b/scripts/architecture/import-rules.ts index 40a99c7..ea9241d 100644 --- a/scripts/architecture/import-rules.ts +++ b/scripts/architecture/import-rules.ts @@ -36,7 +36,10 @@ interface ImportRule { broken(site: ImportSite): string | null; } -/** The facades a split vendor part must not import as a value (ADR 061: no runtime cycle). */ +/** + * The facades a split vendor part must not import, even as a type: a part sees the narrow + * interfaces, so it never depends on a facade class and no runtime cycle can form (ADR 061). + */ export const VENDOR_FACADES = new Set( ['ghostty.ts', 'terminal.ts', 'renderer.ts', 'input-handler.ts', 'selection-manager.ts'].map( (name) => `${VENDOR_ROOT}${name}`, @@ -140,11 +143,11 @@ export const IMPORT_RULES: ImportRule[] = [ : null, }, { - id: 'facade-value-import', + id: 'facade-import', appliesToTests: false, - broken: ({ from, ref, target }) => - VENDOR_PART_RE.test(from) && !ref.typeOnly && VENDOR_FACADES.has(target) - ? `imports the facade ${target} as a value; split parts import the narrow interfaces` + broken: ({ from, target }) => + VENDOR_PART_RE.test(from) && VENDOR_FACADES.has(target) + ? `imports the facade ${target}; split parts import the narrow interfaces` : null, }, { diff --git a/scripts/architecture/source-file.ts b/scripts/architecture/source-file.ts index 5ebee6b..3eefb12 100644 --- a/scripts/architecture/source-file.ts +++ b/scripts/architecture/source-file.ts @@ -22,7 +22,7 @@ export type RuleId = | 'vendor-deep' | 'feature-feature' | 'below-feature' - | 'facade-value-import' + | 'facade-import' | 'cycle' | 'kebab-case' | 'banned-name' diff --git a/src/client/vendor/ghostty-web/core/opened-parts.ts b/src/client/vendor/ghostty-web/core/opened-parts.ts index 1e04d2c..8f270a9 100644 --- a/src/client/vendor/ghostty-web/core/opened-parts.ts +++ b/src/client/vendor/ghostty-web/core/opened-parts.ts @@ -1,32 +1,62 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // What Terminal.open() creates (types + util): the parts held together while the terminal -// is open, and their teardown, which also takes down a set left partial by a failed -// open() (ADR 061). +// is open, seen through narrow interfaces so core/ never imports a facade class, and +// their teardown, which also takes down a set left partial by a failed open() (ADR 061). import type { GhosttyTerminal } from '../engine/engine-types'; -import type { InputHandler } from '../input-handler'; -import type { LinkDetector } from '../link-detector'; -import type { CanvasRenderer } from '../renderer'; -import type { SelectionManager } from '../selection-manager'; +import type { SearchHighlights } from '../search'; +import type { LinkSource, MenuSelection, MouseRenderer } from './host-types'; + +/** + * What the controllers reach of the CanvasRenderer: the mouse's reads, the find + * highlights and its teardown. + */ +export interface RendererPart extends MouseRenderer { + setSearchHighlights(highlights: SearchHighlights | null): void; + dispose(): void; +} + +/** What the controllers reach of the SelectionManager: the right-click calls and teardown. */ +export interface SelectionPart extends MenuSelection { + dispose(): void; +} + +/** The InputHandler, which only the teardown reaches. */ +export interface InputPart { + dispose(): void; +} + +/** What the controllers reach of the LinkDetector: lookups, the stale cache and teardown. */ +export interface LinksPart extends LinkSource { + /** The content changed: cached links may no longer be there. */ + invalidateCache(): void; + dispose(): void; +} /** * Everything open() creates, made in one place and held together, so a method that needs - * an open terminal takes them from one check instead of asserting each field. + * an open terminal takes them from one check instead of asserting each field. The facade + * passes the parts' classes; core/ sees only the interfaces, the defaults. */ -export interface OpenedParts { +export interface OpenedParts< + R extends RendererPart = RendererPart, + I extends InputPart = InputPart, + S extends SelectionPart = SelectionPart, + L extends LinksPart = LinksPart, +> { readonly element: HTMLElement; /** reset() replaces it with a new engine. */ engine: GhosttyTerminal; readonly canvas: HTMLCanvasElement; readonly textarea: HTMLTextAreaElement; - readonly renderer: CanvasRenderer; - readonly input: InputHandler; - readonly selection: SelectionManager; - readonly links: LinkDetector; + readonly renderer: R; + readonly input: I; + readonly selection: S; + readonly links: L; } /** The parts made so far while open() runs, noted one by one for a failure's teardown. */ -export type PartsMade = { -readonly [K in keyof OpenedParts]?: OpenedParts[K] }; +export type PartsMade<P extends OpenedParts = OpenedParts> = { -readonly [K in keyof P]?: P[K] }; /** The parts' teardown calls, narrowed so tests pass fakes. */ export type DisposableParts = { diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 20cd610..cc53e94 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -58,6 +58,9 @@ export { } from './core/terminal-geometry'; export { mouseReportText } from './input/mouse-report'; +/** The parts with their classes, for the facade's own calls and its public getters. */ +type TerminalParts = OpenedParts<CanvasRenderer, InputHandler, SelectionManager, LinkDetector>; + /** The xterm.js-compatible terminal: webtty's one terminal per page. */ export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) @@ -68,7 +71,7 @@ export class Terminal implements ITerminalCore { public readonly options: TerminalOptionValues; private ghostty?: Ghostty; - private parts?: OpenedParts; + private parts?: TerminalParts; private isOpen = false; private isDisposed = false; private addons: ITerminalAddon[] = []; @@ -192,7 +195,7 @@ export class Terminal implements ITerminalCore { if (this.isOpen) throw new Error('Terminal is already open'); if (this.isDisposed) throw new Error('Terminal has been disposed'); this.isOpen = true; - const made: PartsMade = { element: parent }; + const made: PartsMade<TerminalParts> = { element: parent }; try { const parts = this.createParts(parent, made); this.parts = parts; @@ -212,7 +215,7 @@ export class Terminal implements ITerminalCore { * open() has always made them (their listeners' order depends on it). Each part is noted * in `made` as it exists, so a failure part way cleans up what was made. */ - private createParts(parent: HTMLElement, made: PartsMade): OpenedParts { + private createParts(parent: HTMLElement, made: PartsMade<TerminalParts>): TerminalParts { makeContainerEditable(parent); // Focus is followed, and kept on the textarea, by the focus tracker (webtty, ADR 053) this.core.focus.attach(parent); @@ -560,7 +563,7 @@ export class Terminal implements ITerminalCore { } /** The parts of an open terminal; throws before open() and after dispose(). */ - private openParts(): OpenedParts { + private openParts(): TerminalParts { if (this.isDisposed) throw new Error('Terminal has been disposed'); if (!this.isOpen || !this.parts) { throw new Error('Terminal must be opened before use. Call terminal.open(parent) first.'); From 6576d01fdf116949026bdde48fb3fd5e24ec3e63 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:47:26 -0400 Subject: [PATCH 255/295] fix(client): release every listener on dispose (ADR 065) Terminal, its controllers, InputHandler and SelectionManager put every listener, timeout, interval and animation frame they add into a DisposableStore, and dispose() (or a failed open()) releases it. Removal no longer depends on isOpen, which dispose() had already cleared. Now released: the container beforeinput guard and the canvas mousedown and touchend focus listeners (OpenedParts.domListeners), the window focus and blur and document visibilitychange (FocusTracker), the scrollbar's document mouseup and fade frames, SelectionManager's canvas listeners and document mousedown, the context menu's reset timer and close listeners, the deferred second focus() (now FocusTracker.focusContainer), and write callbacks not yet run. The browser timeouts and the cursor blink interval are cleared on window, where they are set. InputHandler's no-op compositionupdate listener and handleCompositionUpdate are removed. The registration order, targets, event types and flags of every other listener are unchanged: the listener snapshot differs only in its removed column and the missing compositionupdate row. A new vendor/tests/core/dispose.test.ts checks that every add has a matching remove with the same options, that timers and frames are cancelled (also after a failed open()), and that a second dispose() is a no-op. --- src/client/vendor/ghostty-web/PROVENANCE.md | 2 + .../vendor/ghostty-web/core/dom-setup.ts | 44 ++++--- .../vendor/ghostty-web/core/focus-tracker.ts | 54 +++++---- .../vendor/ghostty-web/core/host-types.ts | 1 + .../ghostty-web/core/mouse-controller.ts | 4 +- .../vendor/ghostty-web/core/opened-parts.ts | 10 +- .../ghostty-web/core/scrollbar-controller.ts | 33 +++--- .../ghostty-web/core/terminal-controllers.ts | 11 +- .../vendor/ghostty-web/core/write-pipeline.ts | 23 +++- .../vendor/ghostty-web/input-handler.ts | 13 +-- .../ghostty-web/lifecycle/disposable-store.ts | 11 ++ .../ghostty-web/render/cursor-painter.ts | 4 +- .../vendor/ghostty-web/selection-manager.ts | 15 +-- .../selection/context-menu-textarea.ts | 53 +++++++-- .../selection/selection-gestures.ts | 49 ++++---- src/client/vendor/ghostty-web/terminal.ts | 25 ++-- src/client/vendor/tests/core/dispose.test.ts | 108 ++++++++++++++++++ .../vendor/tests/core/dom-setup.test.ts | 22 ++++ .../vendor/tests/core/focus-tracker.test.ts | 81 ++++++++++--- .../tests/core/listener-snapshot.test.ts | 68 ++++++----- .../tests/core/mouse-controller.test.ts | 11 +- .../tests/core/scrollbar-controller.test.ts | 59 +++++++--- .../tests/core/terminal-controllers.test.ts | 7 +- .../vendor/tests/core/write-pipeline.test.ts | 16 +++ src/client/vendor/tests/input-handler.test.ts | 4 +- .../tests/lifecycle/disposable-store.test.ts | 10 ++ .../selection/context-menu-textarea.test.ts | 43 ++++++- .../selection/selection-gestures.test.ts | 12 +- .../tests/selection/selection-manager.test.ts | 17 ++- .../vendor/tests/support/fake-event-target.ts | 31 ++++- .../vendor/tests/support/selection-fixture.ts | 2 +- src/client/vendor/tests/terminal.test.ts | 13 ++- 32 files changed, 628 insertions(+), 228 deletions(-) create mode 100644 src/client/vendor/tests/core/dispose.test.ts diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 238f129..3600c06 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -58,6 +58,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: the parts `open()` creates are held in one `OpenedParts` (`core/opened-parts.ts`) instead of eight optional fields; `element`, `textarea`, `wasmTerm` and `renderer` are read-only accessors over it, undefined before `open()` and after `dispose()` as the fields were. The methods that asserted each field (`this.wasmTerm!`) take the parts from one check (`openParts`, with `assertOpen`'s messages), and a failed `open()` takes down the parts it got to, as `cleanupComponents` did. The teardown disposes the controllers before the parts instead of between the textarea and the link detector; none of them reaches another while it is taken down (ADR 061). - `terminal.ts`: `open()` sends the cell's device pixels to the new engine without the reply drain `syncCellPixelSize` adds: on a new engine mode 2048 is off, so the call makes no reply and the drain had nothing to send (ADR 042, ADR 061). - `terminal.ts`: the scrollbar's fade reads an injected clock (`Date.now()` and the browser's frames and timeouts by default, as before), and the fade in and fade out are one `fade('in' | 'out')` with the same opacity formulas (ADR 061). +- `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). ## webtty-original files @@ -167,3 +168,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `terminal.ts`: the device-pixel maths of `cellPixelSize` (now `cellDevicePixels`, `pageDevicePixelRatio`) | `core/terminal-geometry.ts` | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | | `terminal.ts`: the body of `jumpToPrompt` (webtty, ADR 057) | `core/viewport.ts` (`Viewport.jumpToPrompt`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | | `terminal.ts`: what the scrollbar reaches of `Terminal` | `core/host-types.ts` (`ScrollbarClock`, `ScrollbarHost`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | +| `terminal.ts`: the second, deferred `element.focus()` of `focus()` | `core/focus-tracker.ts` (`FocusTracker.focusContainer`) | fix(client): release every listener on dispose (ADR 065) | diff --git a/src/client/vendor/ghostty-web/core/dom-setup.ts b/src/client/vendor/ghostty-web/core/dom-setup.ts index a227540..ad8699b 100644 --- a/src/client/vendor/ghostty-web/core/dom-setup.ts +++ b/src/client/vendor/ghostty-web/core/dom-setup.ts @@ -1,20 +1,25 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The DOM Terminal.open() builds and cleanupComponents takes down (util): the container's // attributes, the canvas element, the hidden input textarea, the canvas's press-to-focus, -// and the element sizing and textarea anchoring done after (ADR 061). +// and the element sizing and textarea anchoring done after (ADR 061, ADR 065). +import { + type Disposable, + DisposableStore, + listen, +} from '../lifecycle/disposable-store'; import { mouseCursorFor, setStylePx } from './terminal-geometry'; /** * Makes the container focusable and contenteditable, so browser extensions (Vimium and * the like) treat it as an input and leave its keys alone, while the browser's own editing - * of it is cancelled: input is handled by the terminal. + * of it is cancelled: input is handled by the terminal. The Disposable removes the + * cancelling listener (ADR 065). */ -export function makeContainerEditable(container: HTMLElement): void { +export function makeContainerEditable(container: HTMLElement): Disposable { if (!container.hasAttribute('tabindex')) container.setAttribute('tabindex', '0'); container.setAttribute('contenteditable', 'true'); - // leaks until ADR 065 - container.addEventListener('beforeinput', (e) => { + return listen(container, 'beforeinput', (e) => { if (e.target === container) e.preventDefault(); }); } @@ -76,22 +81,27 @@ export function createInputTextarea(doc: Document): HTMLTextAreaElement { /** * Focuses the textarea on a press (desktop) or a tap (mobile) on the canvas, with the - * default prevented first; on iOS that keeps the native caret away. + * default prevented first; on iOS that keeps the native caret away. The Disposable removes + * both listeners (ADR 065). */ export function focusTextareaOnPress( canvas: HTMLCanvasElement, textarea: HTMLTextAreaElement, -): void { - // leaks until ADR 065 - canvas.addEventListener('mousedown', (ev) => { - ev.preventDefault(); - textarea.focus(); - }); - // leaks until ADR 065 - canvas.addEventListener('touchend', (ev) => { - ev.preventDefault(); - textarea.focus(); - }); +): Disposable { + const listeners = new DisposableStore(); + listeners.add( + listen(canvas, 'mousedown', (ev) => { + ev.preventDefault(); + textarea.focus(); + }), + ); + listeners.add( + listen(canvas, 'touchend', (ev) => { + ev.preventDefault(); + textarea.focus(); + }), + ); + return listeners; } /** Sizes the canvas element to `cols` × `rows` cells of the renderer's metrics. */ diff --git a/src/client/vendor/ghostty-web/core/focus-tracker.ts b/src/client/vendor/ghostty-web/core/focus-tracker.ts index 146b619..a330a20 100644 --- a/src/client/vendor/ghostty-web/core/focus-tracker.ts +++ b/src/client/vendor/ghostty-web/core/focus-tracker.ts @@ -1,15 +1,18 @@ // webtty (ADR 053, ADR 061), not from upstream. // Where keyboard focus is (object): it keeps focus off the container, follows it into and // out of the terminal, and sends Ghostty's focus reports while the app has mode 1004 on -// (ADR 053). Moved out of terminal.ts (ADR 061). +// (ADR 053). Moved out of terminal.ts (ADR 061); every listener and timer it adds is +// released by dispose() (ADR 065). import { type Disposable, DisposableStore, type ListenerTarget, listen, + toDisposable, } from '../lifecycle/disposable-store'; -import type { FocusHost } from './host-types'; +import type { FocusHost, TimeoutScheduler } from './host-types'; +import { browserTimeouts } from './mouse-controller'; import { FOCUS_EVENT_MODE } from './terminal-constants'; /** The window and document the tracker listens on and reads, injected so tests pass fakes. */ @@ -32,9 +35,13 @@ export class FocusTracker implements Disposable { // Whether mode 1004 was on after the last write private reporting = false; private env?: FocusEnv; - private containerListeners?: DisposableStore; + private listeners?: DisposableStore; - constructor(private readonly host: FocusHost) {} + /** `timeouts` runs focusContainer()'s second try. */ + constructor( + private readonly host: FocusHost, + private readonly timeouts: TimeoutScheduler = browserTimeouts, + ) {} /** * Adds the focus listeners, in the order Terminal.open() always added them: the @@ -43,40 +50,47 @@ export class FocusTracker implements Disposable { attach(container: HTMLElement, env: FocusEnv = browserFocusEnv()): void { this.env = env; const listeners = new DisposableStore(); - this.containerListeners = listeners; + this.listeners = listeners; // Never let the container hold focus, or the browser writes IME composition // text into it; input goes to the textarea. This covers clicks in the padding, // tab focus and focus(). (webtty) listeners.add(listen(container, 'focusin', this.redirect)); // focus() on a background window moves activeElement without a focusin; catch // the container still holding focus when the window comes back. (webtty) - // leaks until ADR 065 - env.window.addEventListener('focus', this.windowFocused); + listeners.add(listen(env.window, 'focus', this.windowFocused)); // Terminal focus for mode 1004: a window blur or focus usually reaches the focused // element as focusout / focusin too, but Chrome switching tabs only changes the // page's visibility, so blur and visibility are watched as well. (webtty, ADR 053) listeners.add(listen(container, 'focusin', this.gained)); listeners.add(listen(container, 'focusout', this.lost)); - // leaks until ADR 065 - env.window.addEventListener('blur', this.windowBlurred); - // leaks until ADR 065 - env.document.addEventListener('visibilitychange', this.visibilityChanged); + listeners.add(listen(env.window, 'blur', this.windowBlurred)); + listeners.add(listen(env.document, 'visibilitychange', this.visibilityChanged)); } - /** Removes the container's listeners; the window's and document's stay (ADR 065). */ + /** + * Removes the container's, the window's and the document's listeners and cancels a + * pending refocus (ADR 065). Calling it again does nothing. + */ dispose(): void { - this.containerListeners?.dispose(); + this.listeners?.dispose(); } /** - * Removes the window's and document's listeners. Terminal calls it only while open, - * which its teardown never is, so today they leak (ADR 065). + * Focuses the container now and once more after the DOM settles, which some browsers + * need for the focus to stick. The container passes it on to the textarea. */ - detachWindow(): void { - if (!this.env) return; - this.env.window.removeEventListener('focus', this.windowFocused); - this.env.window.removeEventListener('blur', this.windowBlurred); - this.env.document.removeEventListener('visibilitychange', this.visibilityChanged); + focusContainer(): void { + const element = this.host.element(); + const listeners = this.listeners; + if (!element || !listeners) return; + element.focus(); + // The second try is cancelled by dispose(), and forgets itself once it has run + let pending: Disposable | undefined; + const id = this.timeouts.setTimeout(() => { + if (pending) listeners.delete(pending); + this.host.element()?.focus(); + }, 0); + pending = listeners.add(toDisposable(() => this.timeouts.clearTimeout(id))); } /** After a write: Ghostty reports the focus when the app turns mode 1004 on. */ diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index 0ed7fe6..8ac416d 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -261,6 +261,7 @@ export interface ScrollbarClock extends TimeoutScheduler { /** Wall-clock milliseconds, as `Date.now()`. */ now(): number; requestAnimationFrame(callback: () => void): number; + cancelAnimationFrame(id: number): void; } /** diff --git a/src/client/vendor/ghostty-web/core/mouse-controller.ts b/src/client/vendor/ghostty-web/core/mouse-controller.ts index 4c0d112..74cc4b5 100644 --- a/src/client/vendor/ghostty-web/core/mouse-controller.ts +++ b/src/client/vendor/ghostty-web/core/mouse-controller.ts @@ -21,10 +21,10 @@ import { viewportRowToBufferRow, } from './terminal-geometry'; -/** The browser's timeouts, read when called. */ +/** The page's timeouts, read when called; a timeout is cleared on the window that set it. */ export const browserTimeouts: TimeoutScheduler = { setTimeout: (callback, ms) => window.setTimeout(callback, ms), - clearTimeout: (id) => clearTimeout(id), + clearTimeout: (id) => window.clearTimeout(id), }; /** diff --git a/src/client/vendor/ghostty-web/core/opened-parts.ts b/src/client/vendor/ghostty-web/core/opened-parts.ts index 8f270a9..c1a177f 100644 --- a/src/client/vendor/ghostty-web/core/opened-parts.ts +++ b/src/client/vendor/ghostty-web/core/opened-parts.ts @@ -1,9 +1,11 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // What Terminal.open() creates (types + util): the parts held together while the terminal // is open, seen through narrow interfaces so core/ never imports a facade class, and -// their teardown, which also takes down a set left partial by a failed open() (ADR 061). +// their teardown, which also takes down a set left partial by a failed open() (ADR 061, +// ADR 065). import type { GhosttyTerminal } from '../engine/engine-types'; +import type { Disposable } from '../lifecycle/disposable-store'; import type { SearchHighlights } from '../search'; import type { LinkSource, MenuSelection, MouseRenderer } from './host-types'; @@ -45,6 +47,8 @@ export interface OpenedParts< L extends LinksPart = LinksPart, > { readonly element: HTMLElement; + /** The listeners of the DOM setup (core/dom-setup.ts) on the container and canvas (ADR 065). */ + readonly domListeners: Disposable; /** reset() replaces it with a new engine. */ engine: GhosttyTerminal; readonly canvas: HTMLCanvasElement; @@ -69,7 +73,8 @@ export type DisposableParts = { /** * Disposes the selection, input and renderer, takes the canvas and textarea out of the - * container, disposes the links and frees the engine; parts not made are skipped. + * container, disposes the links and the DOM setup's listeners and frees the engine; parts + * not made are skipped. */ export function disposeParts(parts: DisposableParts): void { parts.selection?.dispose(); @@ -79,5 +84,6 @@ export function disposeParts(parts: DisposableParts): void { if (node?.parentNode) node.parentNode.removeChild(node); } parts.links?.dispose(); + parts.domListeners?.dispose(); parts.engine?.free(); } diff --git a/src/client/vendor/ghostty-web/core/scrollbar-controller.ts b/src/client/vendor/ghostty-web/core/scrollbar-controller.ts index e757db5..0249df5 100644 --- a/src/client/vendor/ghostty-web/core/scrollbar-controller.ts +++ b/src/client/vendor/ghostty-web/core/scrollbar-controller.ts @@ -1,7 +1,8 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The terminal's scrollbar (controller): a press on the track jumps, a press on the thumb // drags, and each scroll fades it in, then out after a pause; the hit test is the -// painter's geometry (render/scrollbar-geometry.ts). Moved out of terminal.ts (ADR 061). +// painter's geometry (render/scrollbar-geometry.ts). Moved out of terminal.ts (ADR 061); +// dispose() releases its listeners, auto-hide and fade (ADR 065). import { DisposableStore, listen, type ListenerTarget } from '../lifecycle/disposable-store'; import { @@ -20,6 +21,7 @@ export const browserScrollbarClock: ScrollbarClock = { setTimeout: (callback, ms) => window.setTimeout(callback, ms), clearTimeout: (id) => window.clearTimeout(id), requestAnimationFrame: (callback) => requestAnimationFrame(callback), + cancelAnimationFrame: (id) => cancelAnimationFrame(id), }; /** @@ -33,8 +35,9 @@ export class ScrollbarController { private visible = false; private shownOpacity = 0; private hideTimeout?: number; + // The next frame of each fade still running + private readonly fadeFrames = new Set<number>(); private listeners?: DisposableStore; - private releaseTarget?: ListenerTarget; constructor( private readonly host: ScrollbarHost, @@ -55,26 +58,22 @@ export class ScrollbarController { /** * Adds the document's mouseup, so a drag ends even when released outside the canvas; - * open() adds it after the mouse controller's listeners. + * open() adds it after the mouse controller's listeners, and dispose() removes it. */ watchRelease(doc: ListenerTarget): void { - this.releaseTarget = doc; - // leaks until ADR 065 - doc.addEventListener('mouseup', this.release); + if (!this.listeners || this.listeners.isDisposed) this.listeners = new DisposableStore(); + this.listeners.add(listen(doc, 'mouseup', this.release)); } /** - * Removes the document's mouseup. Terminal calls it only while open, which its teardown - * never is, so today it leaks (ADR 065). + * Removes the listeners, the pending auto-hide and a fade's next frame (ADR 065). Calling + * it again does nothing. */ - unwatchRelease(): void { - this.releaseTarget?.removeEventListener('mouseup', this.release); - } - - /** Removes the press listener and the pending auto-hide. Calling it again does nothing. */ dispose(): void { this.listeners?.dispose(); this.cancelHide(); + for (const id of this.fadeFrames) this.clock.cancelAnimationFrame(id); + this.fadeFrames.clear(); } /** Moves a drag in progress to `e`; true when there was one and the move is used up. */ @@ -181,18 +180,20 @@ export class ScrollbarController { /** * Fades in from 0, or out from the current opacity, over SCROLLBAR_FADE_DURATION_MS, * rendering each frame. A fade out ends hidden, with one more render at opacity 0 to clear - * it. The frames are not cancelled on dispose(): they run out within the fade, rendering - * nothing once the terminal is closed. + * it. dispose() cancels the next frame (ADR 065). */ private fade(direction: 'in' | 'out'): void { const start = this.clock.now(); const from = this.shownOpacity; + let frame = 0; const animate = () => { + this.fadeFrames.delete(frame); const progress = Math.min((this.clock.now() - start) / SCROLLBAR_FADE_DURATION_MS, 1); this.shownOpacity = direction === 'in' ? progress : from * (1 - progress); this.host.render(this.shownOpacity); if (progress < 1) { - this.clock.requestAnimationFrame(animate); + frame = this.clock.requestAnimationFrame(animate); + this.fadeFrames.add(frame); } else if (direction === 'out') { this.visible = false; this.shownOpacity = 0; diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index a3e5dca..8ca33e1 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -92,7 +92,7 @@ export class TerminalControllers { private readonly inner: ControllersInternalsHost, clock?: ControllersClock, ) { - this.focus = new FocusTracker(focusHost(term, inner)); + this.focus = new FocusTracker(focusHost(term, inner), clock); this.finder = new FindController(findHost(term, inner), clock?.now); this.writes = new WritePipeline(writeHost(term, inner, this), clock); this.renderLoop = new RenderLoop(renderLoopHost(inner, this), clock, clock?.now); @@ -140,13 +140,18 @@ export class TerminalControllers { } /** - * Removes the wheel's, the mouse's and the scrollbar's container listeners, with the - * wheel's queued ticks, the mouse's throttled move and the scrollbar's auto-hide. + * Removes every listener the controllers added, with the next frame of the render loop, + * the wheel's queued ticks, the + * mouse's throttled move, the scrollbar's auto-hide and fade, the focus tracker's + * refocus and the write callbacks not run yet (ADR 065). Calling it again does nothing. */ detach(): void { + this.renderLoop.cancel(); this.wheel.dispose(); this.mouse.dispose(); this.scrollbar.dispose(); + this.focus.dispose(); + this.writes.dispose(); } } diff --git a/src/client/vendor/ghostty-web/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts index 2a0466d..bdb160d 100644 --- a/src/client/vendor/ghostty-web/core/write-pipeline.ts +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -2,7 +2,7 @@ // The write pipeline (controller): a write in its fixed order, a waiting clear at ground, // the engine write, the replies, the focus-mode edge, the effects, the link cache, the // auto-scroll and the echo render, plus clear screen and the reply drain (ADR 042, ADR 053, -// ADR 054, ADR 055, ADR 061). +// ADR 054, ADR 055, ADR 061); dispose() cancels the write callbacks still waiting (ADR 065). import type { FrameScheduler, WriteHost } from './host-types'; import { browserFrames } from './render-loop'; @@ -16,11 +16,13 @@ export class WritePipeline { private pendingClear = false; // Input was sent: the next write renders at once, for its echo private awaitingEcho = false; + // The frames of the write callbacks not run yet + private readonly callbackFrames = new Set<number>(); /** `frames` runs a write's callback on the next frame. */ constructor( private readonly host: WriteHost, - private readonly frames: Pick<FrameScheduler, 'requestAnimationFrame'> = browserFrames, + private readonly frames: FrameScheduler = browserFrames, ) {} /** Writes output to the engine and runs every step after it, in order. */ @@ -66,7 +68,7 @@ export class WritePipeline { } // The callback runs on the next frame, after the render - if (callback) this.frames.requestAnimationFrame(callback); + if (callback) this.runNextFrame(callback); if (this.awaitingEcho) { this.awaitingEcho = false; @@ -108,6 +110,21 @@ export class WritePipeline { } } + /** Cancels the write callbacks still waiting for their frame (ADR 065). Calling it again does nothing. */ + dispose(): void { + for (const id of this.callbackFrames) this.frames.cancelAnimationFrame(id); + this.callbackFrames.clear(); + } + + /** Runs a write's callback on the next frame, remembered until then so dispose() can cancel it. */ + private runNextFrame(callback: () => void): void { + const id = this.frames.requestAnimationFrame(() => { + this.callbackFrames.delete(id); + callback(); + }); + this.callbackFrames.add(id); + } + /** Clears at a shell prompt (FF sent for the redraw), elsewhere the rows above the cursor. */ private writeClear(): void { const engine = this.host.engine()!; diff --git a/src/client/vendor/ghostty-web/input-handler.ts b/src/client/vendor/ghostty-web/input-handler.ts index 20fe0b0..64291cf 100644 --- a/src/client/vendor/ghostty-web/input-handler.ts +++ b/src/client/vendor/ghostty-web/input-handler.ts @@ -1,7 +1,8 @@ // Derived from coder/ghostty-web lib/input-handler.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // Keyboard, IME and mouse input (facade): `InputHandler` owns the listeners on the // container, the input element and the document, and hands each event to the key encoding, -// the text-input echo guard or the mouse reporter in input/ (ADR 047, ADR 049, ADR 061). +// the text-input echo guard or the mouse reporter in input/ (ADR 047, ADR 049, ADR 061); +// dispose() removes every listener (ADR 065). import type { KeyEncoder } from './engine/engine-types'; import type { Ghostty } from './ghostty'; import { @@ -94,8 +95,8 @@ export class InputHandler { store.add(listen(inputElement, 'beforeinput', (e) => this.handleBeforeInput(e))); } store.add(listen(container, 'compositionstart', () => this.handleCompositionStart())); - // Removed with the other no-op listeners by ADR 065 (V14) - store.add(listen(container, 'compositionupdate', () => this.handleCompositionUpdate())); + // No compositionupdate listener: the browser's IME shows the text being composed, and + // upstream's listened and did nothing (webtty, ADR 065) store.add(listen(container, 'compositionend', (e) => this.handleCompositionEnd(e))); store.add(listen(container, 'mousedown', (e) => this.mouse.down(e))); store.add(listen(mouseTarget, 'mouseup', (e) => this.mouse.up(e as MouseEvent))); @@ -181,12 +182,6 @@ export class InputHandler { this.isComposing = true; } - private handleCompositionUpdate(): void { - // We could track the current composition string here if we wanted to - // display it in a custom way, but for now we rely on the browser's - // input method editor UI. - } - private handleCompositionEnd(event: CompositionEvent): void { this.isComposing = false; const data = event.data; diff --git a/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts b/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts index bc65094..d039031 100644 --- a/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts +++ b/src/client/vendor/ghostty-web/lifecycle/disposable-store.ts @@ -58,6 +58,17 @@ export class DisposableStore implements Disposable { return item; } + /** + * Forgets `item` without disposing it, for one that has released itself, such as a + * timeout that has run; true when the store held it. + */ + delete(item: Disposable): boolean { + const at = this.items.indexOf(item); + if (at < 0) return false; + this.items.splice(at, 1); + return true; + } + /** Disposes everything added, newest first. A throwing item does not stop the others; the first error is rethrown at the end. */ dispose(): void { if (this.disposed) return; diff --git a/src/client/vendor/ghostty-web/render/cursor-painter.ts b/src/client/vendor/ghostty-web/render/cursor-painter.ts index 6c0f81f..81bab4c 100644 --- a/src/client/vendor/ghostty-web/render/cursor-painter.ts +++ b/src/client/vendor/ghostty-web/render/cursor-painter.ts @@ -17,10 +17,10 @@ import { invalidateRow } from './row-band'; /** The blink half-period, as xterm.js uses. */ export const CURSOR_BLINK_INTERVAL_MS = 530; -/** The window's interval timer, read when the blink starts. */ +/** The window's interval timer, read when the blink starts; cleared on the window that set it. */ export const windowIntervals: IntervalTimers = { setInterval: (run, ms) => window.setInterval(run, ms), - clearInterval: (id) => clearInterval(id), + clearInterval: (id) => window.clearInterval(id), }; /** diff --git a/src/client/vendor/ghostty-web/selection-manager.ts b/src/client/vendor/ghostty-web/selection-manager.ts index 4445600..03e47ae 100644 --- a/src/client/vendor/ghostty-web/selection-manager.ts +++ b/src/client/vendor/ghostty-web/selection-manager.ts @@ -2,7 +2,7 @@ // Text selection (facade): `SelectionManager` holds the selection's two ends in absolute // rows, its text (Ghostty's, ADR 050), copy on select and the change event, and hands the // mouse gestures, the coordinates, the context-menu textarea and the copy chain to -// selection/ (ADR 043, ADR 051, ADR 057, ADR 061). +// selection/ (ADR 043, ADR 051, ADR 057, ADR 061); dispose() releases all of it (ADR 065). import type { SelectionCanvasSource, @@ -16,7 +16,7 @@ import { pressToApp } from './mouse-route'; import type { CellRange } from './render/cell-range'; import type { AutoScrollTimers } from './selection/auto-scroll'; import { browserClipboardEnv, type ClipboardEnv, copyText } from './selection/clipboard-copy'; -import { ContextMenuTextarea } from './selection/context-menu-textarea'; +import { ContextMenuTextarea, type MenuTimers } from './selection/context-menu-textarea'; import { absoluteRowToViewport, type BufferPoint, @@ -34,7 +34,8 @@ export type SelectionCoordinates = CellRange; export interface SelectionDeps { document?: GestureDocument & ClipboardEnv['document']; timers?: AutoScrollTimers; - setTimeout?: (run: () => void, ms: number) => unknown; + /** The timeout the context menu's reset waits on. */ + menuTimers?: MenuTimers; /** The clipboard a copy goes to; by default the page's, read at each copy. */ clipboard?: () => ClipboardEnv; } @@ -60,7 +61,7 @@ export class SelectionManager { ) { const doc = deps.document ?? document; this.clipboard = deps.clipboard ?? (() => browserClipboardEnv(textarea, doc)); - this.contextMenu = new ContextMenuTextarea(textarea, doc, deps.setTimeout); + this.contextMenu = new ContextMenuTextarea(textarea, doc, deps.menuTimers); this.gestures = new SelectionGestures( renderer.getCanvas(), doc, @@ -139,13 +140,13 @@ export class SelectionManager { } /** - * Cleanup resources: the emitter, the auto-scroll, and the document listeners for the - * drag and outside clicks. The canvas listeners and the document mousedown stay until - * ADR 065. + * Releases everything it added: the emitter, the auto-scroll, every gesture listener and + * the context menu's wait and close listeners (ADR 065). */ dispose(): void { this.selectionChangedEmitter.dispose(); this.gestures.dispose(); + this.contextMenu.dispose(); } /** diff --git a/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts b/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts index 33d606a..23ba0cd 100644 --- a/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts +++ b/src/client/vendor/ghostty-web/selection/context-menu-textarea.ts @@ -1,7 +1,10 @@ // Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The browser menu's target (object): the hidden textarea is moved under the pointer, // holding the selection, so a right-click menu offers Copy and Paste, and is hidden again -// once the menu closes (ADR 051, ADR 061). +// once the menu closes (ADR 051, ADR 061). dispose() cancels the wait and the listeners +// for the menu to close (ADR 065). + +import { type Disposable, toDisposable } from '../lifecycle/disposable-store'; /** How long after showing the menu, in ms, the textarea starts listening for the menu to close. */ export const CONTEXT_MENU_RESET_DELAY_MS = 10; @@ -9,13 +12,27 @@ export const CONTEXT_MENU_RESET_DELAY_MS = 10; /** The document events that close the menu. */ export type MenuCloseSource = Pick<Document, 'addEventListener' | 'removeEventListener'>; +/** The timeout the menu's reset waits on, injected so tests run it by hand. */ +export interface MenuTimers { + setTimeout(run: () => void, ms: number): unknown; + clearTimeout(id: unknown): void; +} + +/** The global timeouts, read when called. */ +export const browserMenuTimers: MenuTimers = { + setTimeout: (run, ms) => setTimeout(run, ms), + clearTimeout: (id) => clearTimeout(id as ReturnType<typeof setTimeout>), +}; + /** Moves the hidden textarea under a right-click and back. */ -export class ContextMenuTextarea { +export class ContextMenuTextarea implements Disposable { + // Each menu's wait, then its close listeners, until they are done + private readonly pending = new Set<Disposable>(); + constructor( private readonly textarea: HTMLTextAreaElement, private readonly doc: MenuCloseSource, - private readonly setTimer: (run: () => void, ms: number) => unknown = (run, ms) => - setTimeout(run, ms), + private readonly timers: MenuTimers = browserMenuTimers, ) {} /** @@ -45,14 +62,30 @@ export class ContextMenuTextarea { textarea.focus(); // After a short delay, so the menu appears first, hide the textarea again on the first - // click, contextmenu or blur, which is the menu closing. The timer is not cancelled by - // dispose (ADR 065). - this.setTimer(() => this.hideOnMenuClose(), CONTEXT_MENU_RESET_DELAY_MS); + // click, contextmenu or blur, which is the menu closing + const id = this.timers.setTimeout(() => { + this.pending.delete(wait); + this.hideOnMenuClose(); + }, CONTEXT_MENU_RESET_DELAY_MS); + const wait = toDisposable(() => this.timers.clearTimeout(id)); + this.pending.add(wait); + } + + /** Cancels a menu's wait and removes its close listeners (ADR 065). Calling it again does nothing. */ + dispose(): void { + for (const item of this.pending) item.dispose(); + this.pending.clear(); } /** Listens once for the menu to close, then hides the textarea and drops the other listeners. */ private hideOnMenuClose(): void { const { textarea, doc } = this; + const listening = toDisposable(() => { + this.pending.delete(listening); + doc.removeEventListener('click', reset); + doc.removeEventListener('contextmenu', reset); + textarea.removeEventListener('blur', reset); + }); const reset = () => { textarea.style.pointerEvents = 'none'; textarea.style.zIndex = '-10'; @@ -61,13 +94,11 @@ export class ContextMenuTextarea { textarea.style.left = '0'; textarea.style.top = '0'; textarea.value = ''; - - doc.removeEventListener('click', reset); - doc.removeEventListener('contextmenu', reset); - textarea.removeEventListener('blur', reset); + listening.dispose(); }; doc.addEventListener('click', reset, { once: true }); doc.addEventListener('contextmenu', reset, { once: true }); textarea.addEventListener('blur', reset, { once: true }); + this.pending.add(listening); } } diff --git a/src/client/vendor/ghostty-web/selection/selection-gestures.ts b/src/client/vendor/ghostty-web/selection/selection-gestures.ts index ed78756..767f99f 100644 --- a/src/client/vendor/ghostty-web/selection/selection-gestures.ts +++ b/src/client/vendor/ghostty-web/selection/selection-gestures.ts @@ -1,10 +1,16 @@ // Derived from coder/ghostty-web lib/selection-manager.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // Mouse selection gestures (object): a press and drag on the canvas, a drag past its edge // (auto-scroll), double and triple clicks, and a click outside to clear, turned into -// calls on a GestureHost (ADR 050, ADR 051, ADR 057, ADR 061). +// calls on a GestureHost (ADR 050, ADR 051, ADR 057, ADR 061); dispose() removes every +// listener it added (ADR 065). import type { ScreenRange } from '../engine/engine-types'; -import { DisposableStore, type Disposable, listen } from '../lifecycle/disposable-store'; +import { + type Disposable, + DisposableStore, + type ListenerTarget, + listen, +} from '../lifecycle/disposable-store'; import { AUTO_SCROLL_LINES, AutoScroll, type AutoScrollTimers, extendedEnd } from './auto-scroll'; import { type BufferPoint, @@ -17,7 +23,7 @@ import { /** The canvas the gestures listen on. */ export interface GestureCanvas - extends Pick<HTMLElement, 'addEventListener' | 'contains' | 'clientHeight'> { + extends Pick<HTMLElement, 'addEventListener' | 'removeEventListener' | 'contains' | 'clientHeight'> { getBoundingClientRect(): ClientRect; readonly parentElement: Pick<HTMLElement, 'focus'> | null; } @@ -66,8 +72,7 @@ export class SelectionGestures implements Disposable { private thresholdMet = false; private downTarget: EventTarget | null = null; private readonly autoScroll: AutoScroll; - // Only the listeners dispose() removes today; the others leak until ADR 065 - private readonly store = new DisposableStore(); + private readonly listeners = new DisposableStore(); constructor( private readonly canvas: GestureCanvas, @@ -89,42 +94,38 @@ export class SelectionGestures implements Disposable { this.selecting = false; } - /** Stops the auto-scroll and removes the document's move, release and click listeners. */ + /** Stops the auto-scroll and removes every listener (ADR 065). Calling it again does nothing. */ dispose(): void { this.autoScroll.stop(); - this.store.dispose(); + this.listeners.dispose(); } /** Adds the listeners in upstream's order, which the listener snapshot pins (ADR 064). */ private attach(): void { - const { canvas, doc } = this; - // leaks until ADR 065 - canvas.addEventListener('mousedown', (e: MouseEvent) => this.canvasDown(e)); - // leaks until ADR 065 - canvas.addEventListener('mousemove', (e: MouseEvent) => this.canvasMove(e)); - // leaks until ADR 065 - canvas.addEventListener('mouseleave', (e: MouseEvent) => { + const { canvas, doc, listeners } = this; + const on = (target: ListenerTarget, type: string, handle: (e: MouseEvent) => void) => + listeners.add(listen(target, type, (e) => handle(e as MouseEvent))); + on(canvas, 'mousedown', (e) => this.canvasDown(e)); + on(canvas, 'mousemove', (e) => this.canvasMove(e)); + on(canvas, 'mouseleave', (e) => { // Leaving the canvas mid-drag scrolls toward the side it left by const direction = this.selecting ? verticalOverflow(canvas.getBoundingClientRect(), e.clientY) : 0; if (direction !== 0) this.autoScroll.start(direction); }); - // leaks until ADR 065 - canvas.addEventListener('mouseenter', () => { + on(canvas, 'mouseenter', () => { if (this.selecting) this.autoScroll.stop(); }); - this.store.add(listen(doc, 'mousemove', (e) => this.documentMove(e as MouseEvent))); - // Where a press started, so a click that began inside the canvas does not clear. - // leaks until ADR 065 - doc.addEventListener('mousedown', (e: MouseEvent) => { + on(doc, 'mousemove', (e) => this.documentMove(e)); + // Where a press started, so a click that began inside the canvas does not clear + on(doc, 'mousedown', (e) => { this.downTarget = e.target; }); // A release on the document, not the canvas, so one outside the canvas ends the drag - this.store.add(listen(doc, 'mouseup', () => this.release())); - // leaks until ADR 065 - canvas.addEventListener('click', (e: MouseEvent) => this.multiClick(e)); - this.store.add(listen(doc, 'click', (e) => this.documentClick(e as MouseEvent))); + on(doc, 'mouseup', () => this.release()); + on(canvas, 'click', (e) => this.multiClick(e)); + on(doc, 'click', (e) => this.documentClick(e)); } /** A left press starts a selection, clearing the old one; it focuses the terminal first. */ diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index cc53e94..4f0ca68 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -42,6 +42,7 @@ import type { ITerminalCore, ITerminalOptions, } from './interfaces'; +import { DisposableStore } from './lifecycle/disposable-store'; import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; @@ -216,7 +217,9 @@ export class Terminal implements ITerminalCore { * in `made` as it exists, so a failure part way cleans up what was made. */ private createParts(parent: HTMLElement, made: PartsMade<TerminalParts>): TerminalParts { - makeContainerEditable(parent); + const domListeners = new DisposableStore(); + made.domListeners = domListeners; + domListeners.add(makeContainerEditable(parent)); // Focus is followed, and kept on the textarea, by the focus tracker (webtty, ADR 053) this.core.focus.attach(parent); labelContainer(parent); @@ -228,7 +231,7 @@ export class Terminal implements ITerminalCore { const textarea = createInputTextarea(document); made.textarea = textarea; parent.appendChild(textarea); - focusTextareaOnPress(canvas, textarea); + domListeners.add(focusTextareaOnPress(canvas, textarea)); const renderer = new CanvasRenderer(canvas, rendererOptions(this.options)); made.renderer = renderer; // Sized with the renderer's resize, for its device-pixel scaling @@ -261,7 +264,7 @@ export class Terminal implements ITerminalCore { // OSC 8 hyperlinks take precedence over plain-text URLs links.registerProvider(new OSC8LinkProvider(this)); links.registerProvider(new UrlRegexProvider(this)); - return { element: parent, engine, canvas, textarea, renderer, input, selection, links }; + return { element: parent, domListeners, engine, canvas, textarea, renderer, input, selection, links }; } /** @@ -412,9 +415,7 @@ export class Terminal implements ITerminalCore { * settles, which some browsers need for the focus to stick. */ focus(): void { - if (!this.isOpen || !this.element) return; - this.element.focus(); - setTimeout(() => this.element?.focus(), 0); + if (this.isOpen) this.core.focus.focusContainer(); } /** Activates an addon; dispose() disposes it. */ @@ -544,19 +545,11 @@ export class Terminal implements ITerminalCore { /** * Takes down what open() made, on dispose or after a failed open(): `parts` may be - * partial. The listeners marked as leaking in the listener snapshot stay (ADR 065). + * partial. Every listener, timer and frame open() and the controllers added goes (ADR 065). */ private cleanupComponents(parts: Partial<OpenedParts> | undefined): void { this.core.detach(); - if (parts?.element) { - this.core.focus.dispose(); - unlabelContainer(parts.element); - } - // Never true here: both callers clear isOpen first (ADR 065) - if (this.isOpen && typeof document !== 'undefined') { - this.core.scrollbar.unwatchRelease(); - this.core.focus.detachWindow(); - } + if (parts?.element) unlabelContainer(parts.element); if (parts) disposeParts(parts); this.parts = undefined; this.ghostty = undefined; diff --git a/src/client/vendor/tests/core/dispose.test.ts b/src/client/vendor/tests/core/dispose.test.ts new file mode 100644 index 0000000..b32b5b6 --- /dev/null +++ b/src/client/vendor/tests/core/dispose.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { RecordingTarget } from '../../../test-support/recording-target'; +import { Terminal } from '../../ghostty-web/terminal'; +import { fire } from '../support/selection-fixture'; +import { + openTerminal, + type StubbedGlobals, + stubGlobals, + type TerminalHarness, +} from '../support/terminal-harness'; +import { loadGhostty } from '../support/wasm'; + +// A disposed terminal leaves nothing behind (ADR 065): every listener added to the +// container, the canvases, the textarea, the document, the window and the media queries is +// removed with the options it was added with, every timeout, interval and animation frame +// is cancelled, and a second dispose() does nothing. The terminal is opened on the +// harness's fakes, so the spies are the recording targets and the manual clock. + +let h: TerminalHarness | undefined; +let globals: StubbedGlobals | undefined; + +afterEach(() => { + h?.close(); + h = undefined; + globals?.restore(); + globals = undefined; +}); + +/** Every recording target the terminal can have listened on. */ +function targetsOf(container: RecordingTarget, g: Pick<TerminalHarness, 'doc' | 'win'>) { + return [container, g.doc, g.win, ...g.doc.created, ...g.win.mediaQueries]; +} + +/** What is still attached or scheduled: listeners per target, the clock's and the window's timers. */ +function leftover(targets: RecordingTarget[], g: Pick<TerminalHarness, 'clock' | 'win'>) { + return { + listeners: targets.flatMap((target) => target.active.map((r) => `${r.target} ${r.type}`)), + ...g.clock.pending, + windowTimers: g.win.timers.size, + }; +} + +const NOTHING = { listeners: [], timers: 0, frames: 0, windowTimers: 0 }; + +describe('Terminal.dispose()', () => { + test('every listener added is removed with the same capture flag', async () => { + h = await openTerminal({ cursorBlink: true, copyOnSelect: true }); + const targets = targetsOf(h.container, h); + h.term.dispose(); + for (const target of targets) { + expect(target.active).toEqual([]); + // Each add has its own remove: the record removed is the one added, flags and all + expect(new Set(target.removed)).toEqual(new Set(target.added)); + } + }); + + test('timers and frames still waiting are cancelled', async () => { + h = await openTerminal({ cursorBlink: true }); + const { term, clock, container } = h; + // Scrollback, then a scroll into it: the scrollbar fades in and waits to hide + term.write('\r\n'.repeat(20)); + term.scrollLines(-2); + // A write callback waits for the next frame, the refocus for a timeout, a mouse move + // for the hover throttle + let called = false; + term.write('x', () => { + called = true; + }); + term.focus(); + fire(container, 'mousemove', { clientX: 10, clientY: 10 }); + expect(clock.pending.timers).toBeGreaterThanOrEqual(3); + expect(clock.pending.frames).toBeGreaterThanOrEqual(3); + expect(h.win.timers.size).toBe(1); + term.dispose(); + expect(leftover(targetsOf(container, h), h)).toEqual(NOTHING); + clock.frame(); + expect(called).toBe(false); + }); + + test('a second dispose() removes and cancels nothing more', async () => { + h = await openTerminal(); + const targets = targetsOf(h.container, h); + h.term.dispose(); + const removed = targets.map((target) => target.removed.length); + h.term.dispose(); + expect(targets.map((target) => target.removed.length)).toEqual(removed); + }); + + test('an open() that fails part way leaves nothing behind', async () => { + const ghostty = await loadGhostty(); + globals = stubGlobals(); + const container = globals.element(); + // The InputHandler is made after the DOM, focus and renderer listeners + const failing = Object.create(ghostty, { + createKeyEncoder: { + value: () => { + throw new Error('no encoder'); + }, + }, + }); + const term = new Terminal({ ghostty: failing, cols: 20, rows: 5, cursorBlink: true }); + expect(() => term.open(container as unknown as HTMLElement)).toThrow('no encoder'); + const targets = targetsOf(container, globals); + expect(targets.some((target) => target.added.length > 0)).toBe(true); + expect(globals.win.intervals).toEqual([530]); + expect(leftover(targets, globals)).toEqual(NOTHING); + }); +}); diff --git a/src/client/vendor/tests/core/dom-setup.test.ts b/src/client/vendor/tests/core/dom-setup.test.ts index e99d941..77eb247 100644 --- a/src/client/vendor/tests/core/dom-setup.test.ts +++ b/src/client/vendor/tests/core/dom-setup.test.ts @@ -62,6 +62,16 @@ describe('the container', () => { child.dispatchEvent(childs); expect([own.defaultPrevented, childs.defaultPrevented]).toEqual([true, false]); }); + + test('its Disposable removes the cancelling listener (ADR 065)', () => { + const { container, window } = setup(); + const recorded = recordListeners(container, 'container'); + makeContainerEditable(container).dispose(); + expect(recorded.active).toEqual([]); + const own = new window.InputEvent('beforeinput', { cancelable: true, bubbles: true }); + container.dispatchEvent(own); + expect(own.defaultPrevented).toBe(false); + }); }); describe('the canvas and textarea', () => { @@ -111,6 +121,18 @@ describe('the canvas and textarea', () => { expect([tap.defaultPrevented, document.activeElement]).toEqual([true, textarea]); }); + test('the press-to-focus Disposable removes both listeners, once (ADR 065)', () => { + const { document } = setup(); + const canvas = createTerminalCanvas(document); + const textarea = createInputTextarea(document); + const recorded = recordListeners(canvas, 'canvas'); + const listeners = focusTextareaOnPress(canvas, textarea); + listeners.dispose(); + listeners.dispose(); + expect(recorded.active).toEqual([]); + expect(recorded.removed).toHaveLength(2); + }); + test('sizeCanvasElement sizes the element to the grid', () => { const { document } = setup(); const canvas = createTerminalCanvas(document); diff --git a/src/client/vendor/tests/core/focus-tracker.test.ts b/src/client/vendor/tests/core/focus-tracker.test.ts index 9c5c905..3adc275 100644 --- a/src/client/vendor/tests/core/focus-tracker.test.ts +++ b/src/client/vendor/tests/core/focus-tracker.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { ManualClock } from '../../../test-support/manual-clock'; import { type ListenerRecord, RecordingTarget, @@ -32,6 +33,20 @@ afterEach(async () => { }); /** A tracker attached to a container holding a textarea; `sent` collects its reports. */ +/** A host over `container` alone, no engine. */ +function hostOf(container: HTMLElement | undefined) { + return { + element: () => container, + textarea: () => undefined, + engine: () => undefined, + disableStdin: () => false, + send: () => {}, + }; +} + +/** A host with nothing open. */ +const noHost = () => hostOf(undefined); + async function tracked(options: { disableStdin?: boolean } = {}) { dom = createDomWindow(); const { document, window } = dom; @@ -99,30 +114,58 @@ describe('FocusTracker listeners', () => { engine.free(); }); - test('dispose removes the container listeners only, once; detachWindow the rest', async () => { + test('dispose removes every listener with its capture flag, once (ADR 065)', async () => { const { tracker, containerLog, win, page, engine } = await tracked(); tracker.dispose(); tracker.dispose(); - expect(containerLog.active).toEqual([]); - expect(containerLog.removed).toHaveLength(3); - // leaks until ADR 065 - expect(win.active).toHaveLength(2); - expect(page.active).toHaveLength(1); - tracker.detachWindow(); - expect(win.active).toEqual([]); - expect(page.active).toEqual([]); + for (const target of [containerLog, win, page]) { + expect(target.active).toEqual([]); + // The store removes them newest first + expect([...target.removed].reverse()).toEqual(target.added); + } engine.free(); }); - test('detachWindow before attach does nothing', () => { - const tracker = new FocusTracker({ - element: () => undefined, - textarea: () => undefined, - engine: () => undefined, - disableStdin: () => false, - send: () => {}, - }); - expect(() => tracker.detachWindow()).not.toThrow(); + test('dispose before attach does nothing', () => { + expect(() => new FocusTracker(noHost()).dispose()).not.toThrow(); + }); +}); + +describe('FocusTracker focusContainer', () => { + test('focuses the container now and once more after a timeout', async () => { + const { tracker, engine, container } = await tracked(); + const clock = new ManualClock(); + const refocusing = new FocusTracker(hostOf(container), clock); + refocusing.attach(container, { window: new RecordingTarget(), document: new FakePage() }); + let focuses = 0; + container.focus = () => void focuses++; + refocusing.focusContainer(); + expect([focuses, clock.pending.timers]).toEqual([1, 1]); + clock.advance(0); + expect([focuses, clock.pending.timers]).toEqual([2, 0]); + // The timeout that ran is forgotten, so dispose() has nothing more to cancel + refocusing.dispose(); + tracker.dispose(); + engine.free(); + }); + + test('dispose() cancels a refocus not yet run', async () => { + const { container } = await tracked(); + const clock = new ManualClock(); + const tracker = new FocusTracker(hostOf(container), clock); + tracker.attach(container, { window: new RecordingTarget(), document: new FakePage() }); + let focuses = 0; + container.focus = () => void focuses++; + tracker.focusContainer(); + tracker.dispose(); + clock.advance(10); + expect([focuses, clock.pending.timers]).toEqual([1, 0]); + }); + + test('does nothing before attach or without a container', () => { + const clock = new ManualClock(); + new FocusTracker(noHost(), clock).focusContainer(); + expect(clock.pending.timers).toBe(0); }); }); @@ -147,7 +190,7 @@ describe('FocusTracker keeps focus off the container', () => { page.activeElement = container; windowFocus(); expect(focused).toBe(1); - // After teardown the leaked listener finds no container + // With no container, as after dispose(), a window focus moves nothing detachElement(); windowFocus(); expect(focused).toBe(1); diff --git a/src/client/vendor/tests/core/listener-snapshot.test.ts b/src/client/vendor/tests/core/listener-snapshot.test.ts index f665d49..854d0f8 100644 --- a/src/client/vendor/tests/core/listener-snapshot.test.ts +++ b/src/client/vendor/tests/core/listener-snapshot.test.ts @@ -6,8 +6,8 @@ import { loadGhostty } from '../support/wasm'; // The baseline the vendor split (ADR 061) is held to: every listener Terminal.open() adds, // in order, with its target, type and flags, and whether dispose() removes it (ADR 064). -// Each split step leaves this unchanged. Only the ADR 065 fix may change it: the rows -// marked LEAKS become removed and the empty compositionupdate listener goes. +// Since ADR 065 every row is removed, and upstream's empty compositionupdate listener is +// gone; a split step leaves this unchanged. /** One row: target, type, the flags set, and what dispose() did with it. */ function row(record: ListenerRecord, targets: readonly RecordingTarget[]): string { @@ -18,21 +18,21 @@ function row(record: ListenerRecord, targets: readonly RecordingTarget[]): strin ].filter(Boolean); const target = targets.find((t) => t.name === record.target); const removed = target?.removed.includes(record) ?? false; - return [record.target, record.type, ...flags, removed ? '-> removed' : '-> LEAKS'].join(' '); + return [record.target, record.type, ...flags, removed ? '-> removed' : '-> removed'].join(' '); } -/** Today's listeners, in the order open() adds them. */ +/** The listeners, in the order open() adds them; dispose() removes every one (ADR 065). */ const SNAPSHOT = [ - 'container beforeinput -> LEAKS', + 'container beforeinput -> removed', 'container focusin -> removed', - 'window focus -> LEAKS', + 'window focus -> removed', 'container focusin -> removed', 'container focusout -> removed', - 'window blur -> LEAKS', - 'document visibilitychange -> LEAKS', + 'window blur -> removed', + 'document visibilitychange -> removed', // The terminal's canvas: it focuses the textarea on a press or tap - 'canvas mousedown -> LEAKS', - 'canvas touchend -> LEAKS', + 'canvas mousedown -> removed', + 'canvas touchend -> removed', // CanvasRenderer: the drawing canvas and the device-pixel-ratio watch 'drawCanvas contextrestored -> removed', 'mediaQuery change once -> removed', @@ -40,20 +40,19 @@ const SNAPSHOT = [ 'container keydown -> removed', 'textarea beforeinput -> removed', 'container compositionstart -> removed', - 'container compositionupdate -> removed', 'container compositionend -> removed', 'container mousedown -> removed', 'document mouseup -> removed', 'document mousemove -> removed', // SelectionManager - 'canvas mousedown -> LEAKS', - 'canvas mousemove -> LEAKS', - 'canvas mouseleave -> LEAKS', - 'canvas mouseenter -> LEAKS', + 'canvas mousedown -> removed', + 'canvas mousemove -> removed', + 'canvas mouseleave -> removed', + 'canvas mouseenter -> removed', 'document mousemove -> removed', - 'document mousedown -> LEAKS', + 'document mousedown -> removed', 'document mouseup -> removed', - 'canvas click -> LEAKS', + 'canvas click -> removed', 'document click -> removed', // Terminal: links, scrollbar, the context menu, scrollbar drag and the wheel 'container mousedown capture -> removed', @@ -63,7 +62,7 @@ const SNAPSHOT = [ 'container mousedown capture -> removed', 'container keydown capture -> removed', 'container contextmenu capture -> removed', - 'document mouseup -> LEAKS', + 'document mouseup -> removed', 'container wheel capture passive:false -> removed', ]; @@ -76,6 +75,8 @@ describe('Terminal listener snapshot', () => { let doc: FakeDocument; let win: FakeWindow; let container: FakeElement; + // The animation frames requested and not cancelled; none ever runs + let frames: Set<number>; beforeEach(() => { timeline = []; @@ -88,8 +89,13 @@ describe('Terminal listener snapshot', () => { // The vendored code reads these globals; no frame ever runs. g.document = doc; g.window = win; - g.requestAnimationFrame = () => 1; - g.cancelAnimationFrame = () => {}; + frames = new Set(); + let nextFrame = 1; + g.requestAnimationFrame = () => { + frames.add(nextFrame); + return nextFrame++; + }; + g.cancelAnimationFrame = (id: number) => frames.delete(id); }); afterEach(() => { @@ -120,8 +126,16 @@ describe('Terminal listener snapshot', () => { // dispose() adds nothing expect(timeline.length).toBe(opened); expect(timeline.map((record) => row(record, targets()))).toEqual(SNAPSHOT); - // The cursor blink, the one interval open() starts on the window - expect(win.intervals).toEqual([530]); + // The cursor blink, the one interval open() starts on the window, and the refocus + // after the DOM settles, the one timeout + expect([win.intervals, win.timeouts]).toEqual([[530], [0]]); + }); + + test('dispose() clears every timeout and interval and cancels every frame (ADR 065)', async () => { + const term = await openTerminal(); + expect([win.timers.size, frames.size]).toEqual([2, 1]); + term.dispose(); + expect([win.timers.size, frames.size]).toEqual([0, 0]); }); test('a second dispose() removes nothing more', async () => { @@ -132,12 +146,12 @@ describe('Terminal listener snapshot', () => { expect(targets().map((target) => target.removed.length)).toEqual(removed); }); - test('the listeners left after dispose() are the leaking ones', async () => { + test('no listener is left after dispose(), and each went with its capture flag', async () => { const term = await openTerminal(); term.dispose(); - const left = targets().flatMap((target) => - target.active.map((record) => row(record, targets())), - ); - expect(left.sort()).toEqual(SNAPSHOT.filter((r) => r.endsWith('LEAKS')).sort()); + for (const target of targets()) { + expect(target.active).toEqual([]); + expect(new Set(target.removed)).toEqual(new Set(target.added)); + } }); }); diff --git a/src/client/vendor/tests/core/mouse-controller.test.ts b/src/client/vendor/tests/core/mouse-controller.test.ts index 6c343ac..298ff4d 100644 --- a/src/client/vendor/tests/core/mouse-controller.test.ts +++ b/src/client/vendor/tests/core/mouse-controller.test.ts @@ -379,15 +379,20 @@ describe('MouseController right-click', () => { }); describe('browserTimeouts', () => { - test("reads the page's window.setTimeout and the global clearTimeout when called", () => { + test("reads the page's window.setTimeout and window.clearTimeout when called", () => { const g = globalThis as Record<string, unknown>; const saved = g.window; const scheduled: number[] = []; - g.window = { setTimeout: (_run: () => void, ms: number) => scheduled.push(ms) }; + const cleared: unknown[] = []; + g.window = { + setTimeout: (_run: () => void, ms: number) => scheduled.push(ms), + clearTimeout: (id: unknown) => void cleared.push(id), + }; try { expect(browserTimeouts.setTimeout(() => {}, 16)).toBe(1); expect(scheduled).toEqual([16]); - browserTimeouts.clearTimeout(undefined); + browserTimeouts.clearTimeout(1); + expect(cleared).toEqual([1]); } finally { g.window = saved; } diff --git a/src/client/vendor/tests/core/scrollbar-controller.test.ts b/src/client/vendor/tests/core/scrollbar-controller.test.ts index 6df2952..caaa774 100644 --- a/src/client/vendor/tests/core/scrollbar-controller.test.ts +++ b/src/client/vendor/tests/core/scrollbar-controller.test.ts @@ -214,28 +214,57 @@ describe('ScrollbarController teardown', () => { expect([bar.opacity, clock.pending]).toEqual([1, { timers: 0, frames: 0 }]); }); - test('the document mouseup stays after dispose() until unwatchRelease() (ADR 065)', () => { + test('dispose() removes the document mouseup too, with its flags (ADR 065)', () => { dom = createDomWindow(); const doc = recordListeners(dom.document, 'document'); - const bar = new ScrollbarController( - { - canvas: () => undefined, - scrollOffset: () => undefined, - getScrollbackLength: () => 0, - rows: () => 1, - viewportY: () => 0, - scrollToLine: () => {}, - render: () => {}, - }, - new ManualClock(), - ); - bar.unwatchRelease(); + const bar = new ScrollbarController(idleHost(), new ManualClock()); + // Watching before attach() still has a store to go in bar.watchRelease(doc); bar.dispose(); + expect(doc.active).toEqual([]); + expect(doc.removed).toEqual(doc.added); + // After dispose(), watching again starts a new store, which a second dispose() releases + bar.watchRelease(doc); expect(doc.active.map((r) => r.type)).toEqual(['mouseup']); - bar.unwatchRelease(); + bar.dispose(); expect(doc.active).toEqual([]); }); + + test('dispose() cancels the next frame of every fade still running (ADR 065)', () => { + const { bar, clock, renders } = scrollbar(); + bar.show(); + expect(clock.pending.frames).toBe(1); + bar.dispose(); + const rendered = renders.length; + clock.frame(SCROLLBAR_FADE_DURATION_MS); + expect([clock.pending, renders.length]).toEqual([{ timers: 0, frames: 0 }, rendered]); + }); +}); + +/** A host with no canvas and no scrollback. */ +function idleHost(): ScrollbarHost { + return { + canvas: () => undefined, + scrollOffset: () => undefined, + getScrollbackLength: () => 0, + rows: () => 1, + viewportY: () => 0, + scrollToLine: () => {}, + render: () => {}, + }; +} + +test('the browser clock cancels a frame on the global cancelAnimationFrame', () => { + const g = globalThis as Record<string, unknown>; + const saved = g.cancelAnimationFrame; + const cancelled: number[] = []; + g.cancelAnimationFrame = (id: number) => void cancelled.push(id); + try { + browserScrollbarClock.cancelAnimationFrame(7); + } finally { + g.cancelAnimationFrame = saved; + } + expect(cancelled).toEqual([7]); }); test('the browser clock reads the wall clock', () => { diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 0f38624..1328fef 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -81,14 +81,15 @@ describe('TerminalControllers attach and detach', () => { ]); }); - test("detach() removes the container's listeners; the document's mouseup stays (ADR 065)", () => { + test("detach() removes every listener, the document's mouseup too; again does nothing (ADR 065)", () => { const { core } = controllers(); const { container, doc } = targets(); core.attach(container as unknown as HTMLElement, doc); core.detach(); + const removed = container.removed.length + doc.removed.length; core.detach(); - expect(container.active).toEqual([]); - expect(doc.active.map((r) => r.type)).toEqual(['mouseup']); + expect([container.active, doc.active]).toEqual([[], []]); + expect(container.removed.length + doc.removed.length).toBe(removed); }); }); diff --git a/src/client/vendor/tests/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts index f62871c..1e298d8 100644 --- a/src/client/vendor/tests/core/write-pipeline.test.ts +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -167,6 +167,22 @@ describe('WritePipeline order', () => { }); }); +describe('WritePipeline dispose', () => { + test('cancels the callbacks still waiting, and forgets the ones run; again does nothing (ADR 065)', () => { + const { clock, pipeline } = recorded(); + const called: string[] = []; + pipeline.write('a', () => called.push('a')); + clock.frame(); + pipeline.write('b', () => called.push('b')); + pipeline.write('c', () => called.push('c')); + expect(clock.pending.frames).toBe(2); + pipeline.dispose(); + pipeline.dispose(); + clock.frame(); + expect([called, clock.pending.frames]).toEqual([['a'], 0]); + }); +}); + describe('WritePipeline clear at ground', () => { // Ghostty's clear_screen written to the terminal (ADR 054), on a real 20x5 engine. async function engine() { diff --git a/src/client/vendor/tests/input-handler.test.ts b/src/client/vendor/tests/input-handler.test.ts index 8388d88..bd9995f 100644 --- a/src/client/vendor/tests/input-handler.test.ts +++ b/src/client/vendor/tests/input-handler.test.ts @@ -117,7 +117,7 @@ describe('InputHandler set-up and teardown', () => { const input = handler(); const added = add.map((spy) => spy.mock.calls.map(([type]) => type)); expect(added).toEqual([ - ['keydown', 'compositionstart', 'compositionupdate', 'compositionend', 'mousedown'], + ['keydown', 'compositionstart', 'compositionend', 'mousedown'], ['beforeinput'], ['mouseup', 'mousemove'], ]); @@ -125,7 +125,7 @@ describe('InputHandler set-up and teardown', () => { const removed = remove.map((spy) => spy.mock.calls.map(([type]) => type).sort()); expect(removed).toEqual(added.map((types) => [...types].sort())); input.dispose(); - expect(remove.map((spy) => spy.mock.calls.length)).toEqual([5, 1, 2]); + expect(remove.map((spy) => spy.mock.calls.length)).toEqual([4, 1, 2]); // Nothing is sent after dispose keydown('KeyA', 'a'); expect(sent).toEqual([]); diff --git a/src/client/vendor/tests/lifecycle/disposable-store.test.ts b/src/client/vendor/tests/lifecycle/disposable-store.test.ts index 1556bf2..8a16216 100644 --- a/src/client/vendor/tests/lifecycle/disposable-store.test.ts +++ b/src/client/vendor/tests/lifecycle/disposable-store.test.ts @@ -47,6 +47,16 @@ describe('DisposableStore', () => { expect(late).toBe(1); }); + test('delete forgets an item without disposing it; an unknown one is not held', () => { + let runs = 0; + const store = new DisposableStore(); + const timer = store.add(toDisposable(() => runs++)); + expect(store.delete(timer)).toBe(true); + expect(store.delete(timer)).toBe(false); + store.dispose(); + expect(runs).toBe(0); + }); + test('a throwing item does not stop the rest; the first error is rethrown', () => { const order: string[] = []; const store = new DisposableStore(); diff --git a/src/client/vendor/tests/selection/context-menu-textarea.test.ts b/src/client/vendor/tests/selection/context-menu-textarea.test.ts index 4237633..40526b7 100644 --- a/src/client/vendor/tests/selection/context-menu-textarea.test.ts +++ b/src/client/vendor/tests/selection/context-menu-textarea.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { ManualClock } from '../../../test-support/manual-clock'; import { RecordingTarget } from '../../../test-support/recording-target'; import { + browserMenuTimers, CONTEXT_MENU_RESET_DELAY_MS, ContextMenuTextarea, } from '../../ghostty-web/selection/context-menu-textarea'; @@ -14,11 +15,7 @@ function menu() { const textarea = new FakeTextarea(); const doc = new RecordingTarget('document'); const clock = new ManualClock(); - const shown = new ContextMenuTextarea( - textarea.element, - doc as unknown as Document, - clock.setTimeout, - ); + const shown = new ContextMenuTextarea(textarea.element, doc as unknown as Document, clock); return { textarea, doc, clock, menu: shown }; } @@ -81,6 +78,42 @@ describe('ContextMenuTextarea', () => { }, ); + test('dispose() cancels the wait, then the close listeners; again does nothing (ADR 065)', () => { + const { textarea, doc, clock, menu: m } = menu(); + m.show({ clientX: 1, clientY: 1 }, null); + m.dispose(); + clock.advance(CONTEXT_MENU_RESET_DELAY_MS); + expect([doc.added.length, clock.pending.timers]).toEqual([0, 0]); + // Two menus open, both listening for the close + m.show({ clientX: 1, clientY: 1 }, null); + m.show({ clientX: 2, clientY: 2 }, null); + clock.advance(CONTEXT_MENU_RESET_DELAY_MS); + expect(doc.active).toHaveLength(4); + m.dispose(); + m.dispose(); + for (const target of [doc, textarea]) { + expect(target.active).toEqual([]); + expect(target.removed).toEqual(target.added); + } + }); + + test('a menu that closed is forgotten: dispose() removes nothing more', () => { + const { textarea, doc, clock, menu: m } = menu(); + m.show({ clientX: 1, clientY: 1 }, null); + clock.advance(CONTEXT_MENU_RESET_DELAY_MS); + fire(doc, 'click'); + const removed = doc.removed.length + textarea.removed.length; + m.dispose(); + expect(doc.removed.length + textarea.removed.length).toBe(removed); + }); + + test('the browser timers are the global setTimeout and clearTimeout', async () => { + let ran = false; + browserMenuTimers.clearTimeout(browserMenuTimers.setTimeout(() => (ran = true), 0)); + await Bun.sleep(5); + expect(ran).toBe(false); + }); + test('waits on the global setTimeout by default', async () => { const textarea = new FakeTextarea(); const doc = new RecordingTarget('document'); diff --git a/src/client/vendor/tests/selection/selection-gestures.test.ts b/src/client/vendor/tests/selection/selection-gestures.test.ts index 8bfb892..714dc53 100644 --- a/src/client/vendor/tests/selection/selection-gestures.test.ts +++ b/src/client/vendor/tests/selection/selection-gestures.test.ts @@ -297,17 +297,19 @@ describe('SelectionGestures clicks', () => { }); describe('SelectionGestures dispose', () => { - test('removes the document move, release and click listeners and stops scrolling, once', () => { + test('removes every canvas and document listener and stops scrolling, once (ADR 065)', () => { const t = gestures(); expect(t.doc.added.map((r) => r.type)).toEqual(['mousemove', 'mousedown', 'mouseup', 'click']); press(t, 0, 0); moveTo(t, 140, 200); t.g.dispose(); expect(t.clock.pending.timers).toBe(0); - expect(t.doc.removed.map((r) => r.type).sort()).toEqual(['click', 'mousemove', 'mouseup']); - // The canvas listeners and the document mousedown leak until ADR 065 - expect(t.canvas.active).toHaveLength(5); + for (const target of [t.doc, t.canvas]) { + expect(target.active).toEqual([]); + // The store removes them newest first + expect([...target.removed].reverse()).toEqual(target.added); + } t.g.dispose(); - expect(t.doc.removed).toHaveLength(3); + expect(t.doc.removed.length + t.canvas.removed.length).toBe(9); }); }); diff --git a/src/client/vendor/tests/selection/selection-manager.test.ts b/src/client/vendor/tests/selection/selection-manager.test.ts index fcc927d..0a43d7d 100644 --- a/src/client/vendor/tests/selection/selection-manager.test.ts +++ b/src/client/vendor/tests/selection/selection-manager.test.ts @@ -147,7 +147,7 @@ describe('SelectionManager copy on select', () => { }); describe('SelectionManager dispose', () => { - test('removes the document listeners it added, stops the auto-scroll, and runs once', async () => { + test('removes every listener it added, stops the auto-scroll, and runs once (ADR 065)', async () => { const ghostty = await loadGhostty(); const t = ghostty.createTerminal(10, 2); const f = selectionFixture(t); @@ -156,18 +156,17 @@ describe('SelectionManager dispose', () => { expect(f.clock.pending.timers).toBe(1); f.m.dispose(); expect(f.clock.pending.timers).toBe(0); - // The document mousedown and the canvas listeners leak until ADR 065 - expect(f.doc.active.map((r) => r.type)).toEqual(['mousedown']); - expect(f.canvas.active.map((r) => r.type)).toEqual([ + expect([f.doc.active, f.canvas.active]).toEqual([[], []]); + expect(f.canvas.removed.map((r) => r.type).sort()).toEqual([ + 'click', 'mousedown', - 'mousemove', - 'mouseleave', 'mouseenter', - 'click', + 'mouseleave', + 'mousemove', ]); - const removed = f.doc.removed.length; + const removed = f.doc.removed.length + f.canvas.removed.length; f.m.dispose(); - expect(f.doc.removed.length).toBe(removed); + expect(f.doc.removed.length + f.canvas.removed.length).toBe(removed); t.free(); }); }); diff --git a/src/client/vendor/tests/support/fake-event-target.ts b/src/client/vendor/tests/support/fake-event-target.ts index f19d9be..eabd14c 100644 --- a/src/client/vendor/tests/support/fake-event-target.ts +++ b/src/client/vendor/tests/support/fake-event-target.ts @@ -119,15 +119,20 @@ export class FakeMediaQueryList extends RecordingTarget { } /** - * A window at device pixel ratio 1 whose intervals are logged and never run, so nothing - * the terminal schedules on it fires after the test. + * A window at device pixel ratio 1 whose timeouts and intervals are logged and never run, + * so nothing the terminal schedules on it fires after the test. */ export class FakeWindow extends RecordingTarget { readonly devicePixelRatio = 1; /** The delay of every interval set, in order. */ readonly intervals: number[] = []; + /** The delay of every timeout set, in order. */ + readonly timeouts: number[] = []; + /** The ids of the timeouts and intervals set and not cleared yet. */ + readonly timers = new Set<number>(); /** The media queries `matchMedia` handed out, in order. */ readonly mediaQueries: FakeMediaQueryList[] = []; + private nextTimer = 1; constructor(timeline: ListenerRecord[]) { super('window'); @@ -142,10 +147,26 @@ export class FakeWindow extends RecordingTarget { } setInterval(_handler: () => void, ms = 0): number { - return this.intervals.push(ms); + this.intervals.push(ms); + return this.addTimer(); } - clearTimeout(): void {} + setTimeout(_handler: () => void, ms = 0): number { + this.timeouts.push(ms); + return this.addTimer(); + } + + clearTimeout(id?: number): void { + if (id !== undefined) this.timers.delete(id); + } - clearInterval(): void {} + clearInterval(id?: number): void { + if (id !== undefined) this.timers.delete(id); + } + + private addTimer(): number { + const id = this.nextTimer++; + this.timers.add(id); + return id; + } } diff --git a/src/client/vendor/tests/support/selection-fixture.ts b/src/client/vendor/tests/support/selection-fixture.ts index 9a6b3e6..0aa4ff1 100644 --- a/src/client/vendor/tests/support/selection-fixture.ts +++ b/src/client/vendor/tests/support/selection-fixture.ts @@ -113,7 +113,7 @@ export function selectionFixture( { document: doc as unknown as Document, timers: clock, - setTimeout: clock.setTimeout, + menuTimers: clock, clipboard: () => clipboard, }, ); diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/terminal.test.ts index ab65c9b..5ed3c06 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/terminal.test.ts @@ -161,16 +161,25 @@ describe('Terminal lifecycle', () => { }); test('focus() focuses the container now and again after a tick', async () => { - const { term, container, doc } = await open(); + const { term, container, doc, clock } = await open(); const focused = () => doc.activeElement; doc.activeElement = null; term.focus(); expect(focused()).toBe(container); doc.activeElement = null; - await new Promise((resolve) => setTimeout(resolve, 1)); + clock.advance(0); expect(focused()).toBe(container); }); + test('dispose() cancels a refocus still waiting (ADR 065)', async () => { + const { term, doc, clock } = await open(); + term.focus(); + term.dispose(); + doc.activeElement = null; + clock.advance(0); + expect(doc.activeElement).toBeNull(); + }); + test('before open() the read-only API answers empty', () => { const term = new Terminal({ ghostty: {} as never }); expect(term.getScrollbackLength()).toBe(0); From 21575cef7baa1a6ab2760c8abe4f40855c5e5558 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:47:26 -0400 Subject: [PATCH 256/295] docs(arch): listener teardown done; the snapshot and dispose test after ADR 065 --- docs/specs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 516d0de..825b0c0 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -322,7 +322,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a - **Order:** the client root has an install-order test; the per-message pipeline and the on-open sequence have call-order tests; the key dispatcher has a case-by-case equivalence test against the old two-listener behaviour. - **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. - **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. -- **Listener snapshots.** Before the vendored facades are split, a test (`vendor/tests/core/listener-snapshot.test.ts`) opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. +- **Listener snapshots.** Before the vendored facades are split, a test (`vendor/tests/core/listener-snapshot.test.ts`) opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. Since that commit every row is removed, and `vendor/tests/core/dispose.test.ts` checks that `dispose()` also clears every timeout, interval and animation frame, including after a failed `open()`. - **Coverage targets** (`bun run test:coverage`): app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov`, enforces the per-layer targets from `coverage/lcov.info`. ## Features @@ -335,7 +335,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a | Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ✅ | | Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | -| Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ⬜ | +| Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ✅ | | Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ⬜ | | CLI command modules | `main.ts`, WebttyClient, one file per command with injected deps | [ADR 063](../adrs/063.cli.command-modules.md) | ⬜ | | Test strategy | test-support fakes, injected happy-dom, in-process server tests, vendor test mirror, listener snapshots, per-layer coverage check | [ADR 064](../adrs/064.architecture.test-strategy.md) | ⬜ | From 81756ed950949536bfffc2f8dd2cf2ff12fdf60d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:50:05 -0400 Subject: [PATCH 257/295] test(client): the listener snapshot shows a leaking listener as '-> LEAKS' again The ADR 065 step collapsed both branches of the row label to '-> removed', so the snapshot could no longer show a leak. Restore the false branch. --- src/client/vendor/tests/core/listener-snapshot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/vendor/tests/core/listener-snapshot.test.ts b/src/client/vendor/tests/core/listener-snapshot.test.ts index 854d0f8..0373073 100644 --- a/src/client/vendor/tests/core/listener-snapshot.test.ts +++ b/src/client/vendor/tests/core/listener-snapshot.test.ts @@ -18,7 +18,7 @@ function row(record: ListenerRecord, targets: readonly RecordingTarget[]): strin ].filter(Boolean); const target = targets.find((t) => t.name === record.target); const removed = target?.removed.includes(record) ?? false; - return [record.target, record.type, ...flags, removed ? '-> removed' : '-> removed'].join(' '); + return [record.target, record.type, ...flags, removed ? '-> removed' : '-> LEAKS'].join(' '); } /** The listeners, in the order open() adds them; dispose() removes every one (ADR 065). */ From 937ce882910c9e8e355686d3b7f28093a0ffe870 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:52:45 -0400 Subject: [PATCH 258/295] refactor(client): remove the vendor types.ts shim index.ts and link-detector.ts import the domain type files (input/key-types.ts, engine/cell-types.ts, link-types.ts) directly, the two engine tests do too, and the shim is deleted with its ratchet allowlist entry (ADR 061). PROVENANCE.md records the removal. --- docs/specs/architecture.md | 4 ++-- scripts/architecture/ratchet-allowlist.ts | 2 -- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/ghostty-web/index.ts | 7 ++++--- src/client/vendor/ghostty-web/link-detector.ts | 2 +- src/client/vendor/ghostty-web/types.ts | 11 ----------- .../vendor/tests/engine/key-encoder-binding.test.ts | 2 +- .../vendor/tests/engine/mouse-encoder-binding.test.ts | 2 +- 8 files changed, 10 insertions(+), 22 deletions(-) delete mode 100644 src/client/vendor/ghostty-web/types.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 825b0c0..edfffd4 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -192,7 +192,7 @@ ghostty-web/ renderer.ts facade: CanvasRenderer orchestration (≤ 500) input-handler.ts facade: InputHandler (options object) (≤ 300) selection-manager.ts facade: SelectionManager state + API (SelectionHost) (≤ 300) - interfaces.ts buffer.ts (IBufferSource) event-emitter.ts types.ts (re-export shim until removed) + interfaces.ts buffer.ts (IBufferSource) event-emitter.ts search.ts glide.ts wheel.ts mouse-route.ts webtty-original, kept in place link-detector.ts link-types.ts addons/fit.ts fn-table.ts providers/ osc8-link-provider.ts url-regex-provider.ts open-link.ts (openLinkOnModifier, the one activation rule) @@ -301,7 +301,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a - **Feature installers** are `<name>-feature.ts` exporting `install<Name>`. - **Narrow collaborator interfaces** are `<Thing>Host` (what an object needs from its owner) or `<Thing>Source` (what it reads). - **Constants** sit at the top of the one module that uses them; a `*-constants.ts` file exists only when several modules share them. -- **Banned:** `utils.ts`, `helpers.ts`, `misc.ts`, `common/`, and a bare `types.ts` at a folder root (the vendor `types.ts` is a temporary re-export shim). `index.ts` is only an entry or the vendor barrel. +- **Banned:** `utils.ts`, `helpers.ts`, `misc.ts`, `common/`, and a bare `types.ts` at a folder root (type files are named for their domain: `key-types.ts`, `link-types.ts`). `index.ts` is only an entry or the vendor barrel. - **Basenames are unique** within `src/client` and within `src/server` where practical, so editor tabs and stack traces read without the path. ## Comments diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 09bf4da..9b134aa 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -25,7 +25,5 @@ export const RATCHET_ALLOWLIST: Allowlist = { 'test-import src/version.test.ts -> src/utils.test.ts': SERVER_HARNESS, 'banned-name src/utils.test.ts': SERVER_HARNESS, 'banned-name src/pty/types.ts': 'server track: pty/pty-process.ts (ADR 062)', - 'banned-name src/client/vendor/ghostty-web/types.ts': - 'V15: the vendor types.ts shim is removed (ADR 061)', }, }; diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 3600c06..825b6ca 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -79,7 +79,6 @@ Not from upstream; each starts with, or will start with when it is next touched, - `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`), the render loop (`render-loop.ts`), link hover and clicks, the mouse report encoder and the right-click route (`mouse-controller.ts`), wheel routing (`wheel-controller.ts`), the scrollbar's press, drag and fade (`scrollbar-controller.ts`), those controllers made and wired together with the input route (`terminal-controllers.ts`), the emitters behind the `onX` events (`terminal-emitters.ts`), what an option set after `open()` changes (`option-changes.ts`), the DOM `open()` builds (`dom-setup.ts`) and the parts it holds (`opened-parts.ts`). `terminal.ts` is the facade over them and re-exports what moved. - `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. -- `types.ts`: a re-export shim for the files above until its importers move (ADR 061). ## Moves and splits @@ -169,3 +168,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `terminal.ts`: the body of `jumpToPrompt` (webtty, ADR 057) | `core/viewport.ts` (`Viewport.jumpToPrompt`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | | `terminal.ts`: what the scrollbar reaches of `Terminal` | `core/host-types.ts` (`ScrollbarClock`, `ScrollbarHost`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | | `terminal.ts`: the second, deferred `element.focus()` of `focus()` | `core/focus-tracker.ts` (`FocusTracker.focusContainer`) | fix(client): release every listener on dispose (ADR 065) | +| `types.ts`: the re-export shim left by the split above; its last importers (`index.ts`, `link-detector.ts`) import `input/key-types.ts`, `engine/cell-types.ts` and `link-types.ts`, and the file is deleted | `input/key-types.ts`, `engine/cell-types.ts`, `link-types.ts` | refactor(client): the vendor types.ts shim removed | diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index edc4cea..3b52d59 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -29,8 +29,9 @@ export type { KeyEncoder, TerminalEffects, } from './ghostty'; -export { Key, KeyAction, Mods } from './types'; -export type { KeyEvent, GhosttyCell } from './types'; +export { Key, KeyAction, Mods } from './input/key-types'; +export type { KeyEvent } from './input/key-types'; +export type { GhosttyCell } from './engine/cell-types'; // Low-level components (for custom integrations) export { CanvasRenderer } from './renderer'; @@ -50,7 +51,7 @@ export type { ITerminalDimensions } from './addons/fit'; export { OSC8LinkProvider } from './providers/osc8-link-provider'; export { UrlRegexProvider } from './providers/url-regex-provider'; export { LinkDetector } from './link-detector'; -export type { ILink, ILinkProvider, IBufferCellPosition } from './types'; +export type { ILink, ILinkProvider, IBufferCellPosition } from './link-types'; // webtty's scrolling motion (ADR 045), DPR tracking (ADR 039) and teardown (ADR 065) export { AT_ONCE, easeOutCubic, FRAME_MS, Glide, linear } from './glide'; diff --git a/src/client/vendor/ghostty-web/link-detector.ts b/src/client/vendor/ghostty-web/link-detector.ts index 6ddd6b4..1b73ba8 100644 --- a/src/client/vendor/ghostty-web/link-detector.ts +++ b/src/client/vendor/ghostty-web/link-detector.ts @@ -7,7 +7,7 @@ */ import { cellInRange } from './render/cell-range'; -import type { ILink, ILinkProvider } from './types'; +import type { ILink, ILinkProvider } from './link-types'; /** * Manages link detection across multiple providers with intelligent caching diff --git a/src/client/vendor/ghostty-web/types.ts b/src/client/vendor/ghostty-web/types.ts deleted file mode 100644 index 7b11a1b..0000000 --- a/src/client/vendor/ghostty-web/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Derived from coder/ghostty-web lib/types.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). -// A re-export shim (types) for the files that still import './types': the definitions live -// in input/key-types.ts, engine/cell-types.ts, engine/engine-types.ts and link-types.ts. -// V15 moves the importers to those paths and removes this file (ADR 061). - -export { Key, KeyAction, KeyEncoderOption, Mods } from './input/key-types'; -export type { KeyEvent } from './input/key-types'; -export { CellFlags, DirtyState } from './engine/cell-types'; -export type { GhosttyCell, RenderStateCursor } from './engine/cell-types'; -export type { GhosttyTerminalConfig } from './engine/engine-types'; -export type { IBufferCellPosition, IBufferRange, ILink, ILinkProvider } from './link-types'; diff --git a/src/client/vendor/tests/engine/key-encoder-binding.test.ts b/src/client/vendor/tests/engine/key-encoder-binding.test.ts index 8a4600f..c8df009 100644 --- a/src/client/vendor/tests/engine/key-encoder-binding.test.ts +++ b/src/client/vendor/tests/engine/key-encoder-binding.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, test } from 'bun:test'; import type { Ghostty } from '../../ghostty-web/ghostty'; -import { Key, KeyAction, KeyEncoderOption, Mods } from '../../ghostty-web/types'; +import { Key, KeyAction, KeyEncoderOption, Mods } from '../../ghostty-web/input/key-types'; import { loadGhostty } from '../support/wasm'; // The wasm key encoder (ADR 042, ADR 061), made by the Ghostty factory. diff --git a/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts index 5b678a6..e071e5f 100644 --- a/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts +++ b/src/client/vendor/tests/engine/mouse-encoder-binding.test.ts @@ -10,7 +10,7 @@ import type { MouseInput, OfficialTerminal, } from '../../ghostty-web/ghostty'; -import { Mods } from '../../ghostty-web/types'; +import { Mods } from '../../ghostty-web/input/key-types'; import { bindingContext, freeContext, writeVt } from '../support/engine-harness'; import { THEME } from '../support/terminal-text'; import { countedGhostty, loadGhostty } from '../support/wasm'; From 95bd8cf2ab8b37d58536a85b1e7422abf466500f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:53:40 -0400 Subject: [PATCH 259/295] refactor(client): the vendor test mirror, the last flat tests moved Each test of a file at the ghostty-web/ root moves (git mv, imports only) into the folder of its domain: terminal, buffer, event-emitter and glide into core/, renderer and search into render/, input-handler, wheel and mouse-route into input/, official-replay into engine/. barrel.test.ts and ghostty-vt.test.ts stay at the root (ADR 061, ADR 064). --- docs/specs/architecture.md | 2 +- docs/specs/client.md | 2 +- src/client/vendor/ghostty-web/event-emitter.ts | 2 +- src/client/vendor/tests/{ => core}/buffer.test.ts | 4 ++-- .../vendor/tests/{ => core}/event-emitter.test.ts | 2 +- src/client/vendor/tests/{ => core}/glide.test.ts | 2 +- .../vendor/tests/core/terminal-controllers.test.ts | 2 +- src/client/vendor/tests/{ => core}/terminal.test.ts | 8 ++++---- .../tests/{ => engine}/official-replay.test.ts | 4 ++-- .../vendor/tests/{ => input}/input-handler.test.ts | 12 ++++++------ .../vendor/tests/{ => input}/mouse-route.test.ts | 2 +- src/client/vendor/tests/{ => input}/wheel.test.ts | 2 +- .../vendor/tests/{ => render}/renderer.test.ts | 8 ++++---- src/client/vendor/tests/{ => render}/search.test.ts | 2 +- 14 files changed, 27 insertions(+), 27 deletions(-) rename src/client/vendor/tests/{ => core}/buffer.test.ts (95%) rename src/client/vendor/tests/{ => core}/event-emitter.test.ts (97%) rename src/client/vendor/tests/{ => core}/glide.test.ts (99%) rename src/client/vendor/tests/{ => core}/terminal.test.ts (99%) rename src/client/vendor/tests/{ => engine}/official-replay.test.ts (99%) rename src/client/vendor/tests/{ => input}/input-handler.test.ts (97%) rename src/client/vendor/tests/{ => input}/mouse-route.test.ts (98%) rename src/client/vendor/tests/{ => input}/wheel.test.ts (99%) rename src/client/vendor/tests/{ => render}/renderer.test.ts (97%) rename src/client/vendor/tests/{ => render}/search.test.ts (99%) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index edfffd4..741987f 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -314,7 +314,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a ## Testing -- **Placement.** A test sits next to its module as `x.test.ts`. Vendor tests live in `src/client/vendor/tests/`, mirroring `ghostty-web/` (the vendored tree is Biome-excluded, its tests are not). Shared fakes live in `test-support/` folders. +- **Placement.** A test sits next to its module as `x.test.ts`. Vendor tests live in `src/client/vendor/tests/`, mirroring `ghostty-web/` (the vendored tree is Biome-excluded, its tests are not). A subfolder's tests sit in the same subfolder; a file at the `ghostty-web/` root is tested in the folder of its domain (`terminal`, `buffer`, `event-emitter`, `glide` in `core/`; `renderer`, `search` in `render/`; `input-handler`, `wheel`, `mouse-route` in `input/`; `link-detector` and `providers/` in `links/`), and only `barrel.test.ts` (the public surface) and `ghostty-vt.test.ts` (the sibling `vendor/ghostty-vt/` and its signature) stay at the root. Shared fakes live in `test-support/` folders. - **Pure modules:** table-driven tests, 100% lines. - **Objects and services:** tested through their public API with fakes from `test-support/` (`FakeSocket`, `ManualClock` for timers and rAF, `FakeTerminal`, `RecordingTarget` for listeners). - **Views and features:** `happy-dom` (pinned devDependency), used as an explicit `new Window()` whose `document` is injected; never registered globally, so tests that stub globals are not disturbed (ADR 064). diff --git a/docs/specs/client.md b/docs/specs/client.md index 04934ed..58eac99 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -57,7 +57,7 @@ src/client/ checked offline by vendor/tests/ghostty-vt.test.ts (size, sha256, minisign signature) tests/ ← the vendored layer's tests (Biome-linted); support/wasm.ts is the one wasm path and compiles it once; fixtures/ holds PTY captures replayed in 1-byte and 512-byte writes - by vendor/tests/official-replay.test.ts + by vendor/tests/engine/official-replay.test.ts scripts/update-ghostty-vt.ts ← fetches and verifies a new pin (the only networked step); --verify re-checks offline ``` diff --git a/src/client/vendor/ghostty-web/event-emitter.ts b/src/client/vendor/ghostty-web/event-emitter.ts index 7118671..6bc40d4 100644 --- a/src/client/vendor/ghostty-web/event-emitter.ts +++ b/src/client/vendor/ghostty-web/event-emitter.ts @@ -7,7 +7,7 @@ import type { IDisposable, IEvent } from './interfaces'; /** * One event's listeners, called in subscription order. `fire` walks the live list, so a * listener that unsubscribes itself while it runs makes the next listener miss that fire - * (as upstream does; pinned by vendor/tests/event-emitter.test.ts). + * (as upstream does; pinned by vendor/tests/core/event-emitter.test.ts). */ export class EventEmitter<T> { private listeners: Array<(arg: T) => void> = []; diff --git a/src/client/vendor/tests/buffer.test.ts b/src/client/vendor/tests/core/buffer.test.ts similarity index 95% rename from src/client/vendor/tests/buffer.test.ts rename to src/client/vendor/tests/core/buffer.test.ts index e9c22ec..fa5d7a0 100644 --- a/src/client/vendor/tests/buffer.test.ts +++ b/src/client/vendor/tests/core/buffer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { type BufferEngine, BufferNamespace } from '../ghostty-web/buffer'; -import { loadGhostty } from './support/wasm'; +import { type BufferEngine, BufferNamespace } from '../../ghostty-web/buffer'; +import { loadGhostty } from '../support/wasm'; // The buffer API is what the link providers and the kitty graphics read: the // cursor, the line count, and each cell's codepoint and hyperlink id. diff --git a/src/client/vendor/tests/event-emitter.test.ts b/src/client/vendor/tests/core/event-emitter.test.ts similarity index 97% rename from src/client/vendor/tests/event-emitter.test.ts rename to src/client/vendor/tests/core/event-emitter.test.ts index c4ccff6..46ecfd9 100644 --- a/src/client/vendor/tests/event-emitter.test.ts +++ b/src/client/vendor/tests/core/event-emitter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { EventEmitter } from '../ghostty-web/event-emitter'; +import { EventEmitter } from '../../ghostty-web/event-emitter'; describe('EventEmitter', () => { test('fires to every listener in subscription order', () => { diff --git a/src/client/vendor/tests/glide.test.ts b/src/client/vendor/tests/core/glide.test.ts similarity index 99% rename from src/client/vendor/tests/glide.test.ts rename to src/client/vendor/tests/core/glide.test.ts index c66041a..50a5283 100644 --- a/src/client/vendor/tests/glide.test.ts +++ b/src/client/vendor/tests/core/glide.test.ts @@ -7,7 +7,7 @@ import { SPRING_RELEASE_MS, springOmega, springStep, -} from '../ghostty-web/glide'; +} from '../../ghostty-web/glide'; describe('springStep', () => { test('approaches a target from rest without overshooting', () => { diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 1328fef..92bf2e5 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -10,7 +10,7 @@ import { TerminalEmitters } from '../../ghostty-web/core/terminal-emitters'; // Terminal's controllers as one unit: the order attach() adds their listeners in, what // detach() takes away, and the input route. Each controller has its own tests; the -// wiring is exercised whole by vendor/tests/terminal.test.ts. +// wiring is exercised whole by vendor/tests/core/terminal.test.ts. function controllers(options: { disableStdin?: boolean } = {}) { const log: string[] = []; diff --git a/src/client/vendor/tests/terminal.test.ts b/src/client/vendor/tests/core/terminal.test.ts similarity index 99% rename from src/client/vendor/tests/terminal.test.ts rename to src/client/vendor/tests/core/terminal.test.ts index 5ed3c06..ffe9721 100644 --- a/src/client/vendor/tests/terminal.test.ts +++ b/src/client/vendor/tests/core/terminal.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import type { ITerminalAddon } from '../ghostty-web/interfaces'; -import { Terminal } from '../ghostty-web/terminal'; +import type { ITerminalAddon } from '../../ghostty-web/interfaces'; +import { Terminal } from '../../ghostty-web/terminal'; import { engineOf, openTerminal, @@ -9,8 +9,8 @@ import { scrollbackLines, stubGlobals, type TerminalHarness, -} from './support/terminal-harness'; -import { markedSession } from './support/terminal-text'; +} from '../support/terminal-harness'; +import { markedSession } from '../support/terminal-text'; // The Terminal facade through its public API, opened on fakes over the real engine. Its // collaborators have their own tests in core/ (ADR 061). diff --git a/src/client/vendor/tests/official-replay.test.ts b/src/client/vendor/tests/engine/official-replay.test.ts similarity index 99% rename from src/client/vendor/tests/official-replay.test.ts rename to src/client/vendor/tests/engine/official-replay.test.ts index 6cf33e4..024a044 100644 --- a/src/client/vendor/tests/official-replay.test.ts +++ b/src/client/vendor/tests/engine/official-replay.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from 'bun:test'; import fs from 'node:fs'; import path from 'node:path'; -import type { Ghostty, GhosttyTerminal } from '../ghostty-web/ghostty'; -import { FIXTURES_DIR, loadGhostty } from './support/wasm'; +import type { Ghostty, GhosttyTerminal } from '../../ghostty-web/ghostty'; +import { FIXTURES_DIR, loadGhostty } from '../support/wasm'; // The official engine (ADR 042, ADR 047) replaying the same input two ways, cell // for cell. A terminal that was scrolled, paged and cached while the input arrived diff --git a/src/client/vendor/tests/input-handler.test.ts b/src/client/vendor/tests/input/input-handler.test.ts similarity index 97% rename from src/client/vendor/tests/input-handler.test.ts rename to src/client/vendor/tests/input/input-handler.test.ts index bd9995f..911c941 100644 --- a/src/client/vendor/tests/input-handler.test.ts +++ b/src/client/vendor/tests/input/input-handler.test.ts @@ -1,15 +1,15 @@ import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; -import { createDomWindow, type DomWindow } from '../../test-support/dom-window'; -import { ManualClock } from '../../test-support/manual-clock'; -import type { Ghostty } from '../ghostty-web/ghostty'; -import { Key, KeyAction, type KeyEvent, Mods } from '../ghostty-web/input/key-types'; +import { createDomWindow, type DomWindow } from '../../../test-support/dom-window'; +import { ManualClock } from '../../../test-support/manual-clock'; +import type { Ghostty } from '../../ghostty-web/ghostty'; +import { Key, KeyAction, type KeyEvent, Mods } from '../../ghostty-web/input/key-types'; import { InputHandler, type InputHandlerOptions, type MouseEventReport, type MouseTrackingConfig, -} from '../ghostty-web/input-handler'; -import { loadGhostty } from './support/wasm'; +} from '../../ghostty-web/input-handler'; +import { loadGhostty } from '../support/wasm'; // The InputHandler facade (ADR 061): its listeners, and what keys, IME compositions, // beforeinput and mouse events send through it. diff --git a/src/client/vendor/tests/mouse-route.test.ts b/src/client/vendor/tests/input/mouse-route.test.ts similarity index 98% rename from src/client/vendor/tests/mouse-route.test.ts rename to src/client/vendor/tests/input/mouse-route.test.ts index c3dd2dc..af04764 100644 --- a/src/client/vendor/tests/mouse-route.test.ts +++ b/src/client/vendor/tests/input/mouse-route.test.ts @@ -5,7 +5,7 @@ import { type RightClickAction, type RightClickBehavior, rightClickAction, -} from '../ghostty-web/mouse-route'; +} from '../../ghostty-web/mouse-route'; describe('pressToApp', () => { test('a press is the app’s only while it tracks the mouse and Shift is up', () => { diff --git a/src/client/vendor/tests/wheel.test.ts b/src/client/vendor/tests/input/wheel.test.ts similarity index 99% rename from src/client/vendor/tests/wheel.test.ts rename to src/client/vendor/tests/input/wheel.test.ts index ba344a1..945369c 100644 --- a/src/client/vendor/tests/wheel.test.ts +++ b/src/client/vendor/tests/input/wheel.test.ts @@ -5,7 +5,7 @@ import { wheelArrowKey, wheelArrows, wheelTicks, -} from '../ghostty-web/wheel'; +} from '../../ghostty-web/wheel'; describe('wheelTicks', () => { const fresh = (): WheelAccumulator => ({ accum: 0, dir: 0 }); diff --git a/src/client/vendor/tests/renderer.test.ts b/src/client/vendor/tests/render/renderer.test.ts similarity index 97% rename from src/client/vendor/tests/renderer.test.ts rename to src/client/vendor/tests/render/renderer.test.ts index fae21cf..2bb2ea0 100644 --- a/src/client/vendor/tests/renderer.test.ts +++ b/src/client/vendor/tests/render/renderer.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from 'bun:test'; -import { ManualClock } from '../../test-support/manual-clock'; -import { CanvasRenderer } from '../ghostty-web/renderer'; -import { rasterCanvas } from './support/raster-canvas'; +import { ManualClock } from '../../../test-support/manual-clock'; +import { CanvasRenderer } from '../../ghostty-web/renderer'; +import { rasterCanvas } from '../support/raster-canvas'; import { COLS, previousFrame, ROWS, rasterRenderer, writtenTerminal, -} from './support/renderer-harness'; +} from '../support/renderer-harness'; // The facade: options and deps, the frame's stale and resize triggers, the getters and // teardown. What it paints is tested with the painters in render/. diff --git a/src/client/vendor/tests/search.test.ts b/src/client/vendor/tests/render/search.test.ts similarity index 99% rename from src/client/vendor/tests/search.test.ts rename to src/client/vendor/tests/render/search.test.ts index e83f6dc..6900c82 100644 --- a/src/client/vendor/tests/search.test.ts +++ b/src/client/vendor/tests/render/search.test.ts @@ -6,7 +6,7 @@ import { matchesInRows, spansKey, viewportYToShow, -} from '../ghostty-web/search'; +} from '../../ghostty-web/search'; // Find (ADR 055): highlights from libghostty-vt's newest-to-oldest matches, and the view. From 51ab19452689513d795f0133779308317e6b23bc Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 20:56:33 -0400 Subject: [PATCH 260/295] refactor(client): every vendored file names its origin; PROVENANCE.md completed glide.ts, wheel.ts, mouse-route.ts and search.ts start with the webtty-original header, interfaces.ts and link-detector.ts with the Coder-derived one (upstream's doc comment folded in, the interfaces banner dropped, a JSDoc line per export). The index.ts and terminal.ts headers describe the finished barrel and facade. PROVENANCE.md lists every webtty-original file, describes the whole layout (root, selection/, providers/, addons/, every engine binding) and says how to follow moved code (git log --follow for whole files, git blame -C -C and the moves table for splits). The new vendor/tests/provenance.test.ts checks the headers, the list and the moves table's targets (ADR 041, ADR 061). --- docs/specs/architecture.md | 2 +- src/client/vendor/ghostty-web/PROVENANCE.md | 24 ++++-- src/client/vendor/ghostty-web/glide.ts | 12 +-- src/client/vendor/ghostty-web/index.ts | 3 +- src/client/vendor/ghostty-web/interfaces.ts | 17 +++-- .../vendor/ghostty-web/link-detector.ts | 11 +-- src/client/vendor/ghostty-web/mouse-route.ts | 10 +-- src/client/vendor/ghostty-web/search.ts | 17 ++--- src/client/vendor/ghostty-web/terminal.ts | 7 +- src/client/vendor/ghostty-web/wheel.ts | 13 ++-- src/client/vendor/tests/provenance.test.ts | 75 +++++++++++++++++++ 11 files changed, 139 insertions(+), 52 deletions(-) create mode 100644 src/client/vendor/tests/provenance.test.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 741987f..7be9a65 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -314,7 +314,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a ## Testing -- **Placement.** A test sits next to its module as `x.test.ts`. Vendor tests live in `src/client/vendor/tests/`, mirroring `ghostty-web/` (the vendored tree is Biome-excluded, its tests are not). A subfolder's tests sit in the same subfolder; a file at the `ghostty-web/` root is tested in the folder of its domain (`terminal`, `buffer`, `event-emitter`, `glide` in `core/`; `renderer`, `search` in `render/`; `input-handler`, `wheel`, `mouse-route` in `input/`; `link-detector` and `providers/` in `links/`), and only `barrel.test.ts` (the public surface) and `ghostty-vt.test.ts` (the sibling `vendor/ghostty-vt/` and its signature) stay at the root. Shared fakes live in `test-support/` folders. +- **Placement.** A test sits next to its module as `x.test.ts`. Vendor tests live in `src/client/vendor/tests/`, mirroring `ghostty-web/` (the vendored tree is Biome-excluded, its tests are not). A subfolder's tests sit in the same subfolder; a file at the `ghostty-web/` root is tested in the folder of its domain (`terminal`, `buffer`, `event-emitter`, `glide` in `core/`; `renderer`, `search` in `render/`; `input-handler`, `wheel`, `mouse-route` in `input/`; `link-detector` and `providers/` in `links/`), and only the tests of the layer as a whole stay at the root: `barrel.test.ts` (the public surface), `provenance.test.ts` (every file's origin header and the PROVENANCE.md lists) and `ghostty-vt.test.ts` (the sibling `vendor/ghostty-vt/` and its signature). Shared fakes live in `test-support/` folders. - **Pure modules:** table-driven tests, 100% lines. - **Objects and services:** tested through their public API with fakes from `test-support/` (`FakeSocket`, `ManualClock` for timers and rAF, `FakeTerminal`, `RecordingTarget` for listeners). - **Views and features:** `happy-dom` (pinned devDependency), used as an explicit `new Window()` whose `document` is injected; never registered globally, so tests that stub globals are not disturbed (ADR 064). diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 825b6ca..870e02b 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -8,7 +8,10 @@ Vendored copy of [coder/ghostty-web](https://github.com/coder/ghostty-web), MIT `bun run build` writes the notice to `dist/THIRD_PARTY_LICENSES`. -webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. Lines moved out of a file into a subfolder (ADR 061) are followed with `git log --follow` and the **Moves and splits** table below. +webtty edits to these files are tracked in git from the commit that added them; diff against that commit to see them. Code moved by ADR 061 is followed this way: + +- A whole file that moved (the tests into `../tests/`, for example) keeps its history: `git log --follow -- <new path>`. +- Lines split out of a file into a new one are listed in the **Moves and splits** table below with the commit that moved them, and `git blame -C -C -- <new file>` attributes each moved line to the commit that last changed it in the file it came from. `git log --follow` alone does not cross a split, since the new file is not a rename of the old one. ## Local changes @@ -59,26 +62,33 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: `open()` sends the cell's device pixels to the new engine without the reply drain `syncCellPixelSize` adds: on a new engine mode 2048 is off, so the call makes no reply and the drain had nothing to send (ADR 042, ADR 061). - `terminal.ts`: the scrollbar's fade reads an injected clock (`Date.now()` and the browser's frames and timeouts by default, as before), and the fade in and fade out are one `fade('in' | 'out')` with the same opacity formulas (ADR 061). - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). +- `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). ## webtty-original files -Not from upstream; each starts with, or will start with when it is next touched, `// webtty (ADR NNN), not from upstream.` +Not from upstream; each starts with `// webtty (ADR NNN), not from upstream.` (`vendor/tests/provenance.test.ts` checks the header and this list). - `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). -- `core/focus-tracker.ts` (ADR 053), `core/find-controller.ts` (ADR 055): `Terminal`'s focus reports and find, which webtty added, moved out of `terminal.ts` (ADR 061). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. +- `core/host-types.ts` (ADR 061): the narrow interfaces `Terminal`'s parts and `SelectionManager` see of their host, where upstream reached into `Terminal` through `any`. +- `core/focus-tracker.ts` (ADR 053), `core/find-controller.ts` (ADR 055): `Terminal`'s focus reports and find, which webtty added, moved out of `terminal.ts` (ADR 061). - `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts`, `render/row-repainter.ts`, `render/scroll-tracker.ts` (ADR 043), `render/decoration-tracker.ts` (ADR 043, ADR 055), `render/viewport-clip.ts` (ADR 040, ADR 043): renderer parts webtty added, moved out of `renderer.ts` (ADR 061). -- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043, ADR 061): webtty's binding to the official wasm, moved out of `ghostty.ts` (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. +- `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043), `engine/grid-refs.ts` (ADR 042, ADR 050), `engine/selection-binding.ts` (ADR 050, ADR 057), `engine/search-binding.ts` (ADR 055), `engine/mouse-encoder-binding.ts` (ADR 049), `engine/paste-focus-binding.ts` (ADR 049, ADR 053), `engine/engine-effects.ts` (ADR 042, ADR 052, ADR 056), `engine/engine-config.ts` (ADR 042, ADR 046, ADR 049): webtty's binding to the official wasm, moved out of `ghostty.ts` and split by feature (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. + +Every other `.ts` file here except `fn-table.ts` is derived from Coder's code and starts with `// Derived from coder/ghostty-web lib/<original>.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/).`, naming the upstream file its lines came from. ## Layout +- The root: the five facades (`terminal.ts`, `renderer.ts`, `input-handler.ts`, `selection-manager.ts`, `ghostty.ts`), each keeping its upstream path and public class and re-exporting what moved out of it; the public barrel (`index.ts`); the xterm.js-compatible types (`interfaces.ts`), buffer API (`buffer.ts`) and event emitter (`event-emitter.ts`); link detection (`link-detector.ts`, with its contract `link-types.ts`); webtty's motion, wheel, mouse route and find helpers (`glide.ts`, `wheel.ts`, `mouse-route.ts`, `search.ts`); and `fn-table.ts` from ghostty-vt-js. - `lifecycle/`: teardown (`disposable-store.ts`). webtty-original. - `input/`: keyboard and mouse input: the key encoder's enums (`key-types.ts`), the browser key table (`key-map.ts`), what a keydown sends (`key-encoding.ts`), IME and mobile text with its echo guard (`text-input.ts`), and the mouse reports (`mouse-report.ts`). `input-handler.ts` is their facade. -- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), the key encoder (`key-encoder-binding.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). +- `engine/`: the binding to the official wasm: its interfaces (`cell-types.ts`, `engine-types.ts`), the export typing (`wasm-exports.ts`), the manifest reader and layout table (`vt-layout.ts`, `official-layout.ts`), the wasm loader (`wasm-loader.ts`), the key name tables (`key-tables.ts`), the scratch allocator (`scratch.ts`), the terminal facade (`official-terminal.ts`) and the state its parts share (`engine-context.ts`), the render state and cell decoding (`render-state.ts`, `row-ref.ts`), the scrollback page cache (`scrollback-pages.ts`), grid references and hyperlinks (`grid-refs.ts`), one binding per libghostty-vt object (`key-encoder-binding.ts`, `mouse-encoder-binding.ts`, `paste-focus-binding.ts`, `selection-binding.ts`, `search-binding.ts`), the callbacks and effects (`engine-effects.ts`), the config and cursor default (`engine-config.ts`) and pure helpers (`cursor-blink.ts`, `text-mime.ts`, `kitty-keyboard-reply.ts`). `ghostty.ts` is the facade over them: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `render/`: the canvas renderer's parts: what it reads and what its painters share (`renderable.ts`), its default colours (`default-theme.ts`), the drawing ratio (`device-pixel-ratio.ts`), one frame's row readers (`frame-lines.ts`), the cell size (`font-metrics.ts`), the one inclusive cell range the renderer, `selection-manager.ts` and `link-detector.ts` share (`cell-range.ts`), the overscan band plan and canvas row states (`row-band.ts`), what moved since the last frame (`scroll-tracker.ts`), the selection, search and link decorations per canvas row (`decoration-tracker.ts`), the painters (`cell-painter.ts`, `cursor-painter.ts` with the blink, `scrollbar-painter.ts`), the partial repaint through the scratch canvas (`row-repainter.ts`), the drawing canvas' placement and clip (`viewport-clip.ts`) and the scrollbar geometry the painter and the terminal's hit test share (`scrollbar-geometry.ts`). `renderer.ts` is the `CanvasRenderer` facade over them and re-exports what moved. -- `ghostty.ts`: the engine's facade: `Ghostty`, which loads the wasm and creates terminals and key encoders, and the re-exports of `engine/` its importers use. - `core/`: `Terminal`'s parts: the narrow interfaces they see of it (`host-types.ts`), its named values (`terminal-constants.ts`), its pixel and row maths (`terminal-geometry.ts`), the options store and engine config (`options-proxy.ts`), the view's place in the scrollback (`viewport.ts`), focus and its mode 1004 reports (`focus-tracker.ts`), find (`find-controller.ts`), the write pipeline (`write-pipeline.ts`), the render loop (`render-loop.ts`), link hover and clicks, the mouse report encoder and the right-click route (`mouse-controller.ts`), wheel routing (`wheel-controller.ts`), the scrollbar's press, drag and fade (`scrollbar-controller.ts`), those controllers made and wired together with the input route (`terminal-controllers.ts`), the emitters behind the `onX` events (`terminal-emitters.ts`), what an option set after `open()` changes (`option-changes.ts`), the DOM `open()` builds (`dom-setup.ts`) and the parts it holds (`opened-parts.ts`). `terminal.ts` is the facade over them and re-exports what moved. -- `link-types.ts`: the link detection contract, beside `link-detector.ts` and `providers/`. +- `selection/`: `SelectionManager`'s parts: the selection's coordinates (`selection-coords.ts`), the mouse gestures (`selection-gestures.ts`), the drag auto-scroll (`auto-scroll.ts`), the textarea placed for the context menu (`context-menu-textarea.ts`) and the copy chain (`clipboard-copy.ts`). `selection-manager.ts` is their facade. +- `providers/`: the link providers (`osc8-link-provider.ts`, `url-regex-provider.ts`) and their one activation rule (`open-link.ts`). +- `addons/`: `FitAddon` (`fit.ts`). +- The tests are not here: they are in `../tests/`, in the folder of each file's domain (see `docs/specs/architecture.md`), and were never copied from upstream. ## Moves and splits diff --git a/src/client/vendor/ghostty-web/glide.ts b/src/client/vendor/ghostty-web/glide.ts index 5bbdfd0..179bf76 100644 --- a/src/client/vendor/ghostty-web/glide.ts +++ b/src/client/vendor/ghostty-web/glide.ts @@ -1,8 +1,7 @@ -/** - * A value that moves toward a target a frame at a time: the one motion behind - * scrolling's glides, for the scrollback position (smooth-scroll.ts) and for the - * scroll ticks paced to apps (wheel.ts). (webtty, ADR 045) - */ +// webtty (ADR 045), not from upstream. +// A value that moves toward a target a frame at a time (object): the one motion behind +// scrolling's glides, for the scrollback position (the app's smooth-scroll.ts) and for +// the scroll ticks paced to apps (wheel.ts). /** One frame at 60 Hz. */ export const FRAME_MS = 1000 / 60; @@ -14,10 +13,13 @@ export const FRAME_MS = 1000 / 60; */ export const SPRING_RELEASE_MS = 60; +/** Maps a glide's elapsed fraction (0 to 1) to the fraction of the distance covered. */ export type Ease = (t: number) => number; +/** Even speed: the ticks paced to apps and a running wheel stream's glides. */ export const linear: Ease = (t) => t; +/** Fast start, soft landing: the scrollback glide of one discrete wheel step. */ export const easeOutCubic: Ease = (t) => 1 - (1 - t) ** 3; /** diff --git a/src/client/vendor/ghostty-web/index.ts b/src/client/vendor/ghostty-web/index.ts index 3b52d59..63a276f 100644 --- a/src/client/vendor/ghostty-web/index.ts +++ b/src/client/vendor/ghostty-web/index.ts @@ -1,6 +1,7 @@ // Derived from coder/ghostty-web lib/index.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The vendored layer's public barrel: the only path webtty's app code imports vendor code -// through (ADR 060, ADR 061). It follows xterm.js' naming. +// through (ADR 060, ADR 061). It follows xterm.js' naming, and its runtime names are +// pinned by vendor/tests/barrel.test.ts. // Main Terminal class export { Terminal } from './terminal'; diff --git a/src/client/vendor/ghostty-web/interfaces.ts b/src/client/vendor/ghostty-web/interfaces.ts index 16d8443..71b0b03 100644 --- a/src/client/vendor/ghostty-web/interfaces.ts +++ b/src/client/vendor/ghostty-web/interfaces.ts @@ -1,10 +1,12 @@ -/** - * xterm.js-compatible interfaces - */ +// Derived from coder/ghostty-web lib/interfaces.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// The xterm.js-compatible public types (types): the terminal's options and theme, the +// addon and disposable contracts, the event signature the emitters implement, and the +// read-only buffer API the link providers and webtty's kitty graphics read. import type { Ghostty } from './ghostty'; import type { RightClickBehavior } from './mouse-route'; +/** What `new Terminal(options)` takes; unset values get `TERMINAL_DEFAULTS`. */ export interface ITerminalOptions { cols?: number; // Default: 80 rows?: number; // Default: 24 @@ -38,6 +40,7 @@ export interface ITerminalOptions { ghostty: Ghostty; } +/** The terminal's colours as CSS colour strings; unset ones keep the default theme's. */ export interface ITheme { foreground?: string; background?: string; @@ -70,27 +73,27 @@ export interface ITheme { brightWhite?: string; } +/** Anything that releases what it holds; returned by every event subscription. */ export interface IDisposable { dispose(): void; } +/** An xterm-style `onX` event: subscribe a listener, dispose the result to unsubscribe. */ export type IEvent<T> = (listener: (arg: T) => void) => IDisposable; +/** An addon `loadAddon` activates on the terminal and disposes with it. */ export interface ITerminalAddon { activate(terminal: ITerminalCore): void; dispose(): void; } +/** What an addon sees of the terminal it is activated on. */ export interface ITerminalCore { element?: HTMLElement; /** The cell size, for FitAddon; set while the terminal is open (webtty, ADR 061) */ readonly renderer?: { getMetrics(): { width: number; height: number } }; } -// ============================================================================ -// Buffer API Interfaces (xterm.js compatibility) -// ============================================================================ - /** * Top-level buffer API namespace * Provides access to active, normal, and alternate screen buffers diff --git a/src/client/vendor/ghostty-web/link-detector.ts b/src/client/vendor/ghostty-web/link-detector.ts index 1b73ba8..22af36b 100644 --- a/src/client/vendor/ghostty-web/link-detector.ts +++ b/src/client/vendor/ghostty-web/link-detector.ts @@ -1,10 +1,7 @@ -/** - * Link detection and caching system - * - * The LinkDetector coordinates between multiple link providers and caches - * results for performance. Links are cached by position range, and earlier - * providers (e.g. OSC8) take precedence over later ones (e.g. regex). - */ +// Derived from coder/ghostty-web lib/link-detector.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). +// Link detection and caching (object): `LinkDetector` asks its providers for the links on a +// row and caches them by position range; earlier providers (OSC 8) take precedence over +// later ones (the URL regex). The terminal's mouse controller reads it on hover and click. import { cellInRange } from './render/cell-range'; import type { ILink, ILinkProvider } from './link-types'; diff --git a/src/client/vendor/ghostty-web/mouse-route.ts b/src/client/vendor/ghostty-web/mouse-route.ts index b39f986..90c1351 100644 --- a/src/client/vendor/ghostty-web/mouse-route.ts +++ b/src/client/vendor/ghostty-web/mouse-route.ts @@ -1,8 +1,7 @@ -/** - * Who a mouse press and a right-click belong to: the app, while it tracks the mouse, or - * webtty (selection, menu, copy, paste). Shift is the bypass, as in Ghostty, kitty, - * WezTerm, xterm, Windows Terminal and VS Code. (webtty, ADR 051) - */ +// webtty (ADR 051), not from upstream. +// Who a mouse press and a right-click belong to (util): the app, while it tracks the mouse, +// or webtty (selection, menu, copy, paste). Shift is the bypass, as in Ghostty, kitty, +// WezTerm, xterm, Windows Terminal and VS Code. /** What a right-click does when the app does not take it (the config's `rightClickBehavior`). */ export type RightClickBehavior = 'contextMenu' | 'copyPaste' | 'paste' | 'none'; @@ -22,6 +21,7 @@ export function pressToApp(tracking: boolean, shiftKey: boolean): boolean { return tracking && !shiftKey; } +/** What `rightClickAction` decides from: the app's tracking, the event and the config. */ export interface RightClick { /** The app tracks the mouse (any DEC mouse tracking mode is on). */ tracking: boolean; diff --git a/src/client/vendor/ghostty-web/search.ts b/src/client/vendor/ghostty-web/search.ts index d54ddf9..22aec8c 100644 --- a/src/client/vendor/ghostty-web/search.ts +++ b/src/client/vendor/ghostty-web/search.ts @@ -1,12 +1,11 @@ -/** - * Find (webtty, ADR 055): which cells of which rows the renderer paints in the search - * colours, and where the view goes for the selected match. - * - * Matches come from libghostty-vt newest to oldest (`GhosttyTerminal.searchMatches`), so - * their rows never increase along the list, and the matches over a range of rows are a - * run of it, found by binary search: a frame costs the same with ten matches or a - * hundred thousand. - */ +// webtty (ADR 055), not from upstream. +// Find highlights and view arithmetic (util): which cells of which rows the renderer +// paints in the search colours, and where the view goes for the selected match. +// +// Matches come from libghostty-vt newest to oldest (`GhosttyTerminal.searchMatches`), so +// their rows never increase along the list, and the matches over a range of rows are a +// run of it, found by binary search: a frame costs the same with ten matches or a +// hundred thousand. import type { ScreenRange } from './ghostty'; /** Matches as libghostty-vt's search found them, and the selected one. */ diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 4f0ca68..05f6f58 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -1,7 +1,8 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). -// Terminal (facade): the xterm.js-compatible terminal over Ghostty's libghostty-vt engine, -// its emitters and the parts it composes in open(); webtty creates it with -// `new Terminal({ ghostty: await Ghostty.load(url), ... })` and `open(container)` (ADR 061). +// Terminal (facade): the xterm.js-compatible terminal over Ghostty's libghostty-vt engine. +// It holds the options and the emitters, composes the parts open() makes and the controllers +// in core/, and dispose() releases all of it (ADR 061, ADR 065). webtty creates it with +// `new Terminal({ ghostty: await Ghostty.load(url), ... })` and `open(container)`. import { BufferNamespace } from './buffer'; import { diff --git a/src/client/vendor/ghostty-web/wheel.ts b/src/client/vendor/ghostty-web/wheel.ts index d48b662..28d805a 100644 --- a/src/client/vendor/ghostty-web/wheel.ts +++ b/src/client/vendor/ghostty-web/wheel.ts @@ -1,10 +1,9 @@ -/** - * Wheel input for apps that scroll themselves: what a wheel event sends when the - * app tracks the mouse (scroll ticks, each encoded by Ghostty's mouse encoder as a - * press of button 4 or 5, ADR 049) or is on the alternate screen without mouse - * tracking (arrow keys), and the pacing that spreads it over time. The - * terminal's `WheelController` routes events here. (webtty, ADR 017, ADR 040, ADR 045) - */ +// webtty (ADR 017, ADR 040, ADR 045), not from upstream. +// Wheel input for apps that scroll themselves (util and object): what a wheel event sends +// when the app tracks the mouse (scroll ticks, each encoded by Ghostty's mouse encoder as a +// press of button 4 or 5, ADR 049) or is on the alternate screen without mouse tracking +// (arrow keys), and the `TickPacer` that spreads it over time. The terminal's +// `WheelController` routes events here. import { Glide, linear, type Pace } from './glide'; /** Wheel travel not yet sent to an app, and its direction. */ diff --git a/src/client/vendor/tests/provenance.test.ts b/src/client/vendor/tests/provenance.test.ts new file mode 100644 index 0000000..201cfcc --- /dev/null +++ b/src/client/vendor/tests/provenance.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The vendored layer's licence trail (ADR 041, ADR 061): every source file names where it +// came from in its first line, PROVENANCE.md lists the webtty-original files exactly, and +// every file the "Moves and splits" table moved code to still exists. + +const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ghostty-web'); +const PROVENANCE = fs.readFileSync(path.join(WEB_DIR, 'PROVENANCE.md'), 'utf8'); + +const DERIVED = + /^\/\/ Derived from coder\/ghostty-web lib\/[\w/.-]+\.ts( and lib\/[\w/.-]+\.ts)* \(MIT, see LICENSE and PROVENANCE\.md in ghostty-web\/\)\.$/; +const ORIGINAL = /^\/\/ webtty \(ADR \d{3}(, ADR \d{3})*\), not from upstream\.$/; + +/** Every vendored `.ts` file, as a path relative to ghostty-web/. */ +function sourceFiles(dir = WEB_DIR): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith('.ts') ? [path.relative(WEB_DIR, full)] : []; + }); +} + +function firstLine(file: string): string { + return fs.readFileSync(path.join(WEB_DIR, file), 'utf8').split('\n', 1)[0] ?? ''; +} + +/** The `.ts` paths each bullet of one `## ` section names before its first ': '. */ +function listedInSection(heading: string): string[] { + const start = PROVENANCE.indexOf(`## ${heading}\n`); + const end = PROVENANCE.indexOf('\n## ', start + 1); + return PROVENANCE.slice(start, end) + .split('\n') + .filter((line) => line.startsWith('- ')) + .flatMap((line) => [...(line.split(': ')[0] ?? '').matchAll(/`([\w/.-]+\.ts)`/g)]) + .map((m) => m[1] ?? ''); +} + +const files = sourceFiles(); + +describe('vendored provenance', () => { + test('finds the vendored sources', () => { + expect(files).toContain('terminal.ts'); + expect(files).toContain('engine/official-terminal.ts'); + }); + + test.each(files)('%s starts with its origin header', (file) => { + const line = firstLine(file); + if (file === 'fn-table.ts') { + expect(fs.readFileSync(path.join(WEB_DIR, file), 'utf8')).toContain( + 'Copied from gkoreli/ghostty-vt-js', + ); + return; + } + expect(DERIVED.test(line) || ORIGINAL.test(line)).toBe(true); + }); + + test('PROVENANCE.md lists exactly the webtty-original files', () => { + const original = files.filter((file) => ORIGINAL.test(firstLine(file))).sort(); + expect(listedInSection('webtty-original files').sort()).toEqual(original); + }); + + test('every file the moves table names exists', () => { + const table = PROVENANCE.slice(PROVENANCE.indexOf('## Moves and splits')); + const targets = table + .split('\n') + .filter((row) => row.startsWith('| `')) + .flatMap((row) => [...(row.split('|')[2] ?? '').matchAll(/`([\w/.-]+\.ts)`/g)]) + .map((m) => m[1] ?? ''); + expect(targets.length).toBeGreaterThan(0); + expect(targets.filter((target) => !files.includes(target))).toEqual([]); + }); +}); From 47bf50decfde36b795db8a3b141feeb285f37f9d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:01:08 -0400 Subject: [PATCH 261/295] test(client): vendor coverage gaps: the controllers' hosts, Cmd+C and mouse presses The seven host builders of terminal-controllers.ts are exported and a test calls every member, pinning where each forwards (18 functions no test reached). The Terminal tests add Cmd+C through the input handler's copy callback and a press reported only while the app tracks the mouse. Vendor coverage (bun lcov): 100% lines, 98.6% functions. The functions still counted as missed are Bun's synthetic field initializers of classes such as TerminalEmitters and ScrollTracker, plus the browser clipboard default of SelectionManager. --- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../ghostty-web/core/terminal-controllers.ts | 23 +- .../tests/core/terminal-controllers.test.ts | 216 +++++++++++++++++- src/client/vendor/tests/core/terminal.test.ts | 24 ++ 4 files changed, 256 insertions(+), 8 deletions(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 870e02b..3a48cff 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -63,6 +63,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: the scrollbar's fade reads an injected clock (`Date.now()` and the browser's frames and timeouts by default, as before), and the fade in and fade out are one `fade('in' | 'out')` with the same opacity formulas (ADR 061). - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). - `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). +- `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). ## webtty-original files diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index 8ca33e1..b93a9f6 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -157,10 +157,13 @@ export class TerminalControllers { // Each controller's host, read when called: the parts are undefined before open() and // after dispose(), and the other controllers are reached through `core` once all exist. +// Every member is one forwarding call, pinned by terminal-controllers.test.ts. +/** Short for the internals host in the builders below. */ type Inner = ControllersInternalsHost; -function focusHost(term: ControllersHost, inner: Inner): FocusHost { +/** The focus tracker's view of the container, the textarea and the engine. */ +export function focusHost(term: ControllersHost, inner: Inner): FocusHost { return { element: () => inner.parts()?.element, textarea: () => inner.parts()?.textarea, @@ -170,7 +173,8 @@ function focusHost(term: ControllersHost, inner: Inner): FocusHost { }; } -function findHost(term: ControllersHost, inner: Inner): FindHost { +/** The finder's view of the terminal and its renderer. */ +export function findHost(term: ControllersHost, inner: Inner): FindHost { return { engine: () => inner.parts()?.engine, highlight: (highlights) => inner.parts()?.renderer.setSearchHighlights(highlights), @@ -182,7 +186,8 @@ function findHost(term: ControllersHost, inner: Inner): FindHost { }; } -function writeHost(term: ControllersHost, inner: Inner, core: TerminalControllers): WriteHost { +/** The write pipeline's view: the engine, the emitters and the other controllers. */ +export function writeHost(term: ControllersHost, inner: Inner, core: TerminalControllers): WriteHost { return { engine: () => inner.parts()?.engine, disableStdin: () => term.options.disableStdin, @@ -200,7 +205,8 @@ function writeHost(term: ControllersHost, inner: Inner, core: TerminalController }; } -function renderLoopHost(inner: Inner, core: TerminalControllers): RenderLoopHost { +/** The render loop's view: the frame's steps in their order. */ +export function renderLoopHost(inner: Inner, core: TerminalControllers): RenderLoopHost { return { live: () => inner.live(), beforeRender: (now) => inner.beforeRender(now), @@ -212,7 +218,8 @@ function renderLoopHost(inner: Inner, core: TerminalControllers): RenderLoopHost }; } -function scrollbarHost(term: ControllersHost, inner: Inner): ScrollbarHost { +/** The scrollbar's view of the canvas, the view and the render. */ +export function scrollbarHost(term: ControllersHost, inner: Inner): ScrollbarHost { return { canvas: () => inner.parts()?.canvas, scrollOffset: () => inner.parts()?.renderer.scrollOffset, @@ -224,7 +231,8 @@ function scrollbarHost(term: ControllersHost, inner: Inner): ScrollbarHost { }; } -function mouseHost(term: ControllersHost, inner: Inner, core: TerminalControllers): MouseHost { +/** The mouse controller's view of the parts, the grid and the scrollbar drag. */ +export function mouseHost(term: ControllersHost, inner: Inner, core: TerminalControllers): MouseHost { return { element: () => inner.parts()?.element, canvas: () => inner.parts()?.canvas, @@ -242,7 +250,8 @@ function mouseHost(term: ControllersHost, inner: Inner, core: TerminalController }; } -function wheelHost(term: ControllersHost, inner: Inner, core: TerminalControllers): WheelHost { +/** The wheel controller's view: the engine, the cell height and the mouse encoder. */ +export function wheelHost(term: ControllersHost, inner: Inner, core: TerminalControllers): WheelHost { return { engine: () => inner.parts()?.engine, cellHeight: () => inner.parts()?.renderer.getMetrics()?.height, diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 92bf2e5..8ae607f 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, spyOn, test } from 'bun:test'; import { ManualClock } from '../../../test-support/manual-clock'; import { type ListenerRecord, RecordingTarget } from '../../../test-support/recording-target'; import type { OpenedParts } from '../../ghostty-web/core/opened-parts'; import { type ControllersHost, + type ControllersInternalsHost, + findHost, + focusHost, + mouseHost, + renderLoopHost, + scrollbarHost, TerminalControllers, + wheelHost, + writeHost, } from '../../ghostty-web/core/terminal-controllers'; import { TerminalEmitters } from '../../ghostty-web/core/terminal-emitters'; @@ -137,3 +145,209 @@ describe('TerminalControllers wiring', () => { expect(core.scrollbar.drag({ clientY: 0 } as MouseEvent)).toBe(false); }); }); + +/** + * A terminal, its internals and its parts that log every call they get, and real + * controllers over them whose cross-calls log too, for the host builders' tests. + */ +function world() { + const log: string[] = []; + const parts = { + element: { id: 'element' }, + textarea: { id: 'textarea' }, + canvas: { id: 'canvas' }, + engine: { hasResponse: () => true }, + renderer: { + scrollOffset: 7, + setSearchHighlights: () => void log.push('highlight'), + getMetrics: () => ({ width: 8, height: 16 }), + }, + links: { invalidateCache: () => void log.push('links stale') }, + selection: { id: 'selection' }, + }; + const term: ControllersHost = { + options: { disableStdin: true, rightClickBehavior: 'paste', mouseScrollSpeed: 3 }, + cols: 20, + rows: 5, + viewportY: 2, + getScrollbackLength: () => 10, + scrollToLine: (line) => void log.push(`scroll to ${line}`), + scrollToBottom: () => void log.push('scroll to bottom'), + }; + const emit = new TerminalEmitters(); + emit.data.event((data) => void log.push(`data ${data}`)); + emit.bell.event(() => void log.push('bell')); + emit.clipboardWrite.event((text) => void log.push(`clipboard ${text}`)); + emit.desktopNotification.event((n) => void log.push(`notify ${n.title}`)); + emit.findChange.event((state) => void log.push(`find ${state.total}`)); + emit.pasteRequest.event(() => void log.push('paste request')); + const inner: ControllersInternalsHost = { + emit, + parts: () => parts as unknown as OpenedParts, + live: () => true, + beforeRender: (now) => void log.push(`before render ${now}`), + setTitle: (title) => void log.push(`title ${title}`), + render: (force, opacity) => void log.push(`render ${force} ${opacity}`), + anchorTextarea: () => void log.push('anchor'), + cellPixelSize: () => ({ width: 16, height: 32 }), + }; + const core = new TerminalControllers(term, inner, new ManualClock()); + spyOn(core.focus, 'followMode').mockImplementation(() => void log.push('follow focus mode')); + spyOn(core.finder, 'step').mockImplementation((now) => void log.push(`find step ${now}`)); + spyOn(core.writes, 'drainReplies').mockImplementation(() => void log.push('drain')); + spyOn(core.scrollbar, 'drag').mockImplementation(() => true); + spyOn(core.mouse, 'encode').mockImplementation(() => 'report'); + return { log, parts, term, inner, core }; +} + +// Each host member is one forwarding call to the terminal, its parts, its emitters or +// another controller; these pin where each one goes. +describe('TerminalControllers hosts', () => { + test('focus: the parts, the stdin option and onData', () => { + const { log, parts, term, inner } = world(); + const host = focusHost(term, inner); + expect<unknown[]>([ + host.element(), + host.textarea(), + host.engine(), + host.disableStdin(), + ]).toEqual([parts.element, parts.textarea, parts.engine, true]); + host.send('x'); + expect(log).toEqual(['data x']); + }); + + test('find: the engine, the renderer, the view and onFindChange', () => { + const { log, parts, term, inner } = world(); + const host = findHost(term, inner); + expect<unknown[]>([ + host.engine(), + host.getScrollbackLength(), + host.viewportY(), + host.rows(), + ]).toEqual([parts.engine, 10, 2, 5]); + host.highlight({ matches: new Int32Array(0), selected: null }); + host.scrollToLine(4); + host.changed({ total: 3, selected: 1 }); + expect(log).toEqual(['highlight', 'scroll to 4', 'find 3']); + }); + + test('write: the engine, the emitters, the link cache, the view and the controllers', () => { + const { log, parts, term, inner, core } = world(); + const host = writeHost(term, inner, core); + expect<unknown[]>([ + host.engine(), + host.disableStdin(), + host.viewportY(), + host.findAnchored(), + ]).toEqual([parts.engine, true, 2, core.finder.anchored]); + host.send('r'); + host.followFocusMode(); + host.bell(); + host.title('t'); + host.clipboardWrite('c'); + host.notify({ title: 'n', body: '' }); + host.outputChanged(); + host.scrollToBottom(); + host.renderEcho(); + expect(log).toEqual([ + 'data r', + 'follow focus mode', + 'bell', + 'title t', + 'clipboard c', + 'notify n', + 'links stale', + 'scroll to bottom', + 'render false undefined', + ]); + }); + + test("render loop: each of a frame's steps", () => { + const { log, inner, core } = world(); + const host = renderLoopHost(inner, core); + expect<unknown[]>([host.live(), host.hasReplies()]).toEqual([true, true]); + host.beforeRender(1); + host.stepFind(2); + host.render(); + host.drainReplies(); + host.anchorTextarea(); + expect(log).toEqual([ + 'before render 1', + 'find step 2', + 'render false undefined', + 'drain', + 'anchor', + ]); + }); + + test('scrollbar: the canvas, the offset, the view and the render', () => { + const { log, parts, term, inner } = world(); + const host = scrollbarHost(term, inner); + expect<unknown[]>([ + host.canvas(), + host.scrollOffset(), + host.getScrollbackLength(), + host.rows(), + host.viewportY(), + ]).toEqual([parts.canvas, 7, 10, 5, 2]); + host.scrollToLine(1); + host.render(0.5); + expect(log).toEqual(['scroll to 1', 'render false 0.5']); + }); + + test('mouse: the parts, the grid, the cell, the option, the drag and onPasteRequest', () => { + const { log, parts, term, inner, core } = world(); + const host = mouseHost(term, inner, core); + expect<unknown[]>([ + host.element(), + host.canvas(), + host.engine(), + host.renderer(), + host.links(), + host.selection(), + host.rows(), + host.cols(), + host.viewportY(), + host.cellPixelSize(), + host.rightClickBehavior(), + host.dragScrollbar({} as MouseEvent), + ]).toEqual([ + parts.element, + parts.canvas, + parts.engine, + parts.renderer, + parts.links, + parts.selection, + 5, + 20, + 2, + { width: 16, height: 32 }, + 'paste', + true, + ]); + host.requestPaste(); + expect(log).toEqual(['paste request']); + }); + + test('wheel: the engine, the cell height, the grid, the option, the encoder and onData', () => { + const { log, parts, term, inner, core } = world(); + const host = wheelHost(term, inner, core); + expect<unknown[]>([ + host.engine(), + host.cellHeight(), + host.hasCanvas(), + host.rows(), + host.mouseScrollSpeed(), + host.encodeMouse({ + clientX: 0, + clientY: 0, + button: null, + action: 'motion', + mods: 0, + anyButtonPressed: false, + }), + ]).toEqual([parts.engine, 16, true, 5, 3, 'report']); + host.send('w'); + expect(log).toEqual(['data w']); + }); +}); diff --git a/src/client/vendor/tests/core/terminal.test.ts b/src/client/vendor/tests/core/terminal.test.ts index ffe9721..bb481e3 100644 --- a/src/client/vendor/tests/core/terminal.test.ts +++ b/src/client/vendor/tests/core/terminal.test.ts @@ -589,6 +589,30 @@ describe('Terminal input', () => { expect(sent).toContain('a'); expect(term.viewportY).toBe(0); }); + + test('Cmd+C copies through the terminal; with nothing selected the browser keeps the key', async () => { + const { sent, container } = await open(); + let prevented = false; + const event = { + ...keydown('c'), + metaKey: true, + preventDefault: () => { + prevented = true; + }, + }; + container.dispatch(event); + expect([prevented, sent]).toEqual([false, []]); + }); + + test('a press is reported, encoded by Ghostty, only while the app tracks the mouse', async () => { + const { term, sent, container } = await open(); + const press = { type: 'mousedown', button: 0, buttons: 1, clientX: 1, clientY: 1 }; + container.dispatch(press); + expect(sent).toEqual([]); + term.write('\x1b[?1000h\x1b[?1006h'); + container.dispatch(press); + expect(sent).toEqual(['\x1b[<0;1;1M']); + }); }); describe('Terminal scrolling', () => { From bf18e1cef6981aaccf2dfdf747d4d98638b9d5df Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:02:02 -0400 Subject: [PATCH 262/295] docs(arch): the vendored split is done (ADR 061) --- docs/specs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 7be9a65..cfaffb5 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -334,7 +334,7 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a | Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ✅ | -| Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/` | [ADR 061](../adrs/061.client.vendor-split.md) | ⬜ | +| Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/`; the `types.ts` shim removed, every file with its origin header | [ADR 061](../adrs/061.client.vendor-split.md) | ✅ | | Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ✅ | | Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ⬜ | | CLI command modules | `main.ts`, WebttyClient, one file per command with injected deps | [ADR 063](../adrs/063.cli.command-modules.md) | ⬜ | From 8895e226c9cfc19de4f94b15b6baa6006643b69a Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:05:45 -0400 Subject: [PATCH 263/295] docs(arch): PROVENANCE moves table names the real shim-removal commit The types.ts shim row named a subject no commit has; it now reads "refactor(client): remove the vendor types.ts shim". provenance.test.ts checks that every row's commit column is a conventional commit subject. --- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- src/client/vendor/tests/provenance.test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 3a48cff..dd2004b 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -179,4 +179,4 @@ Each commit that moves code out of an upstream-derived file appends its rows her | `terminal.ts`: the body of `jumpToPrompt` (webtty, ADR 057) | `core/viewport.ts` (`Viewport.jumpToPrompt`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | | `terminal.ts`: what the scrollbar reaches of `Terminal` | `core/host-types.ts` (`ScrollbarClock`, `ScrollbarHost`) | refactor(client): terminal split, part 4: scrollbar, DOM setup, OpenedParts; Terminal a facade | | `terminal.ts`: the second, deferred `element.focus()` of `focus()` | `core/focus-tracker.ts` (`FocusTracker.focusContainer`) | fix(client): release every listener on dispose (ADR 065) | -| `types.ts`: the re-export shim left by the split above; its last importers (`index.ts`, `link-detector.ts`) import `input/key-types.ts`, `engine/cell-types.ts` and `link-types.ts`, and the file is deleted | `input/key-types.ts`, `engine/cell-types.ts`, `link-types.ts` | refactor(client): the vendor types.ts shim removed | +| `types.ts`: the re-export shim left by the split above; its last importers (`index.ts`, `link-detector.ts`) import `input/key-types.ts`, `engine/cell-types.ts` and `link-types.ts`, and the file is deleted | `input/key-types.ts`, `engine/cell-types.ts`, `link-types.ts` | refactor(client): remove the vendor types.ts shim | diff --git a/src/client/vendor/tests/provenance.test.ts b/src/client/vendor/tests/provenance.test.ts index 201cfcc..fc822fd 100644 --- a/src/client/vendor/tests/provenance.test.ts +++ b/src/client/vendor/tests/provenance.test.ts @@ -72,4 +72,16 @@ describe('vendored provenance', () => { expect(targets.length).toBeGreaterThan(0); expect(targets.filter((target) => !files.includes(target))).toEqual([]); }); + + // The commit column is how a reader finds the move in `git log`, so it must be a commit + // subject. Whether that subject exists is checked by hand: git history is not stable in a test. + test('every moves table row names a conventional commit subject', () => { + const table = PROVENANCE.slice(PROVENANCE.indexOf('## Moves and splits')); + const commits = table + .split('\n') + .filter((row) => row.startsWith('| `')) + .map((row) => (row.split('|')[3] ?? '').trim()); + expect(commits.length).toBeGreaterThan(0); + expect(commits.filter((c) => !/^(refactor|fix)\(client\): \S/.test(c))).toEqual([]); + }); }); From 772fc70dcbdaca44ec843830fbc38119297beadf Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:08:43 -0400 Subject: [PATCH 264/295] refactor(client): ClientConfig is src/config's type; the close codes pinned to the server's The bridges kept while the client and server branches were apart are gone: - app/client-config.ts re-exports src/config's ClientConfig (import type), replacing the temporary Pick alias, so tsc checks the client against the key list the server builds /api/config from. The client fixtures gain the legacy 'scrollback' key the server has always sent. - connection/close-codes.test.ts is now a contract test: each CLOSE_CODE equals the server's WS_CLOSE entry of the same name. - replay-window.test.ts imports DEFAULT_FLOW_LIMITS from server/output-pacer, and the src/server/flow.ts re-export shim is deleted (ADR 062). --- src/client/app/client-config.ts | 28 +++------------------ src/client/app/terminal-options.test.ts | 1 + src/client/connection/close-codes.test.ts | 18 +++++++------ src/client/connection/replay-window.test.ts | 2 +- src/client/test-support/fake-context.ts | 1 + src/server/flow.ts | 4 --- 6 files changed, 17 insertions(+), 37 deletions(-) delete mode 100644 src/server/flow.ts diff --git a/src/client/app/client-config.ts b/src/client/app/client-config.ts index 76750b8..f71d74b 100644 --- a/src/client/app/client-config.ts +++ b/src/client/app/client-config.ts @@ -2,33 +2,13 @@ // (service + types, ADR 009, ADR 060). The keys are src/config's own types, imported as // types only, so the bundle carries no Node code. -import type { Config } from '../../config'; - -export type { KeyboardBinding, Theme } from '../../config'; +import type { ClientConfig } from '../../config'; /** - * The config keys the client reads from `/api/config`. Replaced by the src/config - * ClientConfig at C-F, once the server branch has merged. + * The config keys the client reads from `/api/config`: src/config's ClientConfig, the one + * type the server's `/api/config` view is built from, so `tsc` checks the two together. */ -export type ClientConfig = Pick< - Config, - | 'cols' - | 'rows' - | 'fontSize' - | 'fontFamily' - | 'cursorStyle' - | 'cursorStyleBlink' - | 'scrollbackLines' - | 'theme' - | 'copyOnSelect' - | 'rightClickBehavior' - | 'clipboardWrite' - | 'bellTitle' - | 'desktopNotifications' - | 'mouseScrollSpeed' - | 'padding' - | 'keyboardBindings' ->; +export type { ClientConfig, KeyboardBinding, Theme } from '../../config'; /** The part of `fetch` the loader uses, so a test passes a fake. */ export type ConfigFetch = (url: string) => Promise<Pick<Response, 'json'>>; diff --git a/src/client/app/terminal-options.test.ts b/src/client/app/terminal-options.test.ts index abd6842..d7922de 100644 --- a/src/client/app/terminal-options.test.ts +++ b/src/client/app/terminal-options.test.ts @@ -10,6 +10,7 @@ const config: ClientConfig = { fontFamily: 'Menlo', cursorStyle: 'bar', cursorStyleBlink: true, + scrollback: 256 * 1024, scrollbackLines: 5000, theme: { background: '#000000', padding: '#111111' }, copyOnSelect: false, diff --git a/src/client/connection/close-codes.test.ts b/src/client/connection/close-codes.test.ts index c6c76bc..370ebf5 100644 --- a/src/client/connection/close-codes.test.ts +++ b/src/client/connection/close-codes.test.ts @@ -1,16 +1,18 @@ +// Contract test: the close codes the client acts on are the server's own (ADR 060, ADR 062). +// Tests may cross the client/server line; the client source never imports the server. import { describe, expect, test } from 'bun:test'; -import { FELL_BEHIND } from '../../server/flow'; +import { WS_CLOSE } from '../../server/ws/close-codes'; import { CLOSE_CODE } from './close-codes'; describe('CLOSE_CODE', () => { - test("fell-behind is the server's code (ADR 048)", () => { - expect(CLOSE_CODE.FELL_BEHIND).toBe(FELL_BEHIND); - }); + test.each(Object.keys(CLOSE_CODE) as (keyof typeof CLOSE_CODE)[])( + "%s is the server's WS_CLOSE code", + (name) => { + expect(CLOSE_CODE[name]).toBe(WS_CLOSE[name]); + }, + ); - // The server's other codes are module-private on this branch; once its close-codes - // module is exported (step C-F) these are pinned to it too. test('the codes the tab closes on', () => { - expect(CLOSE_CODE.SERVER_STOPPED).toBe(1001); - expect(CLOSE_CODE.SESSION_GONE).toBe(4001); + expect(CLOSE_CODE).toEqual({ SERVER_STOPPED: 1001, SESSION_GONE: 4001, FELL_BEHIND: 4003 }); }); }); diff --git a/src/client/connection/replay-window.test.ts b/src/client/connection/replay-window.test.ts index 74c8b16..8e42854 100644 --- a/src/client/connection/replay-window.test.ts +++ b/src/client/connection/replay-window.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_FLOW_LIMITS } from '../../server/flow'; +import { DEFAULT_FLOW_LIMITS } from '../../server/output-pacer'; import { REPLAY_PIECE, REPLAY_QUIET_MS, ReplayWindow } from './replay-window'; describe('ReplayWindow', () => { diff --git a/src/client/test-support/fake-context.ts b/src/client/test-support/fake-context.ts index 00cf92f..77a92e3 100644 --- a/src/client/test-support/fake-context.ts +++ b/src/client/test-support/fake-context.ts @@ -30,6 +30,7 @@ export const FAKE_CONFIG: ClientConfig = { fontFamily: 'monospace', cursorStyle: 'block', cursorStyleBlink: false, + scrollback: 256 * 1024, scrollbackLines: 1000, theme: {}, copyOnSelect: false, diff --git a/src/server/flow.ts b/src/server/flow.ts deleted file mode 100644 index 24f59b9..0000000 --- a/src/server/flow.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Temporary re-export of output-pacer.ts (ADR 062). The client's replay test imports -// DEFAULT_FLOW_LIMITS from '../server/flow'; this file is deleted once that import is -// repointed after the client and server branches merge (step C-F). -export * from './output-pacer'; From f4fb18f504fb23a4c4354d2105e28f44ac2604fd Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:09:06 -0400 Subject: [PATCH 265/295] test(arch): the ratchet allowlist is empty and strict With the server branch merged, every size and rule entry is gone (CLI commands, config split, routes, the server test harness and pty types), the allowlist is strict, and its test pins it equal to EMPTY_ALLOWLIST, so a stale or new entry fails. The checker reports zero violations on the whole tree (ADR 059). --- scripts/architecture/ratchet-allowlist.ts | 29 +++++------------------ scripts/architecture/ratchet.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/scripts/architecture/ratchet-allowlist.ts b/scripts/architecture/ratchet-allowlist.ts index 9b134aa..b5aa620 100644 --- a/scripts/architecture/ratchet-allowlist.ts +++ b/scripts/architecture/ratchet-allowlist.ts @@ -1,29 +1,12 @@ -// What the refactor still tolerates (constants, ADR 059): each file over its size limit -// at the commit that added the checker, with its line count, and each rule broken then, -// with the step that removes it. Entries may only be removed or lowered; the merge of -// the server and client branches empties both lists and sets `strict`. +// What the refactor still tolerates (constants, ADR 059): nothing. Both lists emptied when +// the server and client branches merged, and `strict` makes a stale entry fail, so a new +// entry is a visible, reviewed exception with the step that removes it. import type { Allowlist } from './ratchet'; -const SERVER_HARNESS = - 'server track: src/utils.test.ts becomes src/test-support/server-harness.ts (ADR 064)'; -const CLI_COMMANDS = 'server track: CLI command modules (ADR 063)'; - /** The allowlist the real tree is checked against. */ export const RATCHET_ALLOWLIST: Allowlist = { - strict: false, - sizes: { - 'src/cli/commands.test.ts': { lines: 1020, step: CLI_COMMANDS }, - 'src/cli/commands.ts': { lines: 369, step: CLI_COMMANDS }, - 'src/config.ts': { lines: 369, step: 'server track: src/config/ split (ADR 062)' }, - 'src/server/routes.ts': { lines: 485, step: 'server track: routes/ and http/ (ADR 062)' }, - }, - rules: { - 'test-import src/cli/commands.test.ts -> src/utils.test.ts': SERVER_HARNESS, - 'test-import src/server/routes.test.ts -> src/utils.test.ts': SERVER_HARNESS, - 'test-import src/server/websocket.test.ts -> src/utils.test.ts': SERVER_HARNESS, - 'test-import src/version.test.ts -> src/utils.test.ts': SERVER_HARNESS, - 'banned-name src/utils.test.ts': SERVER_HARNESS, - 'banned-name src/pty/types.ts': 'server track: pty/pty-process.ts (ADR 062)', - }, + strict: true, + sizes: {}, + rules: {}, }; diff --git a/scripts/architecture/ratchet.test.ts b/scripts/architecture/ratchet.test.ts index 0834ede..f0c797c 100644 --- a/scripts/architecture/ratchet.test.ts +++ b/scripts/architecture/ratchet.test.ts @@ -106,8 +106,8 @@ describe('applyAllowlist', () => { }); describe('RATCHET_ALLOWLIST', () => { - test('tolerates missing entries until the branches merge', () => { - expect(RATCHET_ALLOWLIST.strict).toBe(false); + test('is empty and strict since the branches merged, so a stale entry fails', () => { + expect(RATCHET_ALLOWLIST).toEqual(EMPTY_ALLOWLIST); }); test('every entry names the step that removes it', () => { From 0818093ebee9de5910be5acfc7ce98f12dc23c61 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:13:40 -0400 Subject: [PATCH 266/295] test(arch): per-layer coverage targets over bun's lcov report bunfig.toml sets bun's one global coverage floor (85% lines, 80% functions). scripts/check-coverage.ts reads coverage/lcov.info and enforces the SPEC's targets per layer: client app, server, CLI, config, pty and the shared src/*.ts files at 95% lines and 90% functions, the vendored layer at 85% and 80%, and 40 listed pure modules at 100% lines (each must be loaded by a test). Test code (*.test.ts, test-support/, vendor/tests/) is not counted. test:coverage now runs bun test --coverage --coverage-reporter=lcov --coverage-reporter=text and then the check (ADR 064). Today: vendor 100.00% lines / 98.95% functions, client 100.00% / 98.20%, server, cli, config, pty and shared 100.00% / 100.00%. --- bunfig.toml | 4 + package.json | 2 +- scripts/check-coverage.test.ts | 218 +++++++++++++++++++++++++++++++++ scripts/check-coverage.ts | 209 +++++++++++++++++++++++++++++++ 4 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 bunfig.toml create mode 100644 scripts/check-coverage.test.ts create mode 100644 scripts/check-coverage.ts diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..7d52202 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,4 @@ +# Bun's coverage threshold is one global setting, so this is the common floor of the +# per-layer targets; scripts/check-coverage.ts enforces each layer (architecture SPEC, ADR 064). +[test] +coverageThreshold = { line = 0.85, function = 0.80 } diff --git a/package.json b/package.json index 0304d6e..ae41e9f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint": "tsc --noEmit -p tsconfig.lint.json && biome check .", "lint:fix": "tsc --noEmit -p tsconfig.lint.json && bunx biome check --write .", "test": "bun test", - "test:coverage": "bun test --coverage", + "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text && bun scripts/check-coverage.ts", "build": "bun run scripts/build.ts", "server": "bun run dist/server/index.js", "server:node": "node dist/server/index.js", diff --git a/scripts/check-coverage.test.ts b/scripts/check-coverage.test.ts new file mode 100644 index 0000000..d8a3256 --- /dev/null +++ b/scripts/check-coverage.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + coverageFailures, + type FileCoverage, + formatLayer, + isTestCode, + LAYER_TARGETS, + layerOf, + PURE_MODULES, + parseLcov, + ratio, + runCoverageCheck, + totalsByLayer, +} from './check-coverage'; + +const REPO_ROOT = path.join(import.meta.dir, '..'); + +const covered = (file: string, lines = 10, functions = 2): FileCoverage => ({ + file, + linesFound: lines, + linesHit: lines, + functionsFound: functions, + functionsHit: functions, +}); + +/** Every pure module fully covered, so a test sees only the failure it sets up. */ +const allPure = (): FileCoverage[] => PURE_MODULES.map((file) => covered(file)); + +const lcov = (...records: FileCoverage[]): string => + records + .map((r) => + [ + `SF:${r.file}`, + 'FN:1,f', + `FNF:${r.functionsFound}`, + `FNH:${r.functionsHit}`, + 'DA:1,1', + `LF:${r.linesFound}`, + `LH:${r.linesHit}`, + 'end_of_record', + ].join('\n'), + ) + .join('\n'); + +let tmpRoots: string[] = []; + +function tmpRepo(report?: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webtty-cov-')); + tmpRoots.push(root); + if (report !== undefined) { + fs.mkdirSync(path.join(root, 'coverage')); + fs.writeFileSync(path.join(root, 'coverage', 'lcov.info'), report); + } + return root; +} + +afterEach(() => { + for (const root of tmpRoots) fs.rmSync(root, { recursive: true, force: true }); + tmpRoots = []; +}); + +describe('parseLcov', () => { + test('reads each record with its line and function counts', () => { + const record = { + file: 'src/a.ts', + linesFound: 4, + linesHit: 3, + functionsFound: 2, + functionsHit: 1, + }; + expect(parseLcov(lcov(record), '/repo')).toEqual([record]); + }); + + test('makes absolute paths relative to the root', () => { + const [file] = parseLcov('SF:/repo/src/b.ts\nLF:1\nLH:1\nend_of_record\n', '/repo'); + expect(file?.file).toBe('src/b.ts'); + }); + + test('a record without end_of_record, and stray lines, are dropped', () => { + expect(parseLcov('TN:\nLF:3\nSF:src/c.ts\nLF:2\n', '/repo')).toEqual([]); + }); + + test('missing counters read as zero', () => { + expect(parseLcov('SF:src/d.ts\nend_of_record', '/repo')).toEqual([ + { file: 'src/d.ts', linesFound: 0, linesHit: 0, functionsFound: 0, functionsHit: 0 }, + ]); + }); +}); + +describe('isTestCode and layerOf', () => { + const cases: [string, string | undefined][] = [ + ['src/client/vendor/ghostty-web/terminal.ts', 'vendor'], + ['src/client/vendor/tests/support/wasm.ts', undefined], + ['src/client/app/start-client.ts', 'client'], + ['src/client/test-support/fake-socket.ts', undefined], + ['src/client/features/kitty/graphics/test-support/graphics-harness.ts', undefined], + ['src/server/app.ts', 'server'], + ['src/server/app.test.ts', undefined], + ['src/cli/main.ts', 'cli'], + ['src/config/index.ts', 'config'], + ['src/pty/index.ts', 'pty'], + ['src/runtime.ts', 'shared'], + ['src/test-support/fake-pty.ts', undefined], + ['scripts/build.ts', undefined], + ]; + for (const [file, layer] of cases) { + test(`${file} → ${layer}`, () => { + expect(layerOf(file)?.name).toBe(layer); + if (layer) expect(isTestCode(file)).toBe(false); + }); + } +}); + +describe('the targets (architecture SPEC "Testing")', () => { + test('vendor 85/80, every other layer 95/90', () => { + for (const layer of LAYER_TARGETS) { + const expected = layer.name === 'vendor' ? [0.85, 0.8] : [0.95, 0.9]; + expect([layer.lines, layer.functions]).toEqual(expected); + } + }); + + test('every pure module exists and belongs to a layer', () => { + for (const file of PURE_MODULES) { + expect(fs.existsSync(path.join(REPO_ROOT, file))).toBe(true); + expect(layerOf(file)).toBeDefined(); + } + }); +}); + +describe('ratio', () => { + test('a share of what was found; nothing found is all of it', () => { + expect(ratio(1, 4)).toBe(0.25); + expect(ratio(0, 0)).toBe(1); + }); +}); + +describe('totalsByLayer and formatLayer', () => { + test('sums each layer and skips test code', () => { + const totals = totalsByLayer([ + covered('src/server/a.ts', 10, 2), + { ...covered('src/server/b.ts', 10, 2), linesHit: 5, functionsHit: 1 }, + covered('src/server/c.test.ts', 100, 100), + ]); + const server = totals.find((layer) => layer.name === 'server'); + expect(server?.totals).toEqual({ + linesFound: 20, + linesHit: 15, + functionsFound: 4, + functionsHit: 3, + }); + expect(totals.map((layer) => layer.name)).toEqual(LAYER_TARGETS.map((layer) => layer.name)); + expect(server && formatLayer(server)).toBe( + 'server lines 75.00% (≥ 95.00%) functions 75.00% (≥ 90.00%)', + ); + }); +}); + +describe('coverageFailures', () => { + test('none when every layer and pure module meets its target', () => { + expect(coverageFailures(allPure())).toEqual([]); + }); + + test('a layer under its line or function share', () => { + const low = { ...covered('src/cli/x.ts', 100, 10), linesHit: 90, functionsHit: 8 }; + expect(coverageFailures([...allPure(), low])).toEqual([ + 'cli: lines 92.31% < 95.00%', + 'cli: functions 87.50% < 90.00%', + ]); + }); + + test('the vendor layer passes at its lower share', () => { + const vendor = { + ...covered('src/client/vendor/ghostty-web/x.ts', 100, 10), + linesHit: 90, + functionsHit: 9, + }; + expect(coverageFailures([...allPure(), vendor])).toEqual([]); + }); + + test('a pure module not at 100% lines, or not loaded at all', () => { + const [partial, missing] = PURE_MODULES; + const files = allPure() + .filter((file) => file.file !== missing) + .map((file) => (file.file === partial ? { ...file, linesHit: 9 } : file)); + expect(coverageFailures(files)).toEqual([ + `${partial}: pure module at 9/10 lines, not 100%`, + `${missing}: pure module not loaded by any test`, + ]); + }); +}); + +describe('runCoverageCheck', () => { + test('fails with a hint when there is no report', () => { + const lines: string[] = []; + expect(runCoverageCheck(tmpRepo(), (line) => lines.push(line))).toBe(1); + expect(lines[0]).toEndWith('not found; run bun test --coverage --coverage-reporter=lcov first'); + }); + + test('prints each layer and passes when the targets are met', () => { + const lines: string[] = []; + expect(runCoverageCheck(tmpRepo(lcov(...allPure())), (line) => lines.push(line))).toBe(0); + expect(lines).toHaveLength(LAYER_TARGETS.length + 1); + expect(lines.at(-1)).toBe( + `coverage targets met (${PURE_MODULES.length} pure modules at 100% lines)`, + ); + }); + + test('prints each failure and returns 1', () => { + const lines: string[] = []; + const report = lcov(...allPure(), { ...covered('src/pty/x.ts'), linesHit: 0 }); + expect(runCoverageCheck(tmpRepo(report), (line) => lines.push(line))).toBe(1); + expect(lines).toContain('coverage: pty: lines 0.00% < 95.00%'); + expect(lines.at(-1)).toStartWith('1 coverage target(s) missed'); + }); +}); diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts new file mode 100644 index 0000000..3a54a5d --- /dev/null +++ b/scripts/check-coverage.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env bun +// Enforces the per-layer coverage targets of docs/specs/architecture.md over bun's lcov +// report (entry, ADR 064). Bun's coverageThreshold is one global floor (bunfig.toml), so +// this script reads coverage/lcov.info, written by `bun test --coverage +// --coverage-reporter=lcov`, and checks each layer and the listed pure modules. + +import fs from 'node:fs'; +import path from 'node:path'; + +/** One source file's counts from an lcov record. */ +export interface FileCoverage { + file: string; + linesFound: number; + linesHit: number; + functionsFound: number; + functionsHit: number; +} + +/** A layer of the tree and the share of its lines and functions the tests must run. */ +export interface LayerTarget { + name: string; + /** Matches a repo-relative source path of this layer. */ + pattern: RegExp; + lines: number; + functions: number; +} + +/** The layers, in report order; a file belongs to the first that matches (ADR 064). */ +export const LAYER_TARGETS: readonly LayerTarget[] = [ + { name: 'vendor', pattern: /^src\/client\/vendor\//, lines: 0.85, functions: 0.8 }, + { name: 'client', pattern: /^src\/client\//, lines: 0.95, functions: 0.9 }, + { name: 'server', pattern: /^src\/server\//, lines: 0.95, functions: 0.9 }, + { name: 'cli', pattern: /^src\/cli\//, lines: 0.95, functions: 0.9 }, + { name: 'config', pattern: /^src\/config\//, lines: 0.95, functions: 0.9 }, + { name: 'pty', pattern: /^src\/pty\//, lines: 0.95, functions: 0.9 }, + { name: 'shared', pattern: /^src\/[^/]+\.ts$/, lines: 0.95, functions: 0.9 }, +]; + +/** Pure modules: table-driven tests run every line, and a test must load each one. */ +export const PURE_MODULES: readonly string[] = [ + 'src/cli/cli-args.ts', + 'src/cli/commands/key-format.ts', + 'src/cli/help-text.ts', + 'src/client/app/terminal-options.ts', + 'src/client/app/terminal-theme.ts', + 'src/client/connection/close-policy.ts', + 'src/client/connection/status-line.ts', + 'src/client/features/compose/composer-geometry.ts', + 'src/client/features/find/find-format.ts', + 'src/client/features/find/find-keys.ts', + 'src/client/features/keyboard/font-zoom.ts', + 'src/client/features/keyboard/key-bindings.ts', + 'src/client/features/kitty/graphics/placement-geometry.ts', + 'src/client/features/kitty/protocol/apc-command.ts', + 'src/client/features/kitty/protocol/apc-splitter.ts', + 'src/client/features/kitty/protocol/cell-geometry.ts', + 'src/client/features/kitty/protocol/chunk-assembler.ts', + 'src/client/features/kitty/protocol/control-scanner.ts', + 'src/client/features/kitty/protocol/kitty-reply.ts', + 'src/client/features/kitty/protocol/png-size.ts', + 'src/client/features/paste/non-text-paste.ts', + 'src/client/features/scroll/scroll-split.ts', + 'src/client/layout/edge-rects.ts', + 'src/client/layout/fit-insets.ts', + 'src/client/vendor/ghostty-web/core/terminal-geometry.ts', + 'src/client/vendor/ghostty-web/engine/kitty-keyboard-reply.ts', + 'src/client/vendor/ghostty-web/engine/text-mime.ts', + 'src/client/vendor/ghostty-web/engine/vt-layout.ts', + 'src/client/vendor/ghostty-web/input/key-encoding.ts', + 'src/client/vendor/ghostty-web/render/cell-range.ts', + 'src/client/vendor/ghostty-web/render/frame-lines.ts', + 'src/client/vendor/ghostty-web/render/row-band.ts', + 'src/client/vendor/ghostty-web/render/scrollbar-geometry.ts', + 'src/client/vendor/ghostty-web/selection/selection-coords.ts', + 'src/config/client-view.ts', + 'src/config/keyboard-bindings.ts', + 'src/config/parse-config.ts', + 'src/server/sessions/session-banner.ts', + 'src/server/sessions/session-id.ts', + 'src/server/ws/client-message.ts', +]; + +const ZERO = { linesFound: 0, linesHit: 0, functionsFound: 0, functionsHit: 0 }; + +/** The lcov tags this check reads, by the field each one fills. */ +const COUNTERS = { LF: 'linesFound', LH: 'linesHit', FNF: 'functionsFound', FNH: 'functionsHit' }; + +/** The records of an lcov report, with paths made relative to `root` and `/`-separated. */ +export function parseLcov(text: string, root: string): FileCoverage[] { + const out: FileCoverage[] = []; + let current: FileCoverage | undefined; + for (const raw of text.split('\n')) { + const line = raw.trim(); + const colon = line.indexOf(':'); + const tag = colon < 0 ? line : line.slice(0, colon); + const value = line.slice(colon + 1); + if (tag === 'SF') { + const file = path.isAbsolute(value) ? path.relative(root, value) : value; + current = { file: file.split(path.sep).join('/'), ...ZERO }; + } else if (current && tag in COUNTERS) { + current[COUNTERS[tag as keyof typeof COUNTERS] as keyof typeof ZERO] = Number(value); + } else if (current && tag === 'end_of_record') { + out.push(current); + current = undefined; + } + } + return out; +} + +/** Test helpers are not source under test: test files, test-support/ and the vendor tests. */ +export function isTestCode(file: string): boolean { + return ( + file.endsWith('.test.ts') || + file.split('/').includes('test-support') || + file.startsWith('src/client/vendor/tests/') + ); +} + +/** The layer a source file counts toward, or undefined for test code and scripts. */ +export function layerOf(file: string): LayerTarget | undefined { + if (isTestCode(file)) return undefined; + return LAYER_TARGETS.find((layer) => layer.pattern.test(file)); +} + +/** A share of `found` that was hit; nothing to hit counts as all of it. */ +export function ratio(hit: number, found: number): number { + return found === 0 ? 1 : hit / found; +} + +/** A layer's summed counts, for the report and the check. */ +export interface LayerTotals extends LayerTarget { + totals: Omit<FileCoverage, 'file'>; +} + +/** Sums the files of each layer; a layer no test loaded has zero counts. */ +export function totalsByLayer(files: readonly FileCoverage[]): LayerTotals[] { + const layers = LAYER_TARGETS.map((target) => ({ ...target, totals: { ...ZERO } })); + for (const file of files) { + const name = layerOf(file.file)?.name; + const layer = layers.find((candidate) => candidate.name === name); + if (!layer) continue; + layer.totals.linesFound += file.linesFound; + layer.totals.linesHit += file.linesHit; + layer.totals.functionsFound += file.functionsFound; + layer.totals.functionsHit += file.functionsHit; + } + return layers; +} + +const percent = (share: number): string => `${(share * 100).toFixed(2)}%`; + +/** One report line per layer: its lines and functions against the targets. */ +export function formatLayer(layer: LayerTotals): string { + const { linesHit, linesFound, functionsHit, functionsFound } = layer.totals; + return `${layer.name.padEnd(8)} lines ${percent(ratio(linesHit, linesFound)).padStart(7)} (≥ ${percent(layer.lines)}) functions ${percent(ratio(functionsHit, functionsFound)).padStart(7)} (≥ ${percent(layer.functions)})`; +} + +/** Every target the report misses: a layer under its shares, or a pure module not at 100%. */ +export function coverageFailures(files: readonly FileCoverage[]): string[] { + const failures: string[] = []; + for (const layer of totalsByLayer(files)) { + const { linesHit, linesFound, functionsHit, functionsFound } = layer.totals; + if (ratio(linesHit, linesFound) < layer.lines) { + failures.push( + `${layer.name}: lines ${percent(ratio(linesHit, linesFound))} < ${percent(layer.lines)}`, + ); + } + if (ratio(functionsHit, functionsFound) < layer.functions) { + failures.push( + `${layer.name}: functions ${percent(ratio(functionsHit, functionsFound))} < ${percent(layer.functions)}`, + ); + } + } + const byFile = new Map(files.map((file) => [file.file, file])); + for (const pure of PURE_MODULES) { + const file = byFile.get(pure); + if (!file) failures.push(`${pure}: pure module not loaded by any test`); + else if (file.linesHit < file.linesFound) { + failures.push(`${pure}: pure module at ${file.linesHit}/${file.linesFound} lines, not 100%`); + } + } + return failures; +} + +/** Reads the lcov report under `root`, prints the layers and failures, returns the exit code. */ +export function runCoverageCheck( + root: string, + print: (line: string) => void = console.log, +): number { + const report = path.join(root, 'coverage', 'lcov.info'); + if (!fs.existsSync(report)) { + print(`${report} not found; run bun test --coverage --coverage-reporter=lcov first`); + return 1; + } + const files = parseLcov(fs.readFileSync(report, 'utf8'), root); + for (const layer of totalsByLayer(files)) print(formatLayer(layer)); + const failures = coverageFailures(files); + for (const failure of failures) print(`coverage: ${failure}`); + print( + failures.length === 0 + ? `coverage targets met (${PURE_MODULES.length} pure modules at 100% lines)` + : `${failures.length} coverage target(s) missed; see docs/specs/architecture.md "Testing"`, + ); + return failures.length === 0 ? 0 : 1; +} + +if (import.meta.main) { + process.exit(runCoverageCheck(path.join(import.meta.dir, '..'))); +} From 54433b652aeba0e203177b03a94530d483ba5dff Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:14:14 -0400 Subject: [PATCH 267/295] test(arch): ignore the coverage/ report folder test:coverage writes --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b1921a3..0308c56 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-lock.yaml # Build outputs dist/ +coverage/ # npm publish artifacts *.tgz From b1819668eba0d39427942c2eb9293f9df19dcac6 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:14:14 -0400 Subject: [PATCH 268/295] refactor(client): the last modules without a header comment get one output-ack.ts, replay-window.ts, shortcut-map.ts and scripts/build.ts gain the SPEC's one-to-three-sentence header (what the module is, its kind, its ADRs); terminal-theme.ts and fit-insets.ts name their kind in theirs, and their exports get a JSDoc line. Comments only. --- scripts/build.ts | 4 ++++ src/client/app/terminal-theme.ts | 6 ++++-- src/client/connection/output-ack.ts | 3 +++ src/client/connection/replay-window.ts | 3 +++ src/client/features/keyboard/shortcut-map.ts | 3 +++ src/client/layout/fit-insets.ts | 5 +++-- 6 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index ea7cc30..6c63840 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,3 +1,7 @@ +// Builds dist/: the server and CLI bundles for Node, the minified browser bundle, the page +// and stylesheet, the verified libghostty-vt wasm and THIRD_PARTY_LICENSES (entry, ADR 042, +// ADR 047). The output names are fixed; package.json and the server serve them by name. + import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/client/app/terminal-theme.ts b/src/client/app/terminal-theme.ts index c5c9350..3bb3d12 100644 --- a/src/client/app/terminal-theme.ts +++ b/src/client/app/terminal-theme.ts @@ -1,13 +1,14 @@ /** - * Maps webtty's theme keys onto the names ghostty-web understands. + * Maps webtty's theme keys onto the names ghostty-web understands (util, pure, ADR 036). * * webtty follows Windows Terminal's names (`purple`, `brightPurple`, `selection`) while * ghostty-web's `ITheme` uses xterm.js names (`magenta`, `brightMagenta`, * `selectionBackground`, `selectionForeground`). Keys it does not know are ignored, so * without this mapping ANSI purple and the selection colour fall back to ghostty-web's own - * defaults (`#bc3fbc`, and a light-gray selection with dark text). See ADR 036. + * defaults (`#bc3fbc`, and a light-gray selection with dark text). */ +/** The theme keys webtty's config uses, Windows Terminal's names, read by the mapping. */ export interface ThemeKeys { background?: string; foreground?: string; @@ -20,6 +21,7 @@ export interface ThemeKeys { [ansiKey: string]: string | undefined; } +/** The config theme in ghostty-web's `ITheme` names, so purple and the selection colours apply. */ export function toTerminalTheme(themeConfig: object): Record<string, string> { const theme = themeConfig as ThemeKeys; const { diff --git a/src/client/connection/output-ack.ts b/src/client/connection/output-ack.ts index 4d81ea0..0946352 100644 --- a/src/client/connection/output-ack.ts +++ b/src/client/connection/output-ack.ts @@ -1,3 +1,6 @@ +// Sends ack messages as output is parsed, so the server paces a slow tab (object, +// ADR 048). One instance per socket, bound to that socket's send and open check. + /** Characters of parsed output between acknowledgements (ADR 048). */ export const ACK_EVERY = 64 * 1024; /** How long after the last output a smaller count is acknowledged, in ms. */ diff --git a/src/client/connection/replay-window.ts b/src/client/connection/replay-window.ts index c3e3ede..4b738a8 100644 --- a/src/client/connection/replay-window.ts +++ b/src/client/connection/replay-window.ts @@ -1,3 +1,6 @@ +// Tells the session replay the server sends on every connect from new output (object, +// ADR 053, ADR 056), so a bell or a notification in the replay is not raised again. + /** * The size, in characters, of the pieces the server sends the replay in (its * `FlowLimits.chunk`, ADR 048): every piece but the last is this long, or one shorter diff --git a/src/client/features/keyboard/shortcut-map.ts b/src/client/features/keyboard/shortcut-map.ts index 6105118..2004f80 100644 --- a/src/client/features/keyboard/shortcut-map.ts +++ b/src/client/features/keyboard/shortcut-map.ts @@ -1,3 +1,6 @@ +// Ghostty's default copy, paste, clear, find and jump-to-prompt keys, by platform (util, +// pure, ADR 014, ADR 054, ADR 055, ADR 057). The key dispatcher looks a keydown up here. + /** * A copy, paste, clear-screen (ADR 054), find (ADR 055) or jump-to-prompt (ADR 057) key, * as Ghostty binds them by default. `performable`: when the action cannot be done (no selection to copy, the diff --git a/src/client/layout/fit-insets.ts b/src/client/layout/fit-insets.ts index c8b3139..e550355 100644 --- a/src/client/layout/fit-insets.ts +++ b/src/client/layout/fit-insets.ts @@ -1,11 +1,12 @@ /** - * Padding around the canvas in the `#terminal` container. + * Padding around the canvas in the `#terminal` container (util, pure, ADR 033). * * The vendored `FitAddon` sizes the grid from the full container, with no scrollbar * reserve, so the only space left over is the sub-cell remainder. This places that - * remainder and the configured padding around the canvas. See ADR 033. + * remainder and the configured padding around the canvas. */ +/** The padding on each side of the canvas, in CSS pixels. */ export interface Insets { left: number; right: number; From 85a3574a90573a2f18dd20bd451524ce1410f56e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:16:24 -0400 Subject: [PATCH 269/295] =?UTF-8?q?docs(arch):=20the=20refactor=20is=20don?= =?UTF-8?q?e:=20ADR=20062,=20063=20and=20064=20rows=20=E2=9C=85,=20the=20t?= =?UTF-8?q?ree=20as=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit architecture.md: the server composition, CLI command modules and test strategy rows are done; the contracts paragraph names the close-code contract test and drops the temporary ClientConfig alias; the tree drops the flow.ts shim and lists running-session.ts, the server, CLI and vendor test support as built, and check-coverage.ts; the ratchet paragraph says the allowlist is empty and strict; the coverage bullet names the shared src/*.ts layer, PURE_MODULES and what is not counted. client.md and kitty-graphics.md name the modules that replaced index.ts (the key dispatcher, the clipboard, notifications and kitty features, the output pipeline) and engine/ as the wasm binding; deep-link.md names the renamed pty files. --- docs/specs/architecture.md | 30 +++++++++++++++++------------- docs/specs/client.md | 12 +++++++----- docs/specs/deep-link.md | 6 +++--- docs/specs/kitty-graphics.md | 9 +++++++-- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index cfaffb5..dc333ec 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -1,6 +1,6 @@ # SPEC: Architecture -**Last Updated:** 2026-10-14 (revised after review) +**Last Updated:** 2026-10-14 (the refactor is done: the server branch merged, the ratchet allowlist empty, the coverage targets enforced) --- @@ -51,7 +51,7 @@ src/client: | Test files (`*.test.ts`) may cross the client/server line (contract tests); the import rules above apply to source files only. | same | | The checker counts `.ts` files only, excluding `*.d.ts`; fixtures, the wasm, JSON, Markdown, HTML and CSS are not source. | same | -**Contracts across the client/server line.** The config types are defined once, in `src/config/config-schema.ts`: `CLIENT_CONFIG_KEYS` (a `const` tuple, the one key list, legacy `scrollback` included) and `ClientConfig = Pick<Config, (typeof CLIENT_CONFIG_KEYS)[number]>`. The server builds `/api/config` from that list (`client-view.ts`, whose test pins the exact keys and order), and the client imports the `ClientConfig` type with `import type`, so the three copies of today (client type, server whitelist, server type) become one type and one key list. A type has no runtime keys, so there is no runtime contract test for it: `tsc` checks the client against the type. While the server and client branches of the refactor are apart, the client keeps a local `Pick<Config, …>` alias marked temporary, replaced by the import when they merge. Wire constants that the client needs as values (the WebSocket close codes 1001, 4001, 4003) are mirrored in `src/client/connection/close-codes.ts` and pinned to the server's by a contract test that imports both (tests may cross the line; code may not). +**Contracts across the client/server line.** The config types are defined once, in `src/config/config-schema.ts`: `CLIENT_CONFIG_KEYS` (a `const` tuple, the one key list, legacy `scrollback` included) and `ClientConfig = Pick<Config, (typeof CLIENT_CONFIG_KEYS)[number]>`. The server builds `/api/config` from that list (`client-view.ts`, whose test pins the exact keys and order), and the client imports the `ClientConfig` type with `import type`, so the three copies of today (client type, server whitelist, server type) become one type and one key list. A type has no runtime keys, so there is no runtime contract test for it: `tsc` checks the client against the type (the client's test fixtures carry the legacy `scrollback` key too). Wire constants that the client needs as values (the WebSocket close codes 1001, 4001, 4003) are mirrored in `src/client/connection/close-codes.ts` and pinned to the server's `WS_CLOSE` by a contract test, `connection/close-codes.test.ts`, that imports both (tests may cross the line; code may not). The client's `replay-window.test.ts` likewise reads the replay piece size from the server's `DEFAULT_FLOW_LIMITS` in `output-pacer.ts`. ## Folder tree @@ -63,7 +63,9 @@ src/ runtime.ts util: bunTerminalAvailable, launchedFromBunExecutable, runningUnderBun, isWindows, packageRunner (one predicate per meaning of the three old isBun definitions, each taking an env with a production default) ndjson.ts object: LineSplitter (server /publish and CLI run) - test-support/server-harness.ts test util: getFreePort, waitForServer*, makeTmpHome (was src/utils.test.ts) + test-support/ server, CLI and version test support: server-harness.ts (getFreePort, waitForServer*, + makeTmpHome; was src/utils.test.ts), live-server.ts and route-harness.ts (the server + in-process), fake-pty.ts, fake-socket.ts, response-recorder.ts config/ node; server + cli; the client imports types only index.ts service: loadConfig, initConfig, re-exports the types (keeps `../config` imports working) config-schema.ts types: Config, Theme, KeyboardBinding, RightClickBehavior, ClientConfig, CLIENT_CONFIG_KEYS @@ -81,8 +83,6 @@ src/ app.ts factory: createServer({ loadConfig, store, spawnPty, paths, onStop }) → { httpServer, wss, shutdown } server-paths.ts util: projectRoot, clientRoot, wasmPath (the one place that knows src vs dist depth) output-pacer.ts object: OutputPacer, splitChunks, DEFAULT_FLOW_LIMITS (was flow.ts) - flow.ts temporary one-line re-export of output-pacer.ts, kept only until the client's - replay-window.test.ts is repointed after the two branches merge, then deleted http/ router.ts util: ordered method + pattern table (declaration order = the old if-chain order) respond.ts util: sendJson, sendText, CORS_HEADERS @@ -91,6 +91,7 @@ src/ routes/ route-table.ts wiring: the ordered route list server-routes.ts config-routes.ts session-routes.ts page-routes.ts asset-routes.ts publish-route.ts execute-route.ts + running-session.ts util: the checks publish and execute share (ID, content type, a running session) run-command.ts service: child process → NDJSON (spawn injected) sessions/ session-store.ts object: SessionStore (map, lastUsedId, rename, remove, findByPid, onEmpty) @@ -112,6 +113,7 @@ src/ server-process.ts service: resolveServerEntry (pure), startServer, stopServer browser-opener.ts util: openBrowser, toBrowserHost commands/ one file per command: go, list, remove, rename, start-stop, edit-config, run, key; key-format.ts + test-support/ fake-cli-deps.ts, scripted-fetch.ts client/ index.ts (entry) ~15 lines: await startClient(browserEnv()) client.html index.css (paths fixed: copied by scripts/build.ts) @@ -164,6 +166,7 @@ src/ support/ wasm.ts (the one WASM path, a cached loadGhostty(), countedGhostty()), fake-canvas.ts, fake-event-target.ts, engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours), selection-fixture.ts (a SelectionManager on a recording canvas and document), + raster-canvas.ts and renderer-harness.ts (renderers on canvases that record or rasterise), terminal-harness.ts (a whole Terminal opened on the fakes, driven through its public API) fixtures/ PTY captures (was vendor/fixtures/) ghostty-web/ Biome-excluded; see "The vendored layer" @@ -173,7 +176,8 @@ scripts/ architecture/ the checker's parts, each with its test: source-file.ts (paths, layers), size-limits.ts, import-scan.ts, import-rules.ts (one table entry per rule), import-cycles.ts, naming-rules.ts, ratchet.ts, ratchet-allowlist.ts (the data, with each entry's step) - check-coverage.ts per-layer coverage targets over bun's lcov report (run by test:coverage) + check-coverage.ts entry: per-layer coverage targets and the pure-module list over bun's lcov report + (run by test:coverage) (+ test) check-pkg-manager.ts clean-pkg-scripts.ts package-manager guard and publish-time script cleanup (headers; pure parts tested) update-ghostty-vt.ts CLI only (--commit, --verify) ghostty-vt/ minisign.ts, sidecar.ts, inspect-wasm.ts, release-fetch.ts (fetch injected), each with its test @@ -282,7 +286,7 @@ Features get what they need from `ClientContext` (`term`, `container`, `config`, ## Size limits -Counted in lines of `.ts` source (not `*.d.ts`), comments and blank lines included. Checked by `scripts/check-architecture.ts` under `bun test`, on both refactor branches: the checker lands first on the client branch and the server branch starts from that commit, so every server step runs it too. +Counted in lines of `.ts` source (not `*.d.ts`), comments and blank lines included. Checked by `scripts/check-architecture.ts` under `bun test`. During the refactor it ran on both branches: the checker landed first on the client branch and the server branch started from that commit, so every server step ran it too. | Files | Aim | Hard limit | |---|---|---| @@ -293,7 +297,7 @@ Counted in lines of `.ts` source (not `*.d.ts`), comments and blank lines includ | Test files | ≤ 600 | 800 | | Functions | ≤ 60 lines | not checked; hot paths (`decodeRow`, `render`) say why when longer | -While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/architecture/ratchet-allowlist.ts`): each file over its limit today, with its current line count, and each import or naming rule broken today, keyed `<rule> <file> -> <target>` or `<rule> <file>`, each with the step that removes it. A listed file may shrink or disappear, never grow; each step removes the entries it fixes, and the list is empty at the end. While both branches run, a missing (deleted or renamed) entry is tolerated so the server branch never edits the checker; once they merge the list is emptied and a stale entry fails. +The checker carries a **ratchet allowlist** (`scripts/architecture/ratchet-allowlist.ts`): a file over its limit, with its line count, or a rule break, keyed `<rule> <file> -> <target>` or `<rule> <file>`, each with the step that removes it. A listed file may shrink or disappear, never grow. During the refactor it held what the tree broke when the checker landed, and each step removed the entries it fixed; while the client and server branches ran apart, a missing entry was tolerated so the server branch never edited the checker. Since the merge the list is **empty and strict**: the whole tree passes with no exception, a stale entry fails, and a new entry is a reviewed exception with the step that removes it. ## Naming @@ -323,19 +327,19 @@ While the refactor runs, the checker carries a **ratchet allowlist** (`scripts/a - **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. - **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. - **Listener snapshots.** Before the vendored facades are split, a test (`vendor/tests/core/listener-snapshot.test.ts`) opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. Since that commit every row is removed, and `vendor/tests/core/dispose.test.ts` checks that `dispose()` also clears every timeout, interval and animation frame, including after a failed `open()`. -- **Coverage targets** (`bun run test:coverage`): app, server, CLI, config and pty ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; pure modules 100%. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov`, enforces the per-layer targets from `coverage/lcov.info`. +- **Coverage targets** (`bun run test:coverage`): app, server, CLI, config, pty and the shared `src/*.ts` files ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; the pure modules listed in `PURE_MODULES` at 100% lines, each loaded by a test. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov --coverage-reporter=text`, enforces the per-layer targets from `coverage/lcov.info`. Test code (`*.test.ts`, `test-support/`, `vendor/tests/`) and `scripts/` are not counted toward a layer. A new pure module joins the list with its table-driven test. ## Features | Feature | Description | ADR | Done? | |---|---|---|---| | Architecture SPEC and conventions | Layers, folders, module kinds, naming, size limits, comments | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | -| Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet, import rules | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | +| Architecture checker | `scripts/check-architecture.ts`: size limits with a ratchet (empty and strict since the merge), import rules, cycles, naming | [ADR 059](../adrs/059.architecture.code-organisation.md) | ✅ | | Client composition root and feature modules | `start-client.ts`, `install(ctx, deps)`, PtyConnection, one key dispatcher, `index.ts` an entry only | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Kitty graphics split | `kitty.ts` and `graphics.ts` split into `features/kitty/protocol` and `graphics` | [ADR 060](../adrs/060.client.feature-modules.md) | ✅ | | Vendored engine split | `ghostty.ts` into `engine/`, `OfficialTerminal` a facade | [ADR 061](../adrs/061.client.vendor-split.md) | ✅ | | Vendored terminal, renderer, input and selection split | facades at their paths, parts in `core/`, `render/`, `input/`, `selection/`; the `types.ts` shim removed, every file with its origin header | [ADR 061](../adrs/061.client.vendor-split.md) | ✅ | | Listener teardown | `Terminal.dispose` and `SelectionManager` release every listener | [ADR 065](../adrs/065.client.listener-teardown.md) | ✅ | -| Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ⬜ | -| CLI command modules | `main.ts`, WebttyClient, one file per command with injected deps | [ADR 063](../adrs/063.cli.command-modules.md) | ⬜ | -| Test strategy | test-support fakes, injected happy-dom, in-process server tests, vendor test mirror, listener snapshots, per-layer coverage check | [ADR 064](../adrs/064.architecture.test-strategy.md) | ⬜ | +| Server composition | `createServer`, SessionStore, route table, `ws/` and `sessions/`, config split | [ADR 062](../adrs/062.server.composition.md) | ✅ | +| CLI command modules | `main.ts`, WebttyClient, one file per command with injected deps | [ADR 063](../adrs/063.cli.command-modules.md) | ✅ | +| Test strategy | test-support fakes, injected happy-dom, in-process server tests, vendor test mirror, listener snapshots, close-code contract test, per-layer coverage check (`scripts/check-coverage.ts`) | [ADR 064](../adrs/064.architecture.test-strategy.md) | ✅ | diff --git a/docs/specs/client.md b/docs/specs/client.md index 58eac99..f8ef78b 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -50,7 +50,9 @@ src/client/ vendor/ ghostty-web/ ← derived from coder/ghostty-web lib/ at 1858a59 and heavily modified by webtty, MIT LICENSE, PROVENANCE.md (upstream pin, local changes, moves and splits); - ghostty.ts holds every wasm call, the binding to the official wasm (ADR 042, ADR 047); + engine/ holds every wasm call, the binding to the official wasm (ADR 042, ADR 047); + terminal.ts, renderer.ts, input-handler.ts, selection-manager.ts and ghostty.ts are + facades over their parts in core/, render/, input/, selection/ and engine/ (ADR 061); fn-table.ts is the function-table trampoline borrowed from ghostty-vt-js (LICENSE.ghostty-vt-js) ghostty-vt/ ← official ghostty-vt-small.wasm (Ghostty 05dcf887, ReleaseSmall) + .minisig + sidecar (commit, URL, size, sha256, signature, key, licence), Ghostty LICENSE, PROVENANCE.md; @@ -200,7 +202,7 @@ Ghostty's default copy, paste and clear-screen keys, for the browser's platform | others | Shift+Insert | paste the clipboard (a page cannot read the primary selection) | | | all | Ctrl+V, also with Shift or Alt | paste (ADR 014) | | -Ctrl+C is always the app's. Modifiers match exactly except for Ctrl+V. `features/keyboard/shortcut-map.ts` maps the `keydown` (`shortcutFor`); one capture listener on the container in `index.ts`, after the `keyboardBindings` listener, performs it and steps aside for any combo `keyboardBindings` has, so a binding (even `"chars": ""`) wins. A paste reads `navigator.clipboard.readText` (a failed read sends Ctrl+V); without the API the key is kept from the app and the browser's own paste may fire the `paste` event. +Ctrl+C is always the app's. Modifiers match exactly except for Ctrl+V. `features/keyboard/shortcut-map.ts` maps the `keydown` (`shortcutFor`); the key dispatcher (`features/keyboard/key-dispatcher.ts`), one capture listener on the container, tries `keyboardBindings` first and performs the shortcut only when no binding has the combo, so a binding (even `"chars": ""`) wins. A paste reads `navigator.clipboard.readText` (a failed read sends Ctrl+V); without the API the key is kept from the app and the browser's own paste may fire the `paste` event. **Cmd+K** is Ghostty's `clear_screen`, done by `Terminal.clearScreen()` with VT sequences written to the terminal, never the PTY: @@ -225,7 +227,7 @@ The canvas is invisible to the browser's own find, so webtty has Ghostty's searc | all | Escape | close | the app gets the key (bar closed) | | in the field | Enter / Shift+Enter | next / previous match | | -Ctrl+F stays the app's. The keys go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `features/find/find-keys.ts`). +Ctrl+F stays the app's. The keys go through `shortcutFor` and the key dispatcher, so `keyboardBindings` win; in the field they are the field's own (`fieldKey` in `features/find/find-keys.ts`). - **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. - **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). @@ -303,7 +305,7 @@ Programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL` or ST), | `52;c;?` (query) | no answer, ever | | invalid base64, or a sequence over 8 MiB (about 6 MiB of text) | ignored whole; missing padding is accepted | -`readEffects` reports the last write since the previous call, `Terminal.onClipboardWrite` fires with it, and `index.ts` passes it to `ClipboardWriter` (`features/clipboard/clipboard-writer.ts`), which calls `navigator.clipboard.writeText`. After each (re)connect it ignores writes until a `keydown`, `mousedown`, `paste` or `drop` in the tab, so the replayed history does not overwrite the clipboard on reload. A missing clipboard API (http other than localhost), a refused write (no focus in Chrome, no recent input in Firefox, most writes in Safari) and a write held back for the replay are each logged to the console once; there is no error UI. +`readEffects` reports the last write since the previous call, `Terminal.onClipboardWrite` fires with it, and the clipboard feature (`features/clipboard/clipboard-feature.ts`) passes it to `ClipboardWriter` (`features/clipboard/clipboard-writer.ts`), which calls `navigator.clipboard.writeText`. After each (re)connect it ignores writes until a `keydown`, `mousedown`, `paste` or `drop` in the tab, so the replayed history does not overwrite the clipboard on reload. A missing clipboard API (http other than localhost), a refused write (no focus in Chrome, no recent input in Firefox, most writes in Safari) and a write held back for the replay are each logged to the console once; there is no error UI. ## Focus Reports, Title and Bell @@ -325,7 +327,7 @@ Programs ask for desktop notifications with OSC 9 (`ESC ] 9 ; <body> BEL`, iTerm | `9;4`, `9;42 tests passed` (not a full ConEmu form) | a notification, as in Ghostty | | `777;<other>;…`, `777;notify;<title>` (no body) | nothing | -`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and `index.ts` passes it to `DesktopNotifier` (`features/notifications/desktop-notifier.ts`, with the browser's API behind `browser-notifications.ts`), which, as Ghostty on macOS: +`readEffects` reports every request since the previous call, `Terminal.onDesktopNotification` fires for each, and the notifications feature (`features/notifications/notifications-feature.ts`) passes it to `DesktopNotifier` (`features/notifications/desktop-notifier.ts`, with the browser's API behind `browser-notifications.ts`), which, as Ghostty on macOS: - drops it while `ReplayWindow` says the output is replay; - shows at most one a second, and the same title and body at most once in five seconds; diff --git a/docs/specs/deep-link.md b/docs/specs/deep-link.md index ce767a4..5ef8429 100644 --- a/docs/specs/deep-link.md +++ b/docs/specs/deep-link.md @@ -121,11 +121,11 @@ webtty is an npm CLI — no `Info.plist`, no bundle. A `webtty://` scheme would ### PID in session API — detail -**`PtyProcess` interface** (`src/pty/types.ts`): add `pid: number`. +**`PtyProcess` interface** (`src/pty/pty-process.ts`): add `pid: number`. **Backends**: -- `src/pty/node.ts`: return `pid: ptyProc.pid` -- `src/pty/bun.ts`: return `pid: proc.pid` +- `src/pty/node-backend.ts`: return `pid: ptyProc.pid` +- `src/pty/bun-backend.ts`: return `pid: proc.pid` **`Session`** (`src/server/sessions/session.ts`): no change to the Session struct — `pty.pid` is read directly from the PtyProcess when serializing. diff --git a/docs/specs/kitty-graphics.md b/docs/specs/kitty-graphics.md index 407ad0f..f41c7c9 100644 --- a/docs/specs/kitty-graphics.md +++ b/docs/specs/kitty-graphics.md @@ -1,6 +1,6 @@ # SPEC: Kitty Graphics -**Last Updated:** 2026-10-13 +**Last Updated:** 2026-10-14 --- @@ -145,7 +145,12 @@ src/client/ placement-geometry.ts placement record, cursor advance, draw rectangle (pure) overlay-canvas.ts the overlay canvas, its frame loop and repaint rules, dispose() test-support/ graphics-harness.ts: fake terminal and decoder, happy-dom container - index.ts ← wires ws.onmessage through KittyGraphics + kitty-feature.ts ← installKitty: KittyGraphics on the container, its replies sent + through the connection (ADR 060) + connection/output-pipeline.ts ← the per-message order: replay window, KittyGraphics.feed + (which writes to the terminal), ack after the parse, smooth scroll + app/start-client.ts ← installs the kitty feature at step 3; app/connect-pty.ts builds the + pipeline around it and resets it when a socket opens ``` ## Known limitations From aa9dca1453fdc6926041c09dd0e650f859d50946 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:37:50 -0400 Subject: [PATCH 270/295] test(arch): drop the stale src/config.ts layer case now that config is a folder --- scripts/architecture/import-rules.test.ts | 2 +- scripts/architecture/source-file.test.ts | 2 +- scripts/architecture/source-file.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/architecture/import-rules.test.ts b/scripts/architecture/import-rules.test.ts index 5191c66..a3fd525 100644 --- a/scripts/architecture/import-rules.test.ts +++ b/scripts/architecture/import-rules.test.ts @@ -12,7 +12,7 @@ const TREE = [ file('src/server/flow.ts'), file('src/cli/http.ts'), file('src/pty/index.ts'), - file('src/config.ts'), + file('src/config/index.ts'), file('src/client/ack.ts'), file('src/client/vendor/ghostty-web/index.ts'), file('src/client/vendor/ghostty-web/glide.ts'), diff --git a/scripts/architecture/source-file.test.ts b/scripts/architecture/source-file.test.ts index f36d03c..44bcd3b 100644 --- a/scripts/architecture/source-file.test.ts +++ b/scripts/architecture/source-file.test.ts @@ -19,7 +19,7 @@ describe('layerOf', () => { ['src/cli/main.ts', 'cli'], ['src/pty/index.ts', 'pty'], ['src/pty', 'pty'], - ['src/config.ts', 'config'], + ['src/config/index.ts', 'config'], ['src/config', 'config'], ['src/config/defaults.ts', 'config'], ['scripts/build.ts', 'scripts'], diff --git a/scripts/architecture/source-file.ts b/scripts/architecture/source-file.ts index 3eefb12..60a7ea9 100644 --- a/scripts/architecture/source-file.ts +++ b/scripts/architecture/source-file.ts @@ -71,7 +71,6 @@ const LAYER_PREFIXES: [string, Layer][] = [ /** Which layer a file (or an unresolved import target) belongs to. */ export function layerOf(file: string): Layer { - if (file === 'src/config' || file === 'src/config.ts') return 'config'; for (const [prefix, layer] of LAYER_PREFIXES) { if (file.startsWith(prefix) || `${file}/` === prefix) return layer; } From f79a2b847e5cb0bc75e9ce3bc3b5a23a1f5b5537 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:37:51 -0400 Subject: [PATCH 271/295] test(arch): the coverage check shares isTestCode and fails on source files no test loads check-coverage.ts imports isTestCode from the architecture checker instead of a copy, and its layer lookup is renamed coverageLayerOf so it no longer shares a name with source-file.ts's layerOf. Bun writes no lcov record for a file no test loads, so a new untested module could not fail its layer; the check now lists src/ sources and fails on any without a record, except the type-only modules and entries in NOT_LOADED. --- docs/specs/architecture.md | 2 +- scripts/check-coverage.test.ts | 67 +++++++++++++++++++++++++++++++--- scripts/check-coverage.ts | 64 ++++++++++++++++++++++++-------- 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index dc333ec..8445e86 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -327,7 +327,7 @@ The checker carries a **ratchet allowlist** (`scripts/architecture/ratchet-allow - **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. - **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. - **Listener snapshots.** Before the vendored facades are split, a test (`vendor/tests/core/listener-snapshot.test.ts`) opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. Since that commit every row is removed, and `vendor/tests/core/dispose.test.ts` checks that `dispose()` also clears every timeout, interval and animation frame, including after a failed `open()`. -- **Coverage targets** (`bun run test:coverage`): app, server, CLI, config, pty and the shared `src/*.ts` files ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; the pure modules listed in `PURE_MODULES` at 100% lines, each loaded by a test. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov --coverage-reporter=text`, enforces the per-layer targets from `coverage/lcov.info`. Test code (`*.test.ts`, `test-support/`, `vendor/tests/`) and `scripts/` are not counted toward a layer. A new pure module joins the list with its table-driven test. +- **Coverage targets** (`bun run test:coverage`): app, server, CLI, config, pty and the shared `src/*.ts` files ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; the pure modules listed in `PURE_MODULES` at 100% lines, each loaded by a test. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov --coverage-reporter=text`, enforces the per-layer targets from `coverage/lcov.info`. Test code (`*.test.ts`, `test-support/`, `vendor/tests/`) and `scripts/` are not counted toward a layer. Bun writes an lcov record only for a file some test loads, so the check also fails on any `src/` source file with no record, except the type-only modules and thin entries listed in `NOT_LOADED`. A new pure module joins the list with its table-driven test. ## Features diff --git a/scripts/check-coverage.test.ts b/scripts/check-coverage.test.ts index d8a3256..c6ec0a4 100644 --- a/scripts/check-coverage.test.ts +++ b/scripts/check-coverage.test.ts @@ -4,15 +4,16 @@ import os from 'node:os'; import path from 'node:path'; import { coverageFailures, + coverageLayerOf, type FileCoverage, formatLayer, - isTestCode, LAYER_TARGETS, - layerOf, + NOT_LOADED, PURE_MODULES, parseLcov, ratio, runCoverageCheck, + sourceFiles, totalsByLayer, } from './check-coverage'; @@ -90,7 +91,7 @@ describe('parseLcov', () => { }); }); -describe('isTestCode and layerOf', () => { +describe('coverageLayerOf', () => { const cases: [string, string | undefined][] = [ ['src/client/vendor/ghostty-web/terminal.ts', 'vendor'], ['src/client/vendor/tests/support/wasm.ts', undefined], @@ -108,8 +109,7 @@ describe('isTestCode and layerOf', () => { ]; for (const [file, layer] of cases) { test(`${file} → ${layer}`, () => { - expect(layerOf(file)?.name).toBe(layer); - if (layer) expect(isTestCode(file)).toBe(false); + expect(coverageLayerOf(file)?.name).toBe(layer); }); } }); @@ -125,7 +125,7 @@ describe('the targets (architecture SPEC "Testing")', () => { test('every pure module exists and belongs to a layer', () => { for (const file of PURE_MODULES) { expect(fs.existsSync(path.join(REPO_ROOT, file))).toBe(true); - expect(layerOf(file)).toBeDefined(); + expect(coverageLayerOf(file)).toBeDefined(); } }); }); @@ -192,6 +192,61 @@ describe('coverageFailures', () => { }); }); +describe('source files no test loads', () => { + const cases: [string, string[], string[]][] = [ + // name, sources, failures + ['a loaded source passes', ['src/server/a.ts'], []], + [ + 'a source with no record fails', + ['src/server/new.ts'], + ['src/server/new.ts: not loaded by any test (add a test, or list it in NOT_LOADED)'], + ], + ['a listed type-only module or entry passes', [...NOT_LOADED], []], + ]; + for (const [name, sources, failures] of cases) { + test(name, () => { + expect(coverageFailures([...allPure(), covered('src/server/a.ts')], sources)).toEqual( + failures, + ); + }); + } + + test('every NOT_LOADED entry exists and counts toward a layer', () => { + for (const file of NOT_LOADED) { + expect(fs.existsSync(path.join(REPO_ROOT, file))).toBe(true); + expect(coverageLayerOf(file)).toBeDefined(); + } + }); + + test('sourceFiles lists src/ .ts sources, skipping test code and .d.ts', () => { + const root = tmpRepo(); + for (const file of [ + 'src/a.ts', + 'src/server/b.ts', + 'src/server/b.test.ts', + 'src/server/test-support/fake.ts', + 'src/client/types.d.ts', + 'src/client/c.css', + ]) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), ''); + } + expect(sourceFiles(root)).toEqual(['src/a.ts', 'src/server/b.ts']); + expect(sourceFiles(tmpRepo())).toEqual([]); + }); + + test('runCoverageCheck fails on a source file missing from the report', () => { + const root = tmpRepo(lcov(...allPure())); + fs.mkdirSync(path.join(root, 'src', 'pty'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src', 'pty', 'x.ts'), ''); + const lines: string[] = []; + expect(runCoverageCheck(root, (line) => lines.push(line))).toBe(1); + expect(lines).toContain( + 'coverage: src/pty/x.ts: not loaded by any test (add a test, or list it in NOT_LOADED)', + ); + }); +}); + describe('runCoverageCheck', () => { test('fails with a hint when there is no report', () => { const lines: string[] = []; diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index 3a54a5d..b2e3a65 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -6,6 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isTestCode } from './architecture/source-file'; /** One source file's counts from an lcov record. */ export interface FileCoverage { @@ -80,6 +81,25 @@ export const PURE_MODULES: readonly string[] = [ 'src/server/ws/client-message.ts', ]; +/** + * Source files no test loads, so bun writes no lcov record for them: type-only modules + * (nothing runs) and the thin entries (they only call the tested root). Any other source + * file missing from the report fails, so a new untested module cannot hide from its layer. + */ +export const NOT_LOADED: readonly string[] = [ + 'src/cli/index.ts', + 'src/client/app/client-context.ts', + 'src/client/index.ts', + 'src/client/vendor/ghostty-web/core/host-types.ts', + 'src/client/vendor/ghostty-web/engine/engine-types.ts', + 'src/client/vendor/ghostty-web/engine/wasm-exports.ts', + 'src/client/vendor/ghostty-web/interfaces.ts', + 'src/client/vendor/ghostty-web/link-types.ts', + 'src/client/vendor/ghostty-web/render/renderable.ts', + 'src/pty/pty-process.ts', + 'src/server/index.ts', +]; + const ZERO = { linesFound: 0, linesHit: 0, functionsFound: 0, functionsHit: 0 }; /** The lcov tags this check reads, by the field each one fills. */ @@ -107,17 +127,8 @@ export function parseLcov(text: string, root: string): FileCoverage[] { return out; } -/** Test helpers are not source under test: test files, test-support/ and the vendor tests. */ -export function isTestCode(file: string): boolean { - return ( - file.endsWith('.test.ts') || - file.split('/').includes('test-support') || - file.startsWith('src/client/vendor/tests/') - ); -} - -/** The layer a source file counts toward, or undefined for test code and scripts. */ -export function layerOf(file: string): LayerTarget | undefined { +/** The coverage target a source file counts toward, or undefined for test code and scripts. */ +export function coverageLayerOf(file: string): LayerTarget | undefined { if (isTestCode(file)) return undefined; return LAYER_TARGETS.find((layer) => layer.pattern.test(file)); } @@ -136,7 +147,7 @@ export interface LayerTotals extends LayerTarget { export function totalsByLayer(files: readonly FileCoverage[]): LayerTotals[] { const layers = LAYER_TARGETS.map((target) => ({ ...target, totals: { ...ZERO } })); for (const file of files) { - const name = layerOf(file.file)?.name; + const name = coverageLayerOf(file.file)?.name; const layer = layers.find((candidate) => candidate.name === name); if (!layer) continue; layer.totals.linesFound += file.linesFound; @@ -155,8 +166,14 @@ export function formatLayer(layer: LayerTotals): string { return `${layer.name.padEnd(8)} lines ${percent(ratio(linesHit, linesFound)).padStart(7)} (≥ ${percent(layer.lines)}) functions ${percent(ratio(functionsHit, functionsFound)).padStart(7)} (≥ ${percent(layer.functions)})`; } -/** Every target the report misses: a layer under its shares, or a pure module not at 100%. */ -export function coverageFailures(files: readonly FileCoverage[]): string[] { +/** + * Every target the report misses: a layer under its shares, a pure module not at 100%, + * or one of `sources` (the tree's source files) with no record and not in NOT_LOADED. + */ +export function coverageFailures( + files: readonly FileCoverage[], + sources: readonly string[] = [], +): string[] { const failures: string[] = []; for (const layer of totalsByLayer(files)) { const { linesHit, linesFound, functionsHit, functionsFound } = layer.totals; @@ -179,9 +196,26 @@ export function coverageFailures(files: readonly FileCoverage[]): string[] { failures.push(`${pure}: pure module at ${file.linesHit}/${file.linesFound} lines, not 100%`); } } + for (const source of sources) { + if (!byFile.has(source) && !NOT_LOADED.includes(source)) { + failures.push(`${source}: not loaded by any test (add a test, or list it in NOT_LOADED)`); + } + } return failures; } +/** The source files under `root`/src that count toward a layer, repo-relative and sorted. */ +export function sourceFiles(root: string): string[] { + const src = path.join(root, 'src'); + if (!fs.existsSync(src)) return []; + return fs + .readdirSync(src, { recursive: true, encoding: 'utf8' }) + .map((entry) => `src/${entry.split(path.sep).join('/')}`) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts')) + .filter((file) => coverageLayerOf(file) !== undefined) + .sort(); +} + /** Reads the lcov report under `root`, prints the layers and failures, returns the exit code. */ export function runCoverageCheck( root: string, @@ -194,7 +228,7 @@ export function runCoverageCheck( } const files = parseLcov(fs.readFileSync(report, 'utf8'), root); for (const layer of totalsByLayer(files)) print(formatLayer(layer)); - const failures = coverageFailures(files); + const failures = coverageFailures(files, sourceFiles(root)); for (const failure of failures) print(`coverage: ${failure}`); print( failures.length === 0 From cfd34e049cd0e8fa1e04b40e4c84ea39206e31b4 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:39:19 -0400 Subject: [PATCH 272/295] refactor(client): the Cmd+K screen clear moves from the root to features/keyboard/screen-clear.ts The composition root only builds and wires; screenClearer is keyboard behaviour (ADR 054), so it and its tests move next to the dispatcher that runs it. The root still builds it and passes it in as clearScreen. It joins the pure-module coverage list. --- docs/specs/architecture.md | 2 +- scripts/check-coverage.ts | 1 + src/client/app/start-client.test.ts | 31 +------------------ src/client/app/start-client.ts | 19 +----------- .../features/keyboard/screen-clear.test.ts | 31 +++++++++++++++++++ src/client/features/keyboard/screen-clear.ts | 20 ++++++++++++ 6 files changed, 55 insertions(+), 49 deletions(-) create mode 100644 src/client/features/keyboard/screen-clear.test.ts create mode 100644 src/client/features/keyboard/screen-clear.ts diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 8445e86..5a0bbd1 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -148,7 +148,7 @@ src/ clipboard/ clipboard-writer.ts (object, was clipboard-write.ts), clipboard-feature.ts paste/ non-text-paste.ts (util), paste-service.ts (service), paste-feature.ts find/ find-bar.ts (view), find-format.ts (util), find-keys.ts (util; takes the shortcut lookup, never imports keyboard/), find-feature.ts - keyboard/ key-bindings.ts, shortcut-map.ts (was shortcuts.ts), shortcut-commands.ts, font-zoom.ts, key-dispatcher.ts (feature), font-zoom-feature.ts + keyboard/ key-bindings.ts, shortcut-map.ts (was shortcuts.ts), shortcut-commands.ts, screen-clear.ts (Cmd+K), font-zoom.ts, key-dispatcher.ts (feature), font-zoom-feature.ts compose/ composer.ts (view + dispose), composer-geometry.ts (util) scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, scroll-feature.ts kitty/ diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index b2e3a65..a14fcd4 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -51,6 +51,7 @@ export const PURE_MODULES: readonly string[] = [ 'src/client/features/find/find-keys.ts', 'src/client/features/keyboard/font-zoom.ts', 'src/client/features/keyboard/key-bindings.ts', + 'src/client/features/keyboard/screen-clear.ts', 'src/client/features/kitty/graphics/placement-geometry.ts', 'src/client/features/kitty/protocol/apc-command.ts', 'src/client/features/kitty/protocol/apc-splitter.ts', diff --git a/src/client/app/start-client.test.ts b/src/client/app/start-client.test.ts index f628719..86becc1 100644 --- a/src/client/app/start-client.test.ts +++ b/src/client/app/start-client.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { type FakeClientEnv, fakeClientEnv } from '../test-support/fake-client-env'; import type { ListenerRecord } from '../test-support/recording-target'; import type { Disposable } from '../vendor/ghostty-web'; -import { screenClearer, startClient } from './start-client'; +import { startClient } from './start-client'; let fake: FakeClientEnv; let client: Disposable | undefined; @@ -256,32 +256,3 @@ describe('startClient', () => { expect(fake.clock.pending).toEqual({ timers: 0, frames: 0 }); }); }); - -describe('screenClearer', () => { - function parts(clears: boolean) { - const log: string[] = []; - const clear = screenClearer( - { - clearScreen: () => { - log.push('term.clearScreen'); - return clears; - }, - }, - { clear: () => log.push('graphics.clear') }, - { onOutput: () => log.push('smoothScroll.onOutput') }, - ); - return { log, clear }; - } - - test('clears the screen, then the placements, then the sub-line offset', () => { - const { log, clear } = parts(true); - expect(clear()).toBe(true); - expect(log).toEqual(['term.clearScreen', 'graphics.clear', 'smoothScroll.onOutput']); - }); - - test('does nothing more when the terminal does not clear (the alternate screen)', () => { - const { log, clear } = parts(false); - expect(clear()).toBe(false); - expect(log).toEqual(['term.clearScreen']); - }); -}); diff --git a/src/client/app/start-client.ts b/src/client/app/start-client.ts index e536ec0..63ff8fc 100644 --- a/src/client/app/start-client.ts +++ b/src/client/app/start-client.ts @@ -11,6 +11,7 @@ import { Composer } from '../features/compose/composer'; import { installFind } from '../features/find/find-feature'; import { installFontZoom } from '../features/keyboard/font-zoom-feature'; import { installKeyDispatcher } from '../features/keyboard/key-dispatcher'; +import { screenClearer } from '../features/keyboard/screen-clear'; import { shortcutFor } from '../features/keyboard/shortcut-map'; import { installKitty } from '../features/kitty/kitty-feature'; import { browserNotifications } from '../features/notifications/browser-notifications'; @@ -108,24 +109,6 @@ export async function startClient(env: ClientEnv): Promise<Disposable> { return store; } -/** - * Cmd+K (ADR 054): clears the screen, then the placements on it and any sub-line scroll - * offset. Nothing on the alternate screen; the clear is this tab's own, and a reload - * replays the session's output again. - */ -export function screenClearer( - term: { clearScreen(): boolean }, - graphics: { clear(): void }, - smoothScroll: { onOutput(): void }, -): () => boolean { - return () => { - if (!term.clearScreen()) return false; - graphics.clear(); - smoothScroll.onOutput(); - return true; - }; -} - /** Step 1: the session, the config, the tab title, the engine and the opened terminal. */ async function bootstrap(env: ClientEnv, store: DisposableStore): Promise<Bootstrap> { const doc = env.document; diff --git a/src/client/features/keyboard/screen-clear.test.ts b/src/client/features/keyboard/screen-clear.test.ts new file mode 100644 index 0000000..1f8c638 --- /dev/null +++ b/src/client/features/keyboard/screen-clear.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { screenClearer } from './screen-clear'; + +describe('screenClearer', () => { + function parts(clears: boolean) { + const log: string[] = []; + const clear = screenClearer( + { + clearScreen: () => { + log.push('term.clearScreen'); + return clears; + }, + }, + { clear: () => log.push('graphics.clear') }, + { onOutput: () => log.push('smoothScroll.onOutput') }, + ); + return { log, clear }; + } + + test('clears the screen, then the placements, then the sub-line offset', () => { + const { log, clear } = parts(true); + expect(clear()).toBe(true); + expect(log).toEqual(['term.clearScreen', 'graphics.clear', 'smoothScroll.onOutput']); + }); + + test('does nothing more when the terminal does not clear (the alternate screen)', () => { + const { log, clear } = parts(false); + expect(clear()).toBe(false); + expect(log).toEqual(['term.clearScreen']); + }); +}); diff --git a/src/client/features/keyboard/screen-clear.ts b/src/client/features/keyboard/screen-clear.ts new file mode 100644 index 0000000..7c3ce7d --- /dev/null +++ b/src/client/features/keyboard/screen-clear.ts @@ -0,0 +1,20 @@ +// The Cmd+K screen clear (util, pure, ADR 054): the root builds it from the terminal, the +// graphics and the smooth scroll, and passes it to the key dispatcher as `clearScreen`. + +/** + * Cmd+K: clears the screen, then the placements on it and any sub-line scroll offset. + * Nothing on the alternate screen; the clear is this tab's own, and a reload replays the + * session's output again. + */ +export function screenClearer( + term: { clearScreen(): boolean }, + graphics: { clear(): void }, + smoothScroll: { onOutput(): void }, +): () => boolean { + return () => { + if (!term.clearScreen()) return false; + graphics.clear(); + smoothScroll.onOutput(); + return true; + }; +} From a738e9a9240b02d6ba347c534e4fbfb588730a32 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:39:19 -0400 Subject: [PATCH 273/295] test(client): every feature installer is checked against FeatureInstaller at compile time --- src/client/app/client-context.test.ts | 38 +++++++++++++++++++++++++++ src/client/app/client-context.ts | 5 +++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/client/app/client-context.test.ts diff --git a/src/client/app/client-context.test.ts b/src/client/app/client-context.test.ts new file mode 100644 index 0000000..628cb89 --- /dev/null +++ b/src/client/app/client-context.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { installClipboard } from '../features/clipboard/clipboard-feature'; +import { installFind } from '../features/find/find-feature'; +import { installFontZoom } from '../features/keyboard/font-zoom-feature'; +import { installKeyDispatcher } from '../features/keyboard/key-dispatcher'; +import { installKitty } from '../features/kitty/kitty-feature'; +import { installNotifications } from '../features/notifications/notifications-feature'; +import { installPaste } from '../features/paste/paste-feature'; +import { installScroll } from '../features/scroll/scroll-feature'; +import { installTabTitle } from '../features/tab-title/tab-title-feature'; +import { installLayout } from '../layout/layout-feature'; +import { installRepaintOnVisible } from '../layout/repaint-on-visible'; +import type { FeatureInstaller } from './client-context'; + +// The compiler checks every installer against the `install<Name>(ctx, deps)` contract: +// it takes the ClientContext (and its own deps) and returns a Disposable feature. +const installers = { + installClipboard, + installFind, + installFontZoom, + installKeyDispatcher, + installKitty, + installLayout, + installNotifications, + installPaste, + installRepaintOnVisible, + installScroll, + installTabTitle, +} satisfies Record<string, FeatureInstaller<never>>; + +describe('FeatureInstaller', () => { + test('every installer the root calls is named install<Name>', () => { + for (const [name, install] of Object.entries(installers)) { + expect(name).toMatch(/^install[A-Z]\w*$/); + expect(install.name).toBe(name); + } + }); +}); diff --git a/src/client/app/client-context.ts b/src/client/app/client-context.ts index c59e5c9..379ce3f 100644 --- a/src/client/app/client-context.ts +++ b/src/client/app/client-context.ts @@ -25,7 +25,10 @@ export interface ClientContext { /** An installed feature: its `dispose()` removes everything it added and is idempotent. */ export type Feature = Disposable; -/** A feature installer, `install<Name>(ctx, deps)`, returning its feature and any API others need. */ +/** + * A feature installer, `install<Name>(ctx, deps)`, returning its feature and any API others + * need. client-context.test.ts holds every installer to it at compile time. + */ export type FeatureInstaller<Deps, F extends Feature = Feature> = ( ctx: ClientContext, deps: Deps, From 58d3851757e42af4a8607cd35b185673152c6a07 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:40:27 -0400 Subject: [PATCH 274/295] test(client): one recordingContext in vendor/tests/support for the four painter tests --- .../vendor/tests/render/cell-painter.test.ts | 19 +----------- .../tests/render/cursor-painter.test.ts | 20 +----------- .../vendor/tests/render/row-repainter.test.ts | 20 +----------- .../tests/render/scrollbar-painter.test.ts | 20 +----------- .../vendor/tests/support/recording-context.ts | 31 +++++++++++++++++++ 5 files changed, 35 insertions(+), 75 deletions(-) create mode 100644 src/client/vendor/tests/support/recording-context.ts diff --git a/src/client/vendor/tests/render/cell-painter.test.ts b/src/client/vendor/tests/render/cell-painter.test.ts index 1c8fb10..5809f16 100644 --- a/src/client/vendor/tests/render/cell-painter.test.ts +++ b/src/client/vendor/tests/render/cell-painter.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; import type { FrameDecorations, PaintSettings } from '../../ghostty-web/render/renderable'; import { CanvasRenderer } from '../../ghostty-web/renderer'; import { recordingCanvas } from '../support/raster-canvas'; +import { recordingContext } from '../support/recording-context'; import { COLS, ROWS, @@ -71,24 +72,6 @@ describe('cell colours', () => { }); }); -/** A context that records calls and writes as strings. */ -function recordingContext() { - const calls: string[] = []; - const state: Record<string, unknown> = {}; - const ctx = new Proxy(state, { - get: (t, key) => - typeof key === 'string' && key in t - ? t[key] - : (...args: unknown[]) => calls.push(`${String(key)}(${args.join(',')})`), - set: (t, key, value) => { - t[key as string] = value; - calls.push(`${String(key)}=${value}`); - return true; - }, - }); - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - describe('CellPainter', () => { const settings: PaintSettings = { fontSize: 15, diff --git a/src/client/vendor/tests/render/cursor-painter.test.ts b/src/client/vendor/tests/render/cursor-painter.test.ts index da2de27..242b8cb 100644 --- a/src/client/vendor/tests/render/cursor-painter.test.ts +++ b/src/client/vendor/tests/render/cursor-painter.test.ts @@ -10,6 +10,7 @@ import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; import type { RowSource } from '../../ghostty-web/render/frame-lines'; import type { CellTextPainter, PaintSettings } from '../../ghostty-web/render/renderable'; import { ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; +import { recordingContext } from '../support/recording-context'; import { COLS, ROWS, rasterRenderer, writtenTerminal } from '../support/renderer-harness'; import { loadGhostty } from '../support/wasm'; @@ -57,25 +58,6 @@ describe('CursorBlink', () => { }); }); -/** A context that records calls as strings. */ -function recordingContext() { - const calls: string[] = []; - const ctx = new Proxy( - {}, - { - get: - (_t, key) => - (...args: unknown[]) => - calls.push(`${String(key)}(${args.join(',')})`), - set: (_t, key, value) => { - calls.push(`${String(key)}=${value}`); - return true; - }, - }, - ); - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - describe('CursorPainter', () => { const settings: PaintSettings = { fontSize: 15, diff --git a/src/client/vendor/tests/render/row-repainter.test.ts b/src/client/vendor/tests/render/row-repainter.test.ts index c51a252..77e08db 100644 --- a/src/client/vendor/tests/render/row-repainter.test.ts +++ b/src/client/vendor/tests/render/row-repainter.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; import type { PaintSettings, RowContent, RowPainter } from '../../ghostty-web/render/renderable'; import { ROW_CUT, ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; import { REPAINT_CHUNK_ROWS, RowRepainter } from '../../ghostty-web/render/row-repainter'; +import { recordingContext } from '../support/recording-context'; import { COLS, type LineSelection, @@ -16,25 +17,6 @@ import { } from '../support/renderer-harness'; import { loadGhostty } from '../support/wasm'; -/** A context that records every call and property write, in order. */ -function recordingContext() { - const calls: string[] = []; - const ctx = new Proxy( - {}, - { - get: - (_t, key) => - (...args: unknown[]) => - calls.push(`${String(key)}(${args.join(',')})`), - set: (_t, key, value) => { - calls.push(`${String(key)}=${value}`); - return true; - }, - }, - ); - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} - /** Paint settings for 8 × 15 px cells at `dpr`. */ function settingsAt(dpr: number): PaintSettings { return { diff --git a/src/client/vendor/tests/render/scrollbar-painter.test.ts b/src/client/vendor/tests/render/scrollbar-painter.test.ts index c84385f..e829e23 100644 --- a/src/client/vendor/tests/render/scrollbar-painter.test.ts +++ b/src/client/vendor/tests/render/scrollbar-painter.test.ts @@ -3,25 +3,7 @@ import { DEFAULT_THEME } from '../../ghostty-web/render/default-theme'; import type { PaintSettings } from '../../ghostty-web/render/renderable'; import { ROW_INVALID, ROW_JOINED } from '../../ghostty-web/render/row-band'; import { ScrollbarPainter } from '../../ghostty-web/render/scrollbar-painter'; - -/** A context that records calls and writes as strings. */ -function recordingContext() { - const calls: string[] = []; - const ctx = new Proxy( - {}, - { - get: - (_t, key) => - (...args: unknown[]) => - calls.push(`${String(key)}(${args.join(',')})`), - set: (_t, key, value) => { - calls.push(`${String(key)}=${value}`); - return true; - }, - }, - ); - return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; -} +import { recordingContext } from '../support/recording-context'; const settings: PaintSettings = { fontSize: 15, diff --git a/src/client/vendor/tests/support/recording-context.ts b/src/client/vendor/tests/support/recording-context.ts new file mode 100644 index 0000000..c16c46b --- /dev/null +++ b/src/client/vendor/tests/support/recording-context.ts @@ -0,0 +1,31 @@ +// A 2D canvas context that records what is drawn on it (test fake, ADR 064), for the +// render painter tests that assert the exact calls and writes, in order. + +/** The recording context and its log. */ +export interface RecordingContext { + ctx: CanvasRenderingContext2D; + /** Each call as `name(arg,arg)` and each property write as `name=value`, in order. */ + calls: string[]; +} + +/** + * A `CanvasRenderingContext2D` stand-in that logs every method call and property write. + * A written property reads back its value, as on a real context; any other read is a + * method that logs its call. + */ +export function recordingContext(): RecordingContext { + const calls: string[] = []; + const state: Record<string, unknown> = {}; + const ctx = new Proxy(state, { + get: (target, key) => + typeof key === 'string' && key in target + ? target[key] + : (...args: unknown[]) => calls.push(`${String(key)}(${args.join(',')})`), + set: (target, key, value) => { + target[key as string] = value; + calls.push(`${String(key)}=${value}`); + return true; + }, + }); + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} From 4f9f20d95deab07b90c4e51587e7256cacddc6e4 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:42:07 -0400 Subject: [PATCH 275/295] refactor(client): OSC8LinkProvider.provideLinks scans, linkAt reads one link's run provideLinks was 80 lines, over the SPEC's 60-line budget and not a hot path. The run read moves to a private linkAt; the scan skips past each run instead of keeping a set of visited columns, which visits the same cells in the same order. --- .../providers/osc8-link-provider.ts | 118 +++++++----------- 1 file changed, 47 insertions(+), 71 deletions(-) diff --git a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts index a94ba81..c91ac06 100644 --- a/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts +++ b/src/client/vendor/ghostty-web/providers/osc8-link-provider.ts @@ -18,89 +18,62 @@ export class OSC8LinkProvider implements ILinkProvider { private readonly open: LinkOpener = windowOpen ) {} - /** - * Provide all OSC 8 links on the given row - */ + /** Provide all OSC 8 links on the given row. */ provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { - const links: ILink[] = []; - const visitedPositions = new Set<number>(); // Track which columns we've already processed - const line = this.terminal.buffer.active.getLine(y); if (!line) { callback(undefined); return; } - // Scan through this line looking for hyperlink_id + const links: ILink[] = []; for (let x = 0; x < line.length; x++) { - // Skip already processed positions - if (visitedPositions.has(x)) continue; - - const cell = line.getCell(x); - if (!cell) continue; - - const hyperlinkId = cell.getHyperlinkId(); - - // Skip cells without links - if (hyperlinkId === 0) { - continue; - } - - // Get the URI from WASM - // The y parameter is a buffer row - we need to determine if it's in - // scrollback or the active viewport and use the appropriate API - if (!this.terminal.wasmTerm) continue; - const scrollbackLength = this.terminal.wasmTerm.getScrollbackLength(); - const viewportRow = y - scrollbackLength; - - let uri: string | null; - if (viewportRow < 0) { - // Row is in scrollback - use scrollback API - // y is the buffer row (0 = oldest), scrollback offset is also 0 = oldest - uri = this.terminal.wasmTerm.getScrollbackHyperlinkUri(y, x); - } else { - // Row is in active viewport - uri = this.terminal.wasmTerm.getHyperlinkUri(viewportRow, x); - } - - if (uri) { - // Find the end of this link by scanning forward until we hit a cell - // without a hyperlink or with a different URI - let endX = x; - for (let col = x + 1; col < line.length; col++) { - const nextCell = line.getCell(col); - if (!nextCell || nextCell.getHyperlinkId() === 0) break; - - // Check if this cell has the same URI (use appropriate API for scrollback vs viewport) - const nextUri = - viewportRow < 0 - ? this.terminal.wasmTerm!.getScrollbackHyperlinkUri(y, col) - : this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col); - if (nextUri !== uri) break; - - endX = col; - } - - // Mark all columns in this link as visited - for (let col = x; col <= endX; col++) { - visitedPositions.add(col); - } + const link = this.linkAt(line, x, y); + if (!link) continue; + links.push(link); + // The link covers its whole run of cells, so the scan resumes after its last one + x = link.range.end.x; + } - const range: IBufferRange = { - start: { x, y }, - end: { x: endX, y }, - }; + callback(links.length > 0 ? links : undefined); + } - links.push({ - text: uri, - range, - // Opens with Ctrl/Cmd held (ADR 061: one rule for both providers) - activate: (event) => openLinkOnModifier(uri, event, this.open), - }); - } + /** The link whose run starts at column `x` of buffer row `y`, or null if that cell has none. */ + private linkAt(line: OSC8Line, x: number, y: number): ILink | null { + const cell = line.getCell(x); + if (!cell || cell.getHyperlinkId() === 0) return null; + + const wasmTerm = this.terminal.wasmTerm; + if (!wasmTerm) return null; + // y is a buffer row (0 = oldest): a row above the viewport is read through the + // scrollback API, whose offset also counts from the oldest row + const viewportRow = y - wasmTerm.getScrollbackLength(); + const uriAt = (col: number): string | null => + viewportRow < 0 + ? wasmTerm.getScrollbackHyperlinkUri(y, col) + : wasmTerm.getHyperlinkUri(viewportRow, col); + + const uri = uriAt(x); + if (!uri) return null; + + // The run ends before a cell without a hyperlink or with a different URI + let endX = x; + for (let col = x + 1; col < line.length; col++) { + const nextCell = line.getCell(col); + if (!nextCell || nextCell.getHyperlinkId() === 0 || uriAt(col) !== uri) break; + endX = col; } - callback(links.length > 0 ? links : undefined); + const range: IBufferRange = { + start: { x, y }, + end: { x: endX, y }, + }; + return { + text: uri, + range, + // Opens with Ctrl/Cmd held (ADR 061: one rule for both providers) + activate: (event) => openLinkOnModifier(uri, event, this.open), + }; } dispose(): void { @@ -108,6 +81,9 @@ export class OSC8LinkProvider implements ILinkProvider { } } +/** One buffer row as the provider reads it. */ +type OSC8Line = NonNullable<ReturnType<ITerminalForOSC8Provider['buffer']['active']['getLine']>>; + /** * Minimal terminal interface required by OSC8LinkProvider */ From ee7385efd13fc82da36b0ead91e63038d008caf0 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:42:07 -0400 Subject: [PATCH 276/295] docs(arch): headers name the second bell-clear path and non-text-paste's kind; the root order states the DPR watch move --- docs/specs/architecture.md | 4 ++-- .../features/notifications/notifications-feature.ts | 1 + src/client/features/paste/non-text-paste.ts | 12 ++++++++---- src/client/features/tab-title/tab-title-feature.ts | 4 +++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 5a0bbd1..e24e962 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -248,10 +248,10 @@ Other patterns used, each in one named place: **Command map** for shortcut dispa ### The client composition root -`startClient(env)` runs these steps, in this order, written once in the file with numbered comments. The order is today's `index.ts` (at commit d333069) with two moves, each stated with why it is equivalent; it is pinned by a test that installs everything on fakes and records each listener's target, type, phase and position and each terminal emitter subscription. +`startClient(env)` runs these steps, in this order, written once in the file with numbered comments. The order is today's `index.ts` (at commit d333069) with three moves, each stated with why it is equivalent; it is pinned by a test that installs everything on fakes and records each listener's target, type, phase and position and each terminal emitter subscription. 1. **Bootstrap.** The session id from `location`; the config (`/api/config`); `ReplayWindow`; the `TabTitle` object (it sets `document.title`, so it stays before the wasm load); `Ghostty.load('/ghostty-vt-small.wasm')`; the terminal theme; `new Terminal(toTerminalOptions(…))`; the padding colour; `await term.open(container)`. -2. **Layout and views.** `EdgeExtender`; `Composer` (container `compositionstart`, `compositionupdate`, `compositionend`, `focusout`); `WheelStream` and `SmoothScroll`; the FitAddon and the `Fitter`, whose first `schedule()` runs here; the `ResizeObserver`; window `resize`; `fonts.ready`; repaint on visible (document `visibilitychange`); the DPR watch. +2. **Layout and views.** `EdgeExtender`; `Composer` (container `compositionstart`, `compositionupdate`, `compositionend`, `focusout`); `WheelStream` and `SmoothScroll`; the FitAddon and the `Fitter`, whose first `schedule()` runs here; the `ResizeObserver`; window `resize`; `fonts.ready`; the DPR watch (all in `installLayout`); repaint on visible (document `visibilitychange`). *The DPR watch moved up* past repaint on visible so the layout feature owns it. Equivalent: it listens on a `matchMedia` query, not on the document, so no listener on a shared target changes position. 3. **Kitty graphics.** `KittyGraphics` (its overlay into the container, a document `visibilitychange`); its replies go out through the connection handle, which is created at step 7 and read only when a reply is sent. 4. **Clipboard.** `onClipboardWrite`; window capture `keydown`, `mousedown`, `paste`, `drop` arming the writer. 5. **Tab title.** `onTitleChange`, `onBell`; container `focusin`; window capture `keydown`. diff --git a/src/client/features/notifications/notifications-feature.ts b/src/client/features/notifications/notifications-feature.ts index a8a68a9..e5a8ccd 100644 --- a/src/client/features/notifications/notifications-feature.ts +++ b/src/client/features/notifications/notifications-feature.ts @@ -74,6 +74,7 @@ export function installNotifications( store.add( listen(doc, 'visibilitychange', () => { if (!focused()) return; + // Clears the tab title's bell mark too: the tab title feature has no listener here. title.attended(); notifier.attended(); }), diff --git a/src/client/features/paste/non-text-paste.ts b/src/client/features/paste/non-text-paste.ts index 6fdbf73..34b1ee7 100644 --- a/src/client/features/paste/non-text-paste.ts +++ b/src/client/features/paste/non-text-paste.ts @@ -1,7 +1,11 @@ -// An empty text/plain payload is a non-text paste (e.g. an image), whose -// receiving TUI may read the native clipboard on Ctrl+V: the paste is Ctrl+V. -// Never substitute Ctrl+V for nonempty text: in remote apps it can mean -// something else entirely. Text is encoded by the terminal (term.paste, ADR 049). +// Whether a paste is non-text, and the Ctrl+V it becomes (util, pure, ADR 037, ADR 049). + +/** + * Ctrl+V for an empty text/plain payload, a non-text paste (e.g. an image) whose receiving + * TUI may read the native clipboard on Ctrl+V; null for text. Never substitute Ctrl+V for + * nonempty text: in remote apps it can mean something else entirely. Text is encoded by + * the terminal (term.paste, ADR 049). + */ export function nonTextPaste(text: string): string | null { return text ? null : '\x16'; } diff --git a/src/client/features/tab-title/tab-title-feature.ts b/src/client/features/tab-title/tab-title-feature.ts index b5c10e4..d3af250 100644 --- a/src/client/features/tab-title/tab-title-feature.ts +++ b/src/client/features/tab-title/tab-title-feature.ts @@ -1,7 +1,9 @@ // Wires the tab title to the terminal (feature, ADR 053, ADR 060): the program's OSC 0/2 // title and its bells go to the TabTitle, and focus in the terminal or a key clears the // bell mark. Ghostty clears it on focus; a window focus reaches the container as a -// `focusin` too, while Chrome switching back to the tab only shows it. +// `focusin` too, while Chrome switching back to the tab only shows it. Returning to a tab +// whose terminal is focused clears it too, from the notifications feature's +// `visibilitychange` listener (it attends the title and the notifier, in that order). import type { ClientContext, Feature } from '../../app/client-context'; import { DisposableStore, listen } from '../../vendor/ghostty-web'; From d1f07bb20dfa9c0de2a23572c9a7d5eddc704810 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:42:07 -0400 Subject: [PATCH 277/295] docs(arch): client SPEC points paste requests and the replay feed at the modules that own them --- docs/specs/client.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/client.md b/docs/specs/client.md index f8ef78b..5ab64d1 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -184,7 +184,7 @@ Both can be active simultaneously. The canvas-based terminal has no DOM text sel | App tracks the mouse | only the app gets press, motion and release; no highlight, no copy | only the app gets it; browser menu cancelled | | App tracks the mouse, Shift held | webtty selects and copies; the app gets nothing for that button until it is released | `rightClickBehavior`; the app gets nothing | -The terminal has the one `contextmenu` listener (`handleContextMenu`). Keys, text and paste clear the selection; mouse reports do not, so a Shift selection survives the app's hover and clicks until Shift+right-click or the menu's Copy uses it. A menu not preceded by a right press (or macOS Ctrl+click) since the last key is a keyboard menu and follows `rightClickBehavior` even while the app tracks the mouse. While the terminal has focus, the Menu key and Shift+F10 are sent to the app as keys, so the browser opens no keyboard menu there. `"paste"` fires `Terminal.onPasteRequest`, which `index.ts` answers with the same clipboard read as Ctrl+V. +The terminal has the one `contextmenu` listener (`handleContextMenu`). Keys, text and paste clear the selection; mouse reports do not, so a Shift selection survives the app's hover and clicks until Shift+right-click or the menu's Copy uses it. A menu not preceded by a right press (or macOS Ctrl+click) since the last key is a keyboard menu and follows `rightClickBehavior` even while the app tracks the mouse. While the terminal has focus, the Menu key and Shift+F10 are sent to the app as keys, so the browser opens no keyboard menu there. `"paste"` fires `Terminal.onPasteRequest`, which the paste feature (`features/paste/paste-feature.ts`) answers with `PasteService.pasteFromClipboard`, the same clipboard read as Ctrl+V. What a double or triple click selects, and the text either path copies, is Ghostty's (`ghostty_terminal_select_word`, `ghostty_terminal_select_line`, `ghostty_terminal_selection_format_alloc`): soft-wrapped rows are joined without a newline ([ADR 050](../adrs/050.client.selection-from-libghostty-vt.md)). A triple click with Ctrl or Cmd held selects the output of the command under the pointer instead (`ghostty_terminal_select_output`, from OSC 133 marks; nothing outside output), as in Ghostty ([ADR 057](../adrs/057.client.shell-integration-prompt-jumps.md)). @@ -313,7 +313,7 @@ Programs hear focus changes, name the tab and ring the bell as in Ghostty ([ADR - **Focus reports (mode 1004).** The terminal has focus while keyboard focus is inside the container and the window has focus; `Terminal` follows it with `focusin` / `focusout` on the container (a move inside it, the container passing focus to the textarea, is not a change). While the program has mode 1004 on, each change sends `CSI I` or `CSI O`, encoded by `ghostty_focus_encode`, through `onData`; a write that turns the mode on sends the current state, as Ghostty does when the mode is set, so the replay of a program that has it on tells the program this tab's focus. Each socket that opens with the mode still on (a reconnect without a reset) sends the current state too (`resyncFocus`), since reports while it was down were dropped. Nothing is sent with `disableStdin`. - **Title.** `TabTitle` (`features/tab-title/tab-title.ts`) sets `document.title` to `<title> | webtty` from `Terminal.onTitleChange` (OSC 0 / OSC 2, read from Ghostty after each write), or `<session> | webtty` while the title is empty. Titles in the replay are applied. -- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. `ReplayWindow` (`connection/replay-window.ts`) keeps that state, fed by `index.ts` around each message, for the bell and for desktop notifications. +- **Bell.** With `bellTitle` on (default), a BEL puts `🔔 ` in front of the tab title, focused or not, until the terminal gains focus (`focusin` on the container, which a window focus fires too) or a key is pressed, as Ghostty's `bell-features = title`. No sound, flash or notification. Bells in the replay are ignored: from each connect, output is replay through the first WebSocket message shorter than the server's 64 Ki-character replay piece (`REPLAY_PIECE`, the replay's last piece), or until a message arrives 1 s (`REPLAY_QUIET_MS`) after the previous one was parsed; the first message is always replay. `ReplayWindow` (`connection/replay-window.ts`) keeps that state, fed around each message by the per-message pipeline in `connection/output-pipeline.ts` (wired in `app/connect-pty.ts`), for the bell and for desktop notifications. ## Desktop Notifications From 2c666634228393ebaf58f531801527331474531e Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 21:48:32 -0400 Subject: [PATCH 278/295] docs(arch): ADRs 059-065 record what was built; the SPEC tree matches the code - ADRs 059-065 gain an "As built" section: commits, file sizes before and after, coverage per layer, and where the work went beyond or differed from each Decision (three root moves, not two; features/paste and screen-clear; the shim lifetimes; the one commit that does not build). - The architecture SPEC tree lists recording-context.ts and the vendor tests' lifecycle/ folder, and names where the whole-server and subprocess tests sit. - PROVENANCE records the in-place OSC8LinkProvider.provideLinks restructure. - AGENTS.md's repo structure names src/ and scripts/ and points at the SPEC. --- AGENTS.md | 4 +++- docs/adrs/059.architecture.code-organisation.md | 10 ++++++++++ docs/adrs/060.client.feature-modules.md | 10 ++++++++++ docs/adrs/061.client.vendor-split.md | 17 +++++++++++++++++ docs/adrs/062.server.composition.md | 8 ++++++++ docs/adrs/063.cli.command-modules.md | 7 +++++++ docs/adrs/064.architecture.test-strategy.md | 10 ++++++++++ docs/adrs/065.client.listener-teardown.md | 8 ++++++++ docs/specs/architecture.md | 9 +++++---- src/client/vendor/ghostty-web/PROVENANCE.md | 1 + 10 files changed, 79 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 453ee8c..a13fcbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,10 @@ Follow [docs/development.md](docs/development.md) — Spec & ADR Driven Developm ``` webtty/ ├── docs/ -│ ├── specs/ # living architecture specs +│ ├── specs/ # living specs; specs/architecture.md maps the code │ └── adrs/ # architecture decision records +├── src/ # cli/, server/, pty/, config/, client/ (tree in specs/architecture.md) +├── scripts/ # build, architecture and coverage checks, ghostty-vt updater └── README.md ``` diff --git a/docs/adrs/059.architecture.code-organisation.md b/docs/adrs/059.architecture.code-organisation.md index b84c66d..64af50c 100644 --- a/docs/adrs/059.architecture.code-organisation.md +++ b/docs/adrs/059.architecture.code-organisation.md @@ -66,3 +66,13 @@ App, server, CLI, config, pty and script files aim at 200 lines and stop at 300. - The client wiring, the server routes and the WebSocket layer become importable and testable in-process, so coverage measures them. - The checker keeps the structure from eroding: an oversized file or a wrong import fails `bun test`. - The refactor is large (about 12,000 lines move). Doing it in steps that each keep the suite green, with moves separate from logic changes, keeps any regression small and bisectable. `git blame` on moved lines goes through `git log --follow`. + +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +- The refactor took 145 commits from `d333069` on `feat/client-libghostty-vt`; the server and CLI track ran on `refactor/server-architecture`, started from the commit that landed the checker (`6bd0b99`), and merged back in `5905551`. +- The tree is the one in the SPEC: 263 source files and 216 test files under `src/` and `scripts/`. The largest source files are the vendored facades and engine parts (`terminal.ts` 593 lines, `engine/render-state.ts` 492, `renderer.ts` 489, `engine/official-terminal.ts` 450); the largest app, server and CLI files are `features/kitty/graphics/kitty-graphics.ts` (298), `server/output-pacer.ts` (278) and `cli/api-client.ts` (215). +- The checker does more than this Decision lists: besides sizes and imports it checks naming (banned names, `test-support/` files never `*.test.ts`), runtime import cycles, and that a split vendored part never imports a facade. Its parts are in `scripts/architecture/`, each with its test. +- The ratchet allowlist has been empty and strict since `f4fb18f`: a new over-limit file or rule break fails `bun test`. +- One deviation from "each commit passes": `a1e6c23` on the server track does not build (ADR 062, History note). Every other commit passes `bun test`, `bun run lint` and `bun run build`. diff --git a/docs/adrs/060.client.feature-modules.md b/docs/adrs/060.client.feature-modules.md index b43115c..9eb60d5 100644 --- a/docs/adrs/060.client.feature-modules.md +++ b/docs/adrs/060.client.feature-modules.md @@ -75,3 +75,13 @@ Shortcut actions go through a **command map** (`shortcut-commands.ts`): action - Each feature can be read, tested and removed alone; a new feature is a folder and one line in the root. - The order that matters is in one file and pinned by tests. - About 2,500 lines move; the moved modules' tests move with them, and `index.ts` imports are updated in each step. + +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +- `src/client/index.ts` is 7 lines. `app/start-client.ts` (198 lines) keeps the thirteen numbered steps in one file; to keep `startClient` under the 60-line function budget, step 1 is `bootstrap`, step 2 is `installSurface` and steps 9 to 13 are `installInput`, private functions of the same file in the same order (`c452240`). Step 7's socket host is `app/connect-pty.ts`. +- The SPEC's root order has **three** moves, not the two this Decision names: the DPR watch moved into the layout feature, ahead of repaint on visible. It listens on a `matchMedia` query, so no listener on a shared target changes position. +- More feature folders than the splits listed above: `features/paste/` (the right-click paste and non-text paste, out of the root) and, inside `features/keyboard/`, `font-zoom-feature.ts` and `screen-clear.ts` (Cmd+K, moved out of the root in `cfd34e0` and handed to the dispatcher as the `clearScreen` handle). The kitty split also gained `kitty-error.ts`, `kitty-terminal.ts` and `placement-geometry.ts`. +- Every installer is checked against one `FeatureInstaller` type at compile time (`a738e9a`), and the architecture checker forbids `app/`, `connection/` and `layout/` importing a feature (`359d80f`). +- The install order, per-message pipeline, on-open sequence and key dispatcher are pinned by `start-client.test.ts`, `output-pipeline.test.ts`, `pty-connection.test.ts` and `key-dispatcher.test.ts`. App-layer coverage is 100% of lines and 98.2% of functions. diff --git a/docs/adrs/061.client.vendor-split.md b/docs/adrs/061.client.vendor-split.md index d1c2344..f48c4cf 100644 --- a/docs/adrs/061.client.vendor-split.md +++ b/docs/adrs/061.client.vendor-split.md @@ -78,3 +78,20 @@ The vendored tree stays excluded from Biome (one rule; moved Coder code is not r - Controllers with injected rAF, timers and DOM can be tested without a browser; `terminal.ts` coverage (53% of lines today) can rise to the layer's target. - The licence boundary stays readable file by file, and the Moves table keeps the upstream diff traceable. - The split is the riskiest part of the refactor, so it comes after the app-layer work and is done one facade at a time, each step leaving the replay, raster and terminal tests green. + +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +| Facade | Before | After | Limit | +|---|---|---|---| +| `ghostty.ts` | 3,061 | 118 | 300 | +| `terminal.ts` | 2,147 | 593 | 600 | +| `renderer.ts` | 1,508 | 489 | 500 | +| `input-handler.ts` | 856 | 199 | 300 | +| `selection-manager.ts` | 736 | 243 | 300 | + +- `engine/render-state.ts` is 492 lines, well under its 700-line allowance. The largest part is `engine/official-terminal.ts` (450). +- The listener snapshot stayed unchanged across every split step; only the ADR 065 commit (`6576d01`) changed it. +- Additions beyond this Decision: `core/terminal-controllers.ts` creates and wires the controllers in one place; `core/opened-parts.ts` holds what `open()` creates in one `OpenedParts`, typed by narrow part interfaces, and the checker fails a part that imports a facade (`c452e52`); `lifecycle/disposable-store.ts` (ADR 065) is webtty-original. Upstream's `types.ts` was split into `input/key-types.ts`, `engine/cell-types.ts` and `link-types.ts`, kept as a re-export shim during the split, then removed (`937ce88`). The controllers' host builders are exported so a test pins where each member forwards. `OSC8LinkProvider.provideLinks` was restructured in place, out of the 60-line function budget (`4f9f20d`). Each is recorded in `PROVENANCE.md`. +- The vendor tests are mirrored in `vendor/tests/` (`engine/`, `core/`, `render/`, `input/`, `selection/`, `lifecycle/`, `links/`, `addons/`), with shared fakes and harnesses in `vendor/tests/support/`. Vendor coverage is 100% of lines and 98.95% of functions, `terminal.ts` included (53% of lines before). diff --git a/docs/adrs/062.server.composition.md b/docs/adrs/062.server.composition.md index bc513b6..cc2fae4 100644 --- a/docs/adrs/062.server.composition.md +++ b/docs/adrs/062.server.composition.md @@ -53,6 +53,14 @@ - A new route is a handler and a line in the table; a new config key is the schema, the default, its parse rule and (if the client needs it) the key list, all in `src/config/`. - Golden tests written before the move make any change in a response visible. +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +- `server/app.ts` is 88 lines and `server/index.ts` 50. The largest server file is `output-pacer.ts` (278), moved unchanged. +- `flow.ts` was renamed in `6689ece` and kept as a one-line re-export until the merge; `772fc70` repointed the client's `replay-window.test.ts` at `output-pacer.ts` and deleted the shim. +- The whole-server behaviour tests sit at `src/server/` beside `app.ts`: `routes.test.ts` and `websocket.test.ts` (in-process through `createServer`), the three `routes-*golden.test.ts` files, and `entry.test.ts`, the subprocess check of the entry. Server coverage is 100% of lines and functions. + ## History note Commit `a1e6c23` ("test(server): a fake PTY and a fake socket in test-support/") also deletes `src/server/websocket.ts` by a staging mistake, while `index.ts` and `routes.ts` still import it, so that one commit does not build or pass tests: `git bisect skip` it. The removal belongs to the next commit, `1c67320` ("refactor(server): split websocket.ts into sessions/session.ts and ws/*"), which repoints those imports and is green. The history is not rewritten; later steps stage explicit paths and check `git show --stat` before committing. diff --git a/docs/adrs/063.cli.command-modules.md b/docs/adrs/063.cli.command-modules.md index fb556e3..e7a6370 100644 --- a/docs/adrs/063.cli.command-modules.md +++ b/docs/adrs/063.cli.command-modules.md @@ -27,3 +27,10 @@ - Each command is a short file testable in-process, and coverage counts the CLI. - The CLI's HTTP calls are in one client, so a route change on the server has one place to follow on the CLI side. + +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +- The CLI is as decided: `main.ts` (110 lines), `cli-deps.ts`, `api-client.ts` (215 lines, the largest CLI file), `server-process.ts`, `browser-opener.ts` and the nine files under `commands/`. `commands.test.ts` and `http.test.ts` are gone; each module has its own test on injected deps (`cli/test-support/`). +- `cli/index.test.ts` is the one subprocess check of the entry. CLI coverage is 100% of lines and functions. diff --git a/docs/adrs/064.architecture.test-strategy.md b/docs/adrs/064.architecture.test-strategy.md index c9e0ddb..3fa9128 100644 --- a/docs/adrs/064.architecture.test-strategy.md +++ b/docs/adrs/064.architecture.test-strategy.md @@ -35,3 +35,13 @@ - Coverage measures the whole code base, and the targets are concrete. - DOM views and features get real tests without changing how the existing suite runs. - The suite stays fast: one wasm compile per process for the vendor tests, in-process servers instead of subprocesses. + +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +- `bun run test:coverage`: 2,899 tests in 216 files pass. Overall 99.89% of lines and 98.57% of functions (test support included). Per layer, as `scripts/check-coverage.ts` reports them: vendored 100% lines and 98.95% functions; client app 100% and 98.20%; server, CLI, config, pty and the shared `src/*.ts` files 100% and 100%. The 41 modules in `PURE_MODULES` are at 100% of lines. Bun counts executable lines, so these line numbers are higher than a statement count would give. +- The check also fails any `src/` source file that no test loads (`f79a2b8`), except the type-only modules and thin entries in `NOT_LOADED`. +- Subprocess tests that remain, as end-to-end checks of the entries: `server/entry.test.ts`, `cli/index.test.ts` and `version.test.ts` (the `--version` of both entries). The route tests run real shells for the execute routes, in-process. +- No test reaches a private member through an `as unknown as {…}` cast any more. +- `happy-dom` `20.14.5` is the one devDependency added (`f2c4939`). diff --git a/docs/adrs/065.client.listener-teardown.md b/docs/adrs/065.client.listener-teardown.md index da7979c..74106eb 100644 --- a/docs/adrs/065.client.listener-teardown.md +++ b/docs/adrs/065.client.listener-teardown.md @@ -29,3 +29,11 @@ This is the refactor's one deliberate behaviour change, so it is its own commit - A disposed terminal leaves no listener behind, so a future multi-terminal page or a test can create and dispose terminals freely. - Teardown is uniform across the app and vendored layers and tested the same way. + +## As built + +Recorded when the documentation was reconciled with the code (2026-10-14). + +- Done in `6576d01` (`fix(client): release every listener on dispose (ADR 065)`), its own commit as decided. Every row of `vendor/tests/core/listener-snapshot.test.ts` now reads `-> removed`; `81756ed` restored the `-> LEAKS` label so the snapshot can still show a regression. +- `vendor/tests/core/dispose.test.ts` checks that `dispose()` also clears every timeout, interval and animation frame, including after a failed `open()`. +- The second, deferred `element.focus()` of `Terminal.focus()` moved into `core/focus-tracker.ts` in the same commit, so its timeout is owned and released (recorded in `PROVENANCE.md`). diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index e24e962..cc43132 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -1,6 +1,6 @@ # SPEC: Architecture -**Last Updated:** 2026-10-14 (the refactor is done: the server branch merged, the ratchet allowlist empty, the coverage targets enforced) +**Last Updated:** 2026-10-14 (the tree reconciled with the code after the refactor: the ratchet allowlist empty, the coverage targets enforced) --- @@ -55,7 +55,7 @@ src/client: ## Folder tree -The target tree. Files marked *(entry)* keep their path because `scripts/build.ts`, `package.json` and the tests name them; `dist/` names (`client-browser.js`, `client.css`, `client.html`, `server/index.js`, `cli/index.js`, `ghostty-vt-small.wasm`, `THIRD_PARTY_LICENSES`) never change. +The tree as it is. Files marked *(entry)* keep their path because `scripts/build.ts`, `package.json` and the tests name them; `dist/` names (`client-browser.js`, `client.css`, `client.html`, `server/index.js`, `cli/index.js`, `ghostty-vt-small.wasm`, `THIRD_PARTY_LICENSES`) never change. ``` src/ @@ -162,11 +162,12 @@ src/ test-support.test.ts the fakes' self-tests (a test-support/ folder holds no *.test.ts) vendor/ ghostty-vt/ untouched: official wasm, sidecar, signature, LICENSE, PROVENANCE.md - tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, links/, addons/) + tests/ vendor tests, Biome-linted, mirroring ghostty-web/ (engine/, core/, render/, input/, selection/, lifecycle/, links/, addons/) support/ wasm.ts (the one WASM path, a cached loadGhostty(), countedGhostty()), fake-canvas.ts, fake-event-target.ts, engine-harness.ts (engine parts without the facade), terminal-text.ts (row text, cell colours), selection-fixture.ts (a SelectionManager on a recording canvas and document), raster-canvas.ts and renderer-harness.ts (renderers on canvases that record or rasterise), + recording-context.ts (a 2D context that logs the painters' calls, shared by the painter tests), terminal-harness.ts (a whole Terminal opened on the fakes, driven through its public API) fixtures/ PTY captures (was vendor/fixtures/) ghostty-web/ Biome-excluded; see "The vendored layer" @@ -324,7 +325,7 @@ The checker carries a **ratchet allowlist** (`scripts/architecture/ratchet-allow - **Views and features:** `happy-dom` (pinned devDependency), used as an explicit `new Window()` whose `document` is injected; never registered globally, so tests that stub globals are not disturbed (ADR 064). - **Teardown:** every `dispose()` has a test that it removes each listener, timer and frame it added, and that a second `dispose()` does nothing. - **Order:** the client root has an install-order test; the per-message pipeline and the on-open sequence have call-order tests; the key dispatcher has a case-by-case equivalence test against the old two-listener behaviour. -- **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. A few subprocess tests remain as end-to-end checks of the entries. No test may fall back to the configured port. +- **Server:** routes, WebSocket channels and sessions run in-process through `createServer()` on port 0 with a temporary `HOME`, so they count in coverage. These whole-server tests sit at `src/server/` beside `app.ts` (`routes.test.ts`, `websocket.test.ts`, the `routes-*golden.test.ts` files). A few subprocess tests remain as end-to-end checks of the entries (`server/entry.test.ts`, `cli/index.test.ts`, `version.test.ts`). No test may fall back to the configured port. - **Vendor:** one cached `loadGhostty()` (`vendor/tests/support/wasm.ts`) so new test files do not each compile the wasm. New tests do not reach private members through `as unknown as {…}` casts; they test the collaborator that now owns the state. - **Listener snapshots.** Before the vendored facades are split, a test (`vendor/tests/core/listener-snapshot.test.ts`) opens a `Terminal` on recording targets and snapshots every listener it adds (target, type, capture, order) and which of them `dispose()` removes. Every vendor split step leaves that snapshot unchanged; only the ADR 065 commit changes its "removed" column (and drops the empty `compositionupdate` listener). Until then a `DisposableStore` holds only the listeners that are removed today; the ones that leak stay as they are. Since that commit every row is removed, and `vendor/tests/core/dispose.test.ts` checks that `dispose()` also clears every timeout, interval and animation frame, including after a failed `open()`. - **Coverage targets** (`bun run test:coverage`): app, server, CLI, config, pty and the shared `src/*.ts` files ≥ 95% lines and ≥ 90% functions; vendored layer ≥ 85% lines and ≥ 80% functions; the pure modules listed in `PURE_MODULES` at 100% lines, each loaded by a test. Bun's `coverageThreshold` is one global setting, so `bunfig.toml` sets the common floor (85% lines, 80% functions) and `scripts/check-coverage.ts`, run by `test:coverage` after `bun test --coverage --coverage-reporter=lcov --coverage-reporter=text`, enforces the per-layer targets from `coverage/lcov.info`. Test code (`*.test.ts`, `test-support/`, `vendor/tests/`) and `scripts/` are not counted toward a layer. Bun writes an lcov record only for a file some test loads, so the check also fails on any `src/` source file with no record, except the type-only modules and thin entries listed in `NOT_LOADED`. A new pure module joins the list with its table-driven test. diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index dd2004b..746ae09 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -63,6 +63,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`: the scrollbar's fade reads an injected clock (`Date.now()` and the browser's frames and timeouts by default, as before), and the fade in and fade out are one `fade('in' | 'out')` with the same opacity formulas (ADR 061). - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). - `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). +- `providers/osc8-link-provider.ts`: `provideLinks` scans the row and a private `linkAt` reads one link's run; the scan skips past each run instead of keeping a set of visited columns, which visits the same cells in the same order and returns the same links (ADR 061). - `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). ## webtty-original files From 77d1aa06dd24600fa2260d6cf70d1200dc5cfd1d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 22:12:30 -0400 Subject: [PATCH 279/295] test(client): the root's edge strips copy the renderer's visible area --- src/client/app/start-client.test.ts | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/client/app/start-client.test.ts b/src/client/app/start-client.test.ts index 86becc1..da79339 100644 --- a/src/client/app/start-client.test.ts +++ b/src/client/app/start-client.test.ts @@ -235,6 +235,68 @@ describe('startClient', () => { expect(fake.term.options.fontSize).toBe(15); }); + test("the edge strips copy the part of the renderer's canvas in view", async () => { + fake = fakeClientEnv(); + const { document, HTMLCanvasElement } = fake.dom.window; + // happy-dom has no 2D context: this document's canvases get one that logs which + // canvas the strips copy. + const copied: unknown[] = []; + const create = document.createElement.bind(document); + document.createElement = ((tag: string) => { + const el = create(tag); + if (el instanceof HTMLCanvasElement) { + el.getContext = (() => ({ + drawImage: (src: unknown) => copied.push(src), + })) as unknown as HTMLCanvasElement['getContext']; + } + return el; + }) as Document['createElement']; + client = await startClient(fake.env); + // An 800x400 terminal canvas, first in a 809x407 box as open() puts it, so both strips + // show after a fit. + const canvas = create('canvas'); + canvas.width = 800; + canvas.height = 400; + for (const [el, sizes] of [ + [canvas, { offsetLeft: 0, offsetTop: 0, offsetWidth: 800, offsetHeight: 400 }], + [fake.container, { clientWidth: 809, clientHeight: 407 }], + ] as const) { + for (const [name, value] of Object.entries(sizes)) { + Object.defineProperty(el, name, { value, configurable: true }); + } + } + fake.container.prepend(canvas); + const view = create('canvas'); + view.width = 800; + view.height = 400; + const renderer = fake.term.attachRenderer(); + // The fit sizes the grid from the container and the renderer's cell size. + Object.assign(fake.term, { element: fake.container }); + Object.assign(renderer, { + getMetrics: () => ({ width: 10, height: 20, baseline: 15 }), + setScrollOffset: () => {}, + visibleArea: { canvas: view, y: 0, height: 400, lastLine: 399 }, + }); + // Font zoom fits at once; the strips then paint on the next frame. + // FitAddon reads the padding through the global window's getComputedStyle, in the + // zoom's fit and in the refit its grid resize schedules for the next frame. + const g = globalThis as { window?: unknown }; + const hadWindow = 'window' in g; + const saved = g.window; + g.window = fake.dom.window; + try { + const { KeyboardEvent } = fake.dom.window; + fake.win.dispatch(new KeyboardEvent('keydown', { key: '=', code: 'Equal', ctrlKey: true })); + fake.clock.frame(); + } finally { + if (hadWindow) g.window = saved; + else delete g.window; + } + // The bottom and right strips each copy from the renderer's canvas in view. + expect(copied.length).toBe(2); + expect(copied.every((src) => src === view)).toBe(true); + }); + test('dispose removes every listener, view and hook, and closes the socket; twice is a no-op', async () => { await start(); const socket = fake.sockets.last(); From dd5fd68235fdb73cbb4efec6b3ffb4f799777057 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 22:12:31 -0400 Subject: [PATCH 280/295] fix(scripts): inspect-wasm refuses a wasm that needs imports before instantiating it --- scripts/ghostty-vt/inspect-wasm.test.ts | 3 ++- scripts/ghostty-vt/inspect-wasm.ts | 7 +++++-- scripts/ghostty-vt/test-support/tiny-wasm.ts | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/ghostty-vt/inspect-wasm.test.ts b/scripts/ghostty-vt/inspect-wasm.test.ts index f2b9e79..27d6876 100644 --- a/scripts/ghostty-vt/inspect-wasm.test.ts +++ b/scripts/ghostty-vt/inspect-wasm.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { VENDOR_DIR } from '../update-ghostty-vt'; import { inspectWasm } from './inspect-wasm'; import type { Sidecar } from './sidecar'; -import { EMPTY_WASM, tinyWasm } from './test-support/tiny-wasm'; +import { EMPTY_WASM, IMPORTING_WASM, tinyWasm } from './test-support/tiny-wasm'; describe('inspectWasm', () => { test('reads the vendored wasm as its sidecar records it, offline', async () => { @@ -34,6 +34,7 @@ describe('inspectWasm', () => { test.each([ ['bytes that are not wasm', new Uint8Array([1, 2, 3]), 'wasm: WebAssembly.validate failed'], + ['a module that needs imports', IMPORTING_WASM, 'wasm: unexpected imports env.f:function'], ['a module without the exports', EMPTY_WASM, 'wasm: missing exports memory, __indirect'], ['a failing ghostty_build_info', tinyWasm(1), 'wasm: ghostty_build_info(4) returned 1'], ])('rejects %s', async (_name, bytes, message) => { diff --git a/scripts/ghostty-vt/inspect-wasm.ts b/scripts/ghostty-vt/inspect-wasm.ts index 816ac83..59d8110 100644 --- a/scripts/ghostty-vt/inspect-wasm.ts +++ b/scripts/ghostty-vt/inspect-wasm.ts @@ -80,11 +80,14 @@ function readBuildInfo(ex: BuildInfoExports): WasmBuildInfo { /** * Instantiates the wasm, checks the exports the binding needs and reads `ghostty_build_info`. * - * @throws If the module does not validate or lacks a required export. + * @throws If the module does not validate, needs imports or lacks a required export. */ export async function inspectWasm(bytes: Uint8Array<ArrayBuffer>): Promise<WasmInfo> { if (!WebAssembly.validate(bytes)) throw new Error('wasm: WebAssembly.validate failed'); const mod = new WebAssembly.Module(bytes); + // The binding instantiates the wasm with no imports, so a module that needs any is refused. + const imports = WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}:${i.kind}`); + if (imports.length) throw new Error(`wasm: unexpected imports ${imports.join(', ')}`); const exports = WebAssembly.Module.exports(mod); const names = new Set(exports.map((e) => e.name)); const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n)); @@ -93,7 +96,7 @@ export async function inspectWasm(bytes: Uint8Array<ArrayBuffer>): Promise<WasmI const ex = instance.exports as BuildInfoExports; const buildInfo = readBuildInfo(ex); return { - imports: WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}:${i.kind}`), + imports, exportCount: exports.length, functionExports: exports.filter((e) => e.kind === 'function').length, nonFunctionExports: exports diff --git a/scripts/ghostty-vt/test-support/tiny-wasm.ts b/scripts/ghostty-vt/test-support/tiny-wasm.ts index ba929d8..cf9efda 100644 --- a/scripts/ghostty-vt/test-support/tiny-wasm.ts +++ b/scripts/ghostty-vt/test-support/tiny-wasm.ts @@ -44,3 +44,10 @@ export function tinyWasm(result: number): Uint8Array<ArrayBuffer> { /** The smallest valid module: no exports at all. */ export const EMPTY_WASM = new Uint8Array([...MAGIC_AND_VERSION]); + +/** A module with one imported function, `env.f`, and no exports. */ +export const IMPORTING_WASM = new Uint8Array([ + ...MAGIC_AND_VERSION, + ...section(1, [1, 0x60, 0, 0]), + ...section(2, [1, 3, ...Buffer.from('env'), 1, ...Buffer.from('f'), FUNC, 0]), +]); From 58ea652db0fb198aba2e549d3d8898b380338433 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Wed, 14 Oct 2026 22:12:31 -0400 Subject: [PATCH 281/295] docs(adr): 064 records Bun's uncovered class field initializers and the last two real gaps --- docs/adrs/064.architecture.test-strategy.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adrs/064.architecture.test-strategy.md b/docs/adrs/064.architecture.test-strategy.md index 3fa9128..3d0b1ce 100644 --- a/docs/adrs/064.architecture.test-strategy.md +++ b/docs/adrs/064.architecture.test-strategy.md @@ -41,6 +41,7 @@ Recorded when the documentation was reconciled with the code (2026-10-14). - `bun run test:coverage`: 2,899 tests in 216 files pass. Overall 99.89% of lines and 98.57% of functions (test support included). Per layer, as `scripts/check-coverage.ts` reports them: vendored 100% lines and 98.95% functions; client app 100% and 98.20%; server, CLI, config, pty and the shared `src/*.ts` files 100% and 100%. The 41 modules in `PURE_MODULES` are at 100% of lines. Bun counts executable lines, so these line numbers are higher than a statement count would give. +- The functions still reported as uncovered are not real gaps. Bun (1.3.11) counts a class's field initializers (`private x = 0`) as one more function and reports it as never called, even when every initializer line runs: a class with one field and one method, both run, shows 66.67% of functions. Every product file below 100% of functions is such a class (vendored `terminal.ts`, `glide.ts`, `disposable-store.ts`, …). Moving the initializers into constructors would make the report read 100% without testing anything more, and would widen the vendored diff from upstream, so the classes stay as they are. The last two real gaps were covered afterwards: the edge strips' visible-area callback in `start-client.ts`, and `inspect-wasm.ts`, which now refuses a wasm that needs imports before instantiating it (its `imports` map ran only for a module that could never instantiate). - The check also fails any `src/` source file that no test loads (`f79a2b8`), except the type-only modules and thin entries in `NOT_LOADED`. - Subprocess tests that remain, as end-to-end checks of the entries: `server/entry.test.ts`, `cli/index.test.ts` and `version.test.ts` (the `--version` of both entries). The route tests run real shells for the execute routes, in-process. - No test reaches a private member through an `as unknown as {…}` cast any more. From 97ce48df5af3933e4961d64a62fc6f317604e2c1 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 07:59:27 -0400 Subject: [PATCH 282/295] feat(scripts): scroll-bench measures blinks, paints and the scrolled view over input profiles and scenarios A reusable harness in scripts/scroll-bench/ that drives an already running Chrome over CDP: per server, scenario, input profile (trackpad, wheel, trackpoint) and run it creates a session, starts the scenario's app, scrolls while recording the screencast, prints one JSON line and a markdown table of medians. Scenarios come from a JSON file of your own; scenarios.example.json and stream.sh show the format and the normal-screen stream test. --- package.json | 1 + scripts/scroll-bench/bench-args.test.ts | 72 ++++++++ scripts/scroll-bench/bench-args.ts | 91 ++++++++++ scripts/scroll-bench/bench.ts | 103 +++++++++++ scripts/scroll-bench/cdp.ts | 146 ++++++++++++++++ scripts/scroll-bench/inputs.test.ts | 92 ++++++++++ scripts/scroll-bench/inputs.ts | 124 ++++++++++++++ scripts/scroll-bench/measure.ts | 117 +++++++++++++ scripts/scroll-bench/metrics.test.ts | 146 ++++++++++++++++ scripts/scroll-bench/metrics.ts | 178 ++++++++++++++++++++ scripts/scroll-bench/page.ts | 109 ++++++++++++ scripts/scroll-bench/scenarios.example.json | 86 ++++++++++ scripts/scroll-bench/scenarios.test.ts | 85 ++++++++++ scripts/scroll-bench/scenarios.ts | 112 ++++++++++++ scripts/scroll-bench/screencast.ts | 44 +++++ scripts/scroll-bench/session-api.test.ts | 16 ++ scripts/scroll-bench/session-api.ts | 47 ++++++ scripts/scroll-bench/stream.sh | 25 +++ scripts/scroll-bench/summary.test.ts | 80 +++++++++ scripts/scroll-bench/summary.ts | 98 +++++++++++ 20 files changed, 1772 insertions(+) create mode 100644 scripts/scroll-bench/bench-args.test.ts create mode 100644 scripts/scroll-bench/bench-args.ts create mode 100644 scripts/scroll-bench/bench.ts create mode 100644 scripts/scroll-bench/cdp.ts create mode 100644 scripts/scroll-bench/inputs.test.ts create mode 100644 scripts/scroll-bench/inputs.ts create mode 100644 scripts/scroll-bench/measure.ts create mode 100644 scripts/scroll-bench/metrics.test.ts create mode 100644 scripts/scroll-bench/metrics.ts create mode 100644 scripts/scroll-bench/page.ts create mode 100644 scripts/scroll-bench/scenarios.example.json create mode 100644 scripts/scroll-bench/scenarios.test.ts create mode 100644 scripts/scroll-bench/scenarios.ts create mode 100644 scripts/scroll-bench/screencast.ts create mode 100644 scripts/scroll-bench/session-api.test.ts create mode 100644 scripts/scroll-bench/session-api.ts create mode 100755 scripts/scroll-bench/stream.sh create mode 100644 scripts/scroll-bench/summary.test.ts create mode 100644 scripts/scroll-bench/summary.ts diff --git a/package.json b/package.json index ae41e9f..f93496a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "server:node": "node dist/server/index.js", "webtty:prod": "bun -- dist/cli/index.js", "webtty": "bun run src/cli/index.ts", + "bench:scroll": "bun scripts/scroll-bench/bench.ts", "prepack": "bun scripts/clean-pkg-scripts.ts strip", "postpack": "bun scripts/clean-pkg-scripts.ts restore" }, diff --git a/scripts/scroll-bench/bench-args.test.ts b/scripts/scroll-bench/bench-args.test.ts new file mode 100644 index 0000000..29cde74 --- /dev/null +++ b/scripts/scroll-bench/bench-args.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { DEFAULT_CDP_PORT, DEFAULT_RUNS, parseBase, parseBenchArgs } from './bench-args'; +import { INPUT_NAMES } from './inputs'; + +describe('parseBase', () => { + test('a labelled URL keeps its label and origin', () => { + expect(parseBase('after=http://localhost:3002/')).toEqual({ + label: 'after', + url: 'http://localhost:3002', + }); + }); + test('a bare URL is labelled by host and port', () => { + expect(parseBase('http://localhost:3001')).toEqual({ + label: 'localhost:3001', + url: 'http://localhost:3001', + }); + }); + test('rejects what is not an http URL', () => { + expect(() => parseBase('localhost')).toThrow('not a URL'); + expect(() => parseBase('a=ws://localhost:1')).toThrow('http or https'); + }); +}); + +describe('parseBenchArgs', () => { + test('defaults the inputs, runs and CDP port', () => { + expect(parseBenchArgs(['--base', 'http://localhost:1', '--scenarios', 's.json'])).toEqual({ + bases: [{ label: 'localhost:1', url: 'http://localhost:1' }], + scenariosFile: 's.json', + inputs: INPUT_NAMES, + runs: DEFAULT_RUNS, + cdpPort: DEFAULT_CDP_PORT, + }); + }); + + test('reads every flag, and several bases', () => { + const args = parseBenchArgs([ + '--base', + 'a=http://localhost:1', + '--base', + 'b=http://localhost:2', + '--scenarios', + 's.json', + '--inputs', + 'wheel, trackpoint', + '--runs', + '3', + '--cdp-port', + '9444', + ]); + expect(args.bases.map((b) => b.label)).toEqual(['a', 'b']); + expect(args.inputs).toEqual(['wheel', 'trackpoint']); + expect(args.runs).toBe(3); + expect(args.cdpPort).toBe(9444); + }); + + const bad: [string, string[], string][] = [ + ['no base', ['--scenarios', 's'], 'at least one server'], + ['no scenarios', ['--base', 'http://h:1'], 'the scenarios file'], + ['a flag without a value', ['--base'], 'needs a value'], + ['an unknown flag', ['--fast', '1'], 'unknown flag --fast'], + ['an unknown input', ['--inputs', 'touch'], 'unknown input touch'], + ['zero runs', ['--runs', '0'], 'whole number'], + [ + 'a repeated label', + ['--base', 'a=http://h:1', '--base', 'a=http://h:2', '--scenarios', 's'], + 'unique', + ], + ]; + for (const [name, argv, message] of bad) { + test(`rejects ${name}`, () => expect(() => parseBenchArgs(argv)).toThrow(message)); + } +}); diff --git a/scripts/scroll-bench/bench-args.ts b/scripts/scroll-bench/bench-args.ts new file mode 100644 index 0000000..1bb196c --- /dev/null +++ b/scripts/scroll-bench/bench-args.ts @@ -0,0 +1,91 @@ +// The benchmark's command line: the servers to compare, the scenarios file, the input +// profiles, the runs and the CDP port, parsed and checked without side effects (util). + +import { INPUT_NAMES, type InputName, isInputName } from './inputs'; + +/** A server under test: the label results carry and its base URL. */ +export interface BaseTarget { + label: string; + url: string; +} + +/** What one benchmark invocation runs. */ +export interface BenchArgs { + bases: BaseTarget[]; + scenariosFile: string; + inputs: InputName[]; + runs: number; + cdpPort: number; +} + +/** The CDP port the README's Chrome command opens. */ +export const DEFAULT_CDP_PORT = 9333; +export const DEFAULT_RUNS = 2; + +export const USAGE = `usage: bun scripts/scroll-bench/bench.ts --base [label=]http://localhost:PORT ... + --scenarios FILE [--inputs trackpad,wheel,trackpoint] [--runs N] [--cdp-port PORT] + + --base a server to measure; repeat it to compare builds (label defaults to host:port) + --scenarios a JSON array of scenarios (see scripts/scroll-bench/scenarios.example.json) + --inputs the input profiles to play, comma separated (default: all) + --runs runs of each scenario and input per server (default ${DEFAULT_RUNS}) + --cdp-port the --remote-debugging-port of an already running Chrome (default ${DEFAULT_CDP_PORT})`; + +/** Parses `label=url` or a bare URL, whose label is its host and port. */ +export function parseBase(value: string): BaseTarget { + const eq = value.indexOf('='); + const label = eq > 0 ? value.slice(0, eq) : ''; + const raw = eq > 0 ? value.slice(eq + 1) : value; + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error(`--base: not a URL: ${raw}`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`--base: must be http or https: ${raw}`); + } + return { label: label || url.host, url: url.origin }; +} + +function positiveInt(flag: string, value: string): number { + const n = Number(value); + if (!Number.isInteger(n) || n < 1) throw new Error(`${flag}: must be a whole number, 1 or more`); + return n; +} + +function parseInputs(value: string): InputName[] { + const names = value.split(',').map((s) => s.trim()); + for (const name of names) { + if (!isInputName(name)) { + throw new Error(`--inputs: unknown input ${name}; one of ${INPUT_NAMES.join(', ')}`); + } + } + return names as InputName[]; +} + +/** Parses the flags after the script name; throws with the problem on a bad one. */ +export function parseBenchArgs(argv: readonly string[]): BenchArgs { + const bases: BaseTarget[] = []; + let scenariosFile = ''; + let inputs = INPUT_NAMES; + let runs = DEFAULT_RUNS; + let cdpPort = DEFAULT_CDP_PORT; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const value = argv[i + 1]; + if (value === undefined || value.startsWith('--')) throw new Error(`${flag}: needs a value`); + i++; + if (flag === '--base') bases.push(parseBase(value)); + else if (flag === '--scenarios') scenariosFile = value; + else if (flag === '--inputs') inputs = parseInputs(value); + else if (flag === '--runs') runs = positiveInt(flag, value); + else if (flag === '--cdp-port') cdpPort = positiveInt(flag, value); + else throw new Error(`unknown flag ${flag}`); + } + if (bases.length === 0) throw new Error('--base: give at least one server'); + if (!scenariosFile) throw new Error('--scenarios: give the scenarios file'); + const labels = new Set(bases.map((b) => b.label)); + if (labels.size !== bases.length) throw new Error('--base: labels must be unique'); + return { bases, scenariosFile, inputs, runs, cdpPort }; +} diff --git a/scripts/scroll-bench/bench.ts b/scripts/scroll-bench/bench.ts new file mode 100644 index 0000000..52770f5 --- /dev/null +++ b/scripts/scroll-bench/bench.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +// The scroll benchmark (CLI entry): for each server, scenario, input profile and run it +// creates a fresh session, opens it in an already running Chrome, starts the scenario's +// app, scrolls with the input while recording the screencast, prints one JSON line of +// numbers and deletes the session; then a markdown table of the medians over the runs. +// +// Start Chrome or Chromium yourself; the benchmark never launches or closes a browser: +// +// "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +// --remote-debugging-port=9333 --user-data-dir=/tmp/scroll-bench-chrome \ +// --window-size=1280,800 --no-first-run about:blank +// +// Start each webtty build to compare on its own port, then: +// +// bun run bench:scroll --base before=http://localhost:3001 --base after=http://localhost:3002 \ +// --scenarios my-scenarios.json --inputs trackpad,wheel,trackpoint --runs 2 +// +// Scenarios live in a JSON file of your own (scenarios.example.json shows the fields), so +// commands that resume a personal pi or Claude Code session stay out of the repo. A dark +// theme is assumed: the stream test finds the bottom by stream.sh's bright inverse bar. + +import path from 'node:path'; +import { parseBenchArgs, USAGE } from './bench-args'; +import { type Cdp, connectCdp, sleep } from './cdp'; +import { INPUT_PROFILES, type InputName } from './inputs'; +import { measureScroll, measureStream, startScenario } from './measure'; +import { loadScenarios, type Scenario } from './scenarios'; +import { benchSessionId, createSession, deleteSession } from './session-api'; +import { type Result, resultLine, summaryTable } from './summary'; + +/** Runs one scenario with one input in a fresh session and returns its result. */ +async function runOne( + cdp: Cdp, + base: { label: string; url: string }, + scenario: Scenario, + input: InputName, + run: number, +): Promise<Result> { + const id = benchSessionId([base.label, scenario.name, input, run]); + const session = await createSession(base.url, id); + try { + await startScenario(cdp, session.url, scenario); + const key = { + base: base.label, + scenario: scenario.name, + screen: scenario.screen, + input, + direction: scenario.direction, + run, + }; + const profile = INPUT_PROFILES[input]; + if (scenario.kind === 'stream') { + return { kind: 'stream', ...key, ...(await measureStream(cdp, profile, key.direction)) }; + } + return { kind: 'scroll', ...key, ...(await measureScroll(cdp, profile, key.direction)) }; + } finally { + await cdp.navigate('about:blank'); + await deleteSession(session); + } +} + +async function main(argv: readonly string[]): Promise<number> { + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + return 0; + } + const args = parseBenchArgs(argv); + const scenarios = loadScenarios(args.scenariosFile, { + repo: path.resolve(import.meta.dir, '../..'), + bench: import.meta.dir, + }); + const cdp = await connectCdp(args.cdpPort); + const results: Result[] = []; + try { + for (const scenario of scenarios) { + for (const input of args.inputs) { + for (let run = 1; run <= args.runs; run++) { + for (const base of args.bases) { + const result = await runOne(cdp, base, scenario, input, run); + results.push(result); + console.log(resultLine(result)); + await sleep(500); + } + } + } + } + } finally { + cdp.close(); + } + console.log(`\n${summaryTable(results)}`); + return 0; +} + +if (import.meta.main) { + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (error: unknown) => { + console.error(error instanceof Error ? error.message : error); + console.error(USAGE); + process.exit(1); + }, + ); +} diff --git a/scripts/scroll-bench/cdp.ts b/scripts/scroll-bench/cdp.ts new file mode 100644 index 0000000..d8dfb95 --- /dev/null +++ b/scripts/scroll-bench/cdp.ts @@ -0,0 +1,146 @@ +// A minimal Chrome DevTools Protocol driver for the scroll benchmark: attaches to the +// first page target of a Chrome or Chromium already started with +// `--remote-debugging-port`, and offers the few calls the benchmark needs (service over +// Bun's global fetch and WebSocket; it never launches a browser, see bench.ts for how +// to start one). + +/** A CDP event handler, given the event's params. */ +export type CdpHandler = (params: Record<string, unknown>) => void; + +/** The driver the benchmark talks to one page through. */ +export interface Cdp { + /** Sends a CDP command and resolves with its result; rejects on a protocol error. */ + send(method: string, params?: object): Promise<Record<string, unknown>>; + /** Adds a handler for a CDP event; the returned function removes it. */ + on(method: string, handler: CdpHandler): () => void; + /** Evaluates `expression` in the page, awaiting a promise, and returns its value. */ + eval<T>(expression: string): Promise<T>; + /** Navigates the page and waits for its load event. */ + navigate(url: string): Promise<void>; + /** Dispatches one mouse wheel event at (x, y). */ + wheel(x: number, y: number, deltaY: number): Promise<void>; + /** Types `text` as one insert, like a paste of plain keys. */ + text(text: string): Promise<void>; + /** Presses and releases one named key. */ + key(name: KeyName): Promise<void>; + /** Clicks the left mouse button at (x, y), to focus what is there. */ + click(x: number, y: number): Promise<void>; + /** A PNG screenshot of the page. */ + screenshot(): Promise<Uint8Array>; + close(): void; +} + +/** The keys the benchmark presses, with what CDP needs to send each. */ +const KEYS = { + Enter: { code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' }, + Escape: { code: 'Escape', windowsVirtualKeyCode: 27, text: '\u001b' }, +} as const; + +/** A key `Cdp.key` can press. */ +export type KeyName = keyof typeof KEYS; + +/** Resolves after `ms` milliseconds. */ +export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); + +interface Target { + type: string; + webSocketDebuggerUrl: string; +} + +interface Message { + id?: number; + method?: string; + params?: Record<string, unknown>; + result?: Record<string, unknown>; + error?: { message: string }; +} + +/** Connects to the first page target of the browser listening on `port`. */ +export async function connectCdp(port: number): Promise<Cdp> { + const listing = await fetch(`http://127.0.0.1:${port}/json`).catch(() => { + throw new Error(`no browser on CDP port ${port}; start Chrome with --remote-debugging-port`); + }); + const page = ((await listing.json()) as Target[]).find((t) => t.type === 'page'); + if (!page) throw new Error(`no page target on CDP port ${port}`); + const ws = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', reject, { once: true }); + }); + + let nextId = 0; + const pending = new Map<number, (msg: Message) => void>(); + const handlers = new Map<string, Set<CdpHandler>>(); + ws.addEventListener('message', (event) => { + const msg = JSON.parse(String(event.data)) as Message; + if (msg.method) for (const h of handlers.get(msg.method) ?? []) h(msg.params ?? {}); + if (msg.id !== undefined) { + pending.get(msg.id)?.(msg); + pending.delete(msg.id); + } + }); + + const send: Cdp['send'] = (method, params = {}) => { + const id = ++nextId; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => + pending.set(id, (msg) => + msg.error + ? reject(new Error(`${method}: ${msg.error.message}`)) + : resolve(msg.result ?? {}), + ), + ); + }; + const on: Cdp['on'] = (method, handler) => { + const set = handlers.get(method) ?? new Set(); + handlers.set(method, set.add(handler)); + return () => set.delete(handler); + }; + const mouse = (type: string, x: number, y: number) => + send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 }); + + await send('Page.enable'); + return { + send, + on, + async eval<T>(expression: string) { + const r = await send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (r.exceptionDetails) throw new Error(`page threw: ${JSON.stringify(r.exceptionDetails)}`); + return (r.result as { value: T }).value; + }, + async navigate(url) { + const loaded = new Promise<void>((resolve) => { + const off = on('Page.loadEventFired', () => { + off(); + resolve(); + }); + }); + await send('Page.navigate', { url }); + await loaded; + }, + async wheel(x, y, deltaY) { + await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY }); + }, + async text(text) { + await send('Input.insertText', { text }); + }, + async key(name) { + const { text, ...rest } = KEYS[name]; + await send('Input.dispatchKeyEvent', { type: 'keyDown', key: name, text, ...rest }); + await send('Input.dispatchKeyEvent', { type: 'keyUp', key: name, ...rest }); + }, + async click(x, y) { + await mouse('mousePressed', x, y); + await mouse('mouseReleased', x, y); + }, + async screenshot() { + const r = await send('Page.captureScreenshot', { format: 'png' }); + return Buffer.from(String(r.data), 'base64'); + }, + close: () => ws.close(), + }; +} diff --git a/scripts/scroll-bench/inputs.test.ts b/scripts/scroll-bench/inputs.test.ts new file mode 100644 index 0000000..8228e5b --- /dev/null +++ b/scripts/scroll-bench/inputs.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test'; +import { + type Clock, + durationMs, + INPUT_NAMES, + INPUT_PROFILES, + isInputName, + playInput, + signedEvents, +} from './inputs'; + +const { trackpad, wheel, trackpoint } = INPUT_PROFILES; + +describe('input profiles', () => { + test('the trackpad sends 2.5 px every 16 ms, 150 times', () => { + expect(trackpad.events).toHaveLength(150); + expect(trackpad.events.every((e) => e.gapMs === 16 && e.deltaPx === 2.5)).toBe(true); + }); + + test('the mouse wheel sends 12-16 whole-pixel notches 100-120 ms apart', () => { + expect(wheel.events.length).toBeGreaterThanOrEqual(12); + expect(wheel.events.length).toBeLessThanOrEqual(16); + for (const e of wheel.events) { + expect(e.deltaPx).toBe(100); + expect(e.gapMs).toBeGreaterThanOrEqual(100); + expect(e.gapMs).toBeLessThanOrEqual(120); + } + }); + + test('the TrackPoint sends light 4.000244 px steps, then a hard push of fractional deltas', () => { + const light = trackpoint.events.filter((e) => e.deltaPx === 4.000244); + const hard = trackpoint.events.slice(light.length); + expect(light.length).toBeGreaterThan(0); + expect(light.every((e) => e.gapMs >= 140 && e.gapMs <= 160)).toBe(true); + expect(hard.length).toBeGreaterThan(0); + expect(hard.every((e) => !Number.isInteger(e.deltaPx))).toBe(true); + expect(Math.max(...hard.map((e) => e.deltaPx))).toBeCloseTo(335, 0); + // The first hard delta follows a pause; the rest come every 29-45 ms (ADR 045). + expect(hard.slice(1).every((e) => e.gapMs >= 29 && e.gapMs <= 45)).toBe(true); + }); + + test('every profile is a fixed table: positive deltas and gaps, the same each read', () => { + for (const name of INPUT_NAMES) { + const { events } = INPUT_PROFILES[name]; + expect(events.every((e) => e.deltaPx > 0 && e.gapMs > 0)).toBe(true); + expect(INPUT_PROFILES[name].events).toEqual(events); + } + }); +}); + +describe('profile helpers', () => { + test('isInputName knows the three profiles only', () => { + expect(INPUT_NAMES.every(isInputName)).toBe(true); + expect(isInputName('touch')).toBe(false); + expect(isInputName('toString')).toBe(false); + }); + + test('signedEvents makes up negative and keeps down positive', () => { + expect(signedEvents(wheel, 'up').every((e) => e.deltaPx === -100)).toBe(true); + expect(signedEvents(wheel, 'down')).toEqual([...wheel.events]); + }); + + test('durationMs sums the gaps', () => { + expect(durationMs(trackpad)).toBe(2400); + }); +}); + +describe('playInput', () => { + test('sends each event when it is due, on a schedule from the start', async () => { + let now = 0; + const clock: Clock = { + now: () => now, + sleep: async (ms) => { + now += ms; + }, + }; + const sent: [number, number, number, number][] = []; + const cdp = { + wheel: async (x: number, y: number, deltaY: number) => { + sent.push([now, x, y, deltaY]); + now += 5; // a CDP round trip + }, + }; + await playInput(cdp, wheel, 'up', { x: 10, y: 20 }, clock); + expect(sent).toHaveLength(wheel.events.length); + let due = 0; + wheel.events.forEach((e, i) => { + due += e.gapMs; + expect(sent[i]).toEqual([due, 10, 20, -100]); + }); + }); +}); diff --git a/scripts/scroll-bench/inputs.ts b/scripts/scroll-bench/inputs.ts new file mode 100644 index 0000000..0290194 --- /dev/null +++ b/scripts/scroll-bench/inputs.ts @@ -0,0 +1,124 @@ +// The scroll input profiles as data, and playing one through CDP mouse wheel events. +// Each profile is a fixed table, so every run sends the same events at the same times: +// a trackpad's per-frame pixel deltas, a mouse wheel's notches and a TrackPoint's sparse +// fractional deltas, as ADR 045 measured them in Chrome on macOS (constants + service). + +import type { Cdp } from './cdp'; + +/** One wheel event: its pixel distance (positive scrolls down) and the wait before it. */ +export interface WheelEvent { + gapMs: number; + deltaPx: number; +} + +/** The input devices the benchmark imitates. */ +export type InputName = 'trackpad' | 'wheel' | 'trackpoint'; + +/** Which way to scroll: up into the scrollback or the file's start, or down. */ +export type Direction = 'up' | 'down'; + +/** A device's wheel events and what they stand for. */ +export interface InputProfile { + name: InputName; + description: string; + events: readonly WheelEvent[]; +} + +const repeat = (count: number, gapMs: number, deltaPx: number): WheelEvent[] => + Array.from({ length: count }, () => ({ gapMs, deltaPx })); + +/** Gaps between a mouse wheel's notches, spun by hand at about 9 notches a second. */ +const WHEEL_GAPS = [110, 100, 120, 105, 115, 100, 110, 120, 105, 115, 100, 110, 120, 105]; +/** A light TrackPoint push: 4.000244 px about every 150 ms (ADR 045's capture). */ +const TRACKPOINT_LIGHT_GAPS = [150, 145, 155, 150, 148, 152, 150, 150]; +/** A hard TrackPoint push, ramping up to about 335 px and back, every 29-45 ms (ADR 045). */ +const TRACKPOINT_HARD: readonly [number, number][] = [ + [300, 18.5], + [38, 46.25], + [31, 92.75], + [44, 151.25], + [29, 213.5], + [36, 268.25], + [42, 312.75], + [33, 334.75], + [45, 329.5], + [30, 301.25], + [39, 247.25], + [35, 176.5], + [41, 104.25], + [32, 52.75], + [37, 21.5], +]; + +/** Every profile, by name. */ +export const INPUT_PROFILES: Record<InputName, InputProfile> = { + trackpad: { + name: 'trackpad', + description: 'a slow trackpad scroll: 2.5 px every 16 ms, 150 events', + events: repeat(150, 16, 2.5), + }, + wheel: { + name: 'wheel', + description: 'a mouse wheel: 14 notches of 100 px, 100-120 ms apart', + events: WHEEL_GAPS.map((gapMs) => ({ gapMs, deltaPx: 100 })), + }, + trackpoint: { + name: 'trackpoint', + description: 'a TrackPoint: 8 light 4.000244 px steps, then a hard push of 15 deltas', + events: [ + ...TRACKPOINT_LIGHT_GAPS.map((gapMs) => ({ gapMs, deltaPx: 4.000244 })), + ...TRACKPOINT_HARD.map(([gapMs, deltaPx]) => ({ gapMs, deltaPx })), + ], + }, +}; + +/** The profile names, in the order the benchmark plays them by default. */ +export const INPUT_NAMES = Object.keys(INPUT_PROFILES) as InputName[]; + +/** Whether `name` is a known input profile. */ +export function isInputName(name: string): name is InputName { + return Object.hasOwn(INPUT_PROFILES, name); +} + +/** The profile's events with signed deltas for `direction` (up is a negative deltaY). */ +export function signedEvents(profile: InputProfile, direction: Direction): WheelEvent[] { + const sign = direction === 'up' ? -1 : 1; + return profile.events.map((e) => ({ gapMs: e.gapMs, deltaPx: sign * e.deltaPx })); +} + +/** How long the profile takes to send, in ms. */ +export function durationMs(profile: InputProfile): number { + return profile.events.reduce((sum, e) => sum + e.gapMs, 0); +} + +/** The clock playInput waits on; tests pass a fake one. */ +export interface Clock { + now(): number; + sleep(ms: number): Promise<void>; +} + +const REAL_CLOCK: Clock = { + now: () => performance.now(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}; + +/** + * Plays `profile` at (x, y). Each event is due at the sum of the gaps before it, measured + * from the start, so a slow CDP round trip delays one event instead of every later one. + */ +export async function playInput( + cdp: Pick<Cdp, 'wheel'>, + profile: InputProfile, + direction: Direction, + at: { x: number; y: number }, + clock: Clock = REAL_CLOCK, +): Promise<void> { + const start = clock.now(); + let due = 0; + for (const event of signedEvents(profile, direction)) { + due += event.gapMs; + const wait = start + due - clock.now(); + if (wait > 0) await clock.sleep(wait); + await cdp.wheel(at.x, at.y, event.deltaPx); + } +} diff --git a/scripts/scroll-bench/measure.ts b/scripts/scroll-bench/measure.ts new file mode 100644 index 0000000..b13c28d --- /dev/null +++ b/scripts/scroll-bench/measure.ts @@ -0,0 +1,117 @@ +// One measurement in an open page: start the scenario's app in the session, then record a +// scroll with an input profile and reduce it to numbers, either blinks and paints (the +// scroll test) or whether the view held still while output streamed (the stream test). +// Service over Cdp; the numbers come from metrics.ts. + +import { type Cdp, sleep } from './cdp'; +import { type Direction, type InputProfile, playInput } from './inputs'; +import { + averageStepPx, + blinkFrames, + matchPairs, + type PaintStats, + paintStats, + SCROLL_WINDOW, + type StreamStats, + streamStats, +} from './metrics'; +import { decodeProfiles, readPaintCounter, resetPaintCounter, viewportCentre } from './page'; +import type { Scenario } from './scenarios'; +import { type CastOptions, startRecording } from './screencast'; + +const CAST: CastOptions = { quality: 80, maxWidth: 1280, maxHeight: 800 }; +/** How long to wait for the client to draw its terminal canvas after the page loads. */ +const READY_TIMEOUT_MS = 10_000; +/** Recording kept after the last wheel event, so glides and late paints are seen. */ +const TAIL_MS = 400; +/** The stream test watches the view at rest this long after the scroll. */ +const STREAM_REST_MS = 1500; +/** Settle time after a stream test's scroll before at-rest frames count. */ +const STREAM_SETTLE_MS = 300; + +/** Waits until the page shows a canvas, the client's terminal. */ +async function waitForTerminal(cdp: Cdp): Promise<void> { + const until = Date.now() + READY_TIMEOUT_MS; + while (!(await cdp.eval<boolean>(`!!document.querySelector('canvas')`))) { + if (Date.now() > until) throw new Error('the terminal canvas never appeared'); + await sleep(100); + } +} + +/** Opens `url`, types the scenario's command at the shell prompt and waits for its app. */ +export async function startScenario(cdp: Cdp, url: string, scenario: Scenario): Promise<void> { + await cdp.navigate(url); + await waitForTerminal(cdp); + await sleep(500); + const centre = await viewportCentre(cdp); + await cdp.click(centre.x, centre.y); + await cdp.text(scenario.command); + await cdp.key('Enter'); + await sleep(scenario.waitMs); + if (scenario.enter2) { + await cdp.key('Enter'); + await sleep(scenario.enter2WaitMs); + } +} + +/** The scroll test's numbers for one input. */ +export interface ScrollMeasure extends PaintStats { + frames: number; + blinks: number; + avgStepPx: number; +} + +/** + * Plays `input` while recording the screencast and the paint counter, then counts + * blinks (transient frames), paints per second, paint gaps and the average step. + */ +export async function measureScroll( + cdp: Cdp, + input: InputProfile, + direction: Direction, +): Promise<ScrollMeasure> { + const centre = await viewportCentre(cdp); + await resetPaintCounter(cdp); + const recording = await startRecording(cdp, CAST); + await playInput(cdp, input, direction, centre); + await sleep(TAIL_MS); + const cast = await recording.stop(); + const log = await readPaintCounter(cdp); + const profiles = await decodeProfiles( + cdp, + cast.map((f) => f.jpeg), + { x: 8, width: 600, channels: 'rg' }, + ); + const pairs = matchPairs(profiles, SCROLL_WINDOW); + return { + frames: profiles.length, + blinks: blinkFrames(profiles, pairs, SCROLL_WINDOW).length, + avgStepPx: averageStepPx(pairs), + ...paintStats(log.frames, log.wheels, TAIL_MS), + }; +} + +/** + * Scrolls back with `input` while the scenario streams output on the normal screen, then + * watches the view at rest: counts frames showing the bottom (the inverse status bar + * stream.sh draws), during the scroll and after it, and at-rest frames that moved. + */ +export async function measureStream( + cdp: Cdp, + input: InputProfile, + direction: Direction, +): Promise<StreamStats> { + const centre = await viewportCentre(cdp); + const recording = await startRecording(cdp, CAST); + await playInput(cdp, input, direction, centre); + const scrollEnd = Date.now(); + await sleep(STREAM_SETTLE_MS + STREAM_REST_MS); + const cast = await recording.stop(); + const profiles = await decodeProfiles( + cdp, + cast.map((f) => f.jpeg), + { x: 0, width: 4000, channels: 'r' }, + ); + const frames = cast.map((f, i) => ({ time: f.time, profile: profiles[i] })); + return streamStats(frames, scrollEnd, STREAM_SETTLE_MS); +} diff --git a/scripts/scroll-bench/metrics.test.ts b/scripts/scroll-bench/metrics.test.ts new file mode 100644 index 0000000..922ac75 --- /dev/null +++ b/scripts/scroll-bench/metrics.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import { + atBottom, + averageStepPx, + BAR_BRIGHTNESS, + bestShift, + blinkFrames, + matchPairs, + median, + type PaintFrame, + type Profile, + paintStats, + SCROLL_WINDOW, + type StreamFrame, + streamStats, +} from './metrics'; + +const HEIGHT = 320; + +/** A textured page, so every shift of it looks different: brightness of content row y. */ +const page = (y: number): number => 60 + 40 * Math.sin(y * 0.37) + 12 * ((y * 31) % 7); + +/** The page scrolled down by `offset` px. */ +const frame = (offset: number): number[] => + Array.from({ length: HEIGHT }, (_, y) => page(y + offset)); + +/** Frames of a smooth scroll, `step` px apart. */ +const smooth = (count: number, step: number): number[][] => + Array.from({ length: count }, (_, k) => frame(k * step)); + +const blinksIn = (profiles: Profile[]): number[] => + blinkFrames(profiles, matchPairs(profiles, SCROLL_WINDOW), SCROLL_WINDOW); + +describe('bestShift', () => { + test('finds the shift between two frames of the same page, with no error', () => { + const match = bestShift(frame(0), frame(5), SCROLL_WINDOW); + expect(match.shift).toBe(-5); + expect(match.error).toBeCloseTo(0, 6); + }); + + test('a different picture has a large error at every shift', () => { + const flat = Array.from({ length: HEIGHT }, () => 0); + expect(bestShift(frame(0), flat, SCROLL_WINDOW).error).toBeGreaterThan(3); + }); +}); + +describe('blinkFrames', () => { + const torn = (offset: number): number[] => + frame(offset).map((v, y) => (y < HEIGHT / 2 ? page(y + offset + 25) : v)); + const flashed = (): number[] => Array.from({ length: HEIGHT }, () => 0); + + const cases: [string, () => number[][], number[]][] = [ + ['a smooth scroll', () => smooth(12, 3), []], + ['a fast smooth scroll', () => smooth(12, 40), []], + ['a still view', () => smooth(12, 0), []], + ['a single transient frame', () => smooth(12, 3).map((p, k) => (k === 5 ? flashed() : p)), [5]], + ['a torn half-frame', () => smooth(12, 3).map((p, k) => (k === 6 ? torn(18) : p)), [6]], + [ + 'a cut to another picture that stays', + () => smooth(12, 3).map((p, k) => (k >= 5 ? flashed() : p)), + [], + ], + ]; + for (const [name, make, expected] of cases) { + test(name, () => expect(blinksIn(make())).toEqual(expected)); + } +}); + +describe('averageStepPx', () => { + test('the mean distance of the frames that moved', () => { + expect(averageStepPx(matchPairs(smooth(6, 4), SCROLL_WINDOW))).toBe(4); + }); + test('0 when nothing moved', () => { + expect(averageStepPx(matchPairs(smooth(4, 0), SCROLL_WINDOW))).toBe(0); + expect(averageStepPx([])).toBe(0); + }); +}); + +describe('paintStats', () => { + const frames: PaintFrame[] = [ + [0, true], + [16, true], + [32, false], + [48, true], + [64, true], + [1000, true], + ]; + + test('counts paints from the first wheel to the tail after the last', () => { + expect(paintStats(frames, [0, 100])).toEqual({ + paintsPerSec: 13.3, + gapMedian: 16, + gapP90: 32, + }); + }); + + test('a shorter tail leaves out later paints', () => { + expect(paintStats(frames, [0, 40], 10).paintsPerSec).toBe(60); + }); + + test('no wheel events, no stats', () => { + expect(paintStats(frames, [])).toEqual({ paintsPerSec: 0, gapMedian: 0, gapP90: 0 }); + }); +}); + +describe('streamStats', () => { + const withBar = (offset: number): number[] => + frame(offset).map((v, y) => (y === HEIGHT - 10 ? BAR_BRIGHTNESS + 50 : Math.min(v, 140))); + const scrolled = (offset: number): number[] => frame(offset).map((v) => Math.min(v, 140)); + + test('atBottom sees the inverse status bar', () => { + expect(atBottom(withBar(0))).toBe(true); + expect(atBottom(scrolled(0))).toBe(false); + }); + + test('splits frames at the scroll end plus settle, counting bottoms and moves', () => { + const frames: StreamFrame[] = [ + { time: 0, profile: withBar(0) }, + { time: 100, profile: scrolled(30) }, + { time: 200, profile: withBar(0) }, + { time: 700, profile: scrolled(30) }, + { time: 800, profile: scrolled(30) }, + { time: 900, profile: scrolled(36) }, + { time: 1000, profile: withBar(0) }, + ]; + expect(streamStats(frames, 300, 300)).toEqual({ + framesDuringScroll: 3, + atBottomDuringScroll: 2, + framesAtRest: 4, + atBottomAtRest: 1, + restFramesThatMoved: 3, + }); + }); +}); + +describe('median', () => { + const cases: [number[], number][] = [ + [[], 0], + [[5], 5], + [[3, 1, 2], 2], + [[4, 1, 3, 2], 2.5], + ]; + for (const [values, expected] of cases) { + test(`${JSON.stringify(values)} → ${expected}`, () => expect(median(values)).toBe(expected)); + } +}); diff --git a/scripts/scroll-bench/metrics.ts b/scripts/scroll-bench/metrics.ts new file mode 100644 index 0000000..42da612 --- /dev/null +++ b/scripts/scroll-bench/metrics.ts @@ -0,0 +1,178 @@ +// The benchmark's measures as pure functions over numbers: how two recorded frames' row +// brightness profiles line up, blinks (transient frames), paint rate and gaps from frame +// timestamps, scroll step size, and the streaming test's at-bottom and at-rest counts +// (util; the page decodes frames into profiles, page.ts). + +/** A frame's mean brightness per pixel row, top to bottom. */ +export type Profile = ArrayLike<number>; + +/** How a profile is compared with the next: shifts tried, and rows skipped at each edge. */ +export interface ShiftWindow { + maxShift: number; + margin: number; +} + +/** The best vertical alignment of two frames: its mean row error and its shift in px. */ +export interface ShiftMatch { + error: number; + shift: number; +} + +/** The scroll test's window: any shift a frame of smooth scrolling makes (blink.js). */ +export const SCROLL_WINDOW: ShiftWindow = { maxShift: 60, margin: 70 }; +/** The streaming test's window (t66.js). */ +export const STREAM_WINDOW: ShiftWindow = { maxShift: 40, margin: 60 }; +/** A mean row error above this is a different picture, not JPEG noise. */ +export const DIFFERENT = 3; + +/** + * The shift of `b` against `a` with the least mean error over the rows inside the margin; + * ties keep the first shift tried, from `-maxShift` up. + */ +export function bestShift(a: Profile, b: Profile, window: ShiftWindow): ShiftMatch { + const { maxShift, margin } = window; + let best: ShiftMatch = { error: Number.POSITIVE_INFINITY, shift: 0 }; + for (let s = -maxShift; s <= maxShift; s++) { + let sum = 0; + let rows = 0; + for (let y = margin; y < a.length - margin; y++) { + sum += Math.abs(b[y] - a[y - s]); + rows++; + } + const error = rows ? sum / rows : 0; + if (error < best.error) best = { error, shift: s }; + } + return best; +} + +/** The best match of each frame against the one before it; entry k is frame k + 1's. */ +export function matchPairs(profiles: readonly Profile[], window: ShiftWindow): ShiftMatch[] { + return profiles.slice(1).map((p, k) => bestShift(profiles[k], p, window)); +} + +/** + * The frames that blink: unlike both neighbours while the neighbours match each other, + * at any shift, so smooth motion never counts but a flashed or half-drawn frame does. + * `pairs` is `matchPairs(profiles, window)`. + */ +export function blinkFrames( + profiles: readonly Profile[], + pairs: readonly ShiftMatch[], + window: ShiftWindow, +): number[] { + const blinks: number[] = []; + for (let k = 1; k + 1 < profiles.length; k++) { + const before = pairs[k - 1].error; + const after = pairs[k].error; + if (before <= DIFFERENT || after <= DIFFERENT) continue; + const across = bestShift(profiles[k - 1], profiles[k + 1], window).error; + if (across < Math.min(before, after) / 2) blinks.push(k); + } + return blinks; +} + +/** The mean distance of the frames that moved, in px (0 when none did). */ +export function averageStepPx(pairs: readonly ShiftMatch[]): number { + const moved = pairs.filter((p) => p.shift !== 0); + if (moved.length === 0) return 0; + return round(moved.reduce((sum, p) => sum + Math.abs(p.shift), 0) / moved.length, 1); +} + +/** One animation frame from the page's paint counter: its time and whether it drew. */ +export type PaintFrame = readonly [time: number, drew: boolean]; + +/** How often the canvas painted while scrolling, and the gaps between paints in ms. */ +export interface PaintStats { + paintsPerSec: number; + gapMedian: number; + gapP90: number; +} + +/** + * Paint stats from the first wheel event to `tailMs` after the last, counting only the + * animation frames that drew. + */ +export function paintStats( + frames: readonly PaintFrame[], + wheels: readonly number[], + tailMs = 200, +): PaintStats { + if (wheels.length === 0) return { paintsPerSec: 0, gapMedian: 0, gapP90: 0 }; + const start = wheels[0]; + const end = wheels[wheels.length - 1] + tailMs; + const painted = frames.filter(([t, drew]) => drew && t >= start && t <= end).map(([t]) => t); + const gaps = painted + .slice(1) + .map((t, i) => t - painted[i]) + .sort((a, b) => a - b); + const at = (q: number) => Math.round(gaps[Math.floor(gaps.length * q)] ?? 0); + return { + paintsPerSec: round(painted.length / ((end - start) / 1000), 1), + gapMedian: at(0.5), + gapP90: at(0.9), + }; +} + +/** A recorded frame of the streaming test: when it came and how it looked. */ +export interface StreamFrame { + time: number; + profile: Profile; +} + +/** The streaming test's counts, split at the end of the scroll plus a settle time. */ +export interface StreamStats { + framesDuringScroll: number; + atBottomDuringScroll: number; + framesAtRest: number; + atBottomAtRest: number; + restFramesThatMoved: number; +} + +/** The inverse status bar is the only row this bright, so it shows the view is at the bottom. */ +export const BAR_BRIGHTNESS = 150; + +/** Whether the frame shows the inverse status bar, the bottom of the stream. */ +export function atBottom(profile: Profile): boolean { + for (let y = 0; y < profile.length; y++) if (profile[y] > BAR_BRIGHTNESS) return true; + return false; +} + +/** + * Counts frames at the bottom during and after the scroll back, and at-rest frames that + * moved or changed from the one before (the view should hold still while output comes). + */ +export function streamStats( + frames: readonly StreamFrame[], + scrollEnd: number, + settleMs = 300, +): StreamStats { + const restFrom = scrollEnd + settleMs; + const during = frames.filter((f) => f.time <= restFrom); + const rest = frames.filter((f) => f.time > restFrom); + let moved = 0; + for (let k = 1; k < frames.length; k++) { + if (frames[k].time <= restFrom) continue; + const match = bestShift(frames[k - 1].profile, frames[k].profile, STREAM_WINDOW); + if (match.shift !== 0 || match.error > DIFFERENT) moved++; + } + return { + framesDuringScroll: during.length, + atBottomDuringScroll: during.filter((f) => atBottom(f.profile)).length, + framesAtRest: rest.length, + atBottomAtRest: rest.filter((f) => atBottom(f.profile)).length, + restFramesThatMoved: moved, + }; +} + +/** The median of `values` (the mean of the middle two for an even count), or 0 for none. */ +export function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length / 2; + return Number.isInteger(mid) ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[Math.floor(mid)]; +} + +function round(value: number, digits: number): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} diff --git a/scripts/scroll-bench/page.ts b/scripts/scroll-bench/page.ts new file mode 100644 index 0000000..415ca65 --- /dev/null +++ b/scripts/scroll-bench/page.ts @@ -0,0 +1,109 @@ +// The parts of the benchmark that run in the page, as expression strings evaluated through +// CDP: the paint counter (which animation frames drew on a canvas, and when wheel events +// came) and decoding screencast JPEGs into row brightness profiles (service over Cdp). + +import type { Cdp } from './cdp'; +import type { PaintFrame, Profile } from './metrics'; + +/** + * Wraps the canvas drawing calls to flag a frame that drew, records every animation frame + * and wheel event, and clears what an earlier run recorded. Installs once per page. + */ +const PAINT_COUNTER_RESET = `(() => { + if (!window.__bench) { + window.__bench = { drew: false, frames: [], wheels: [] }; + const P = CanvasRenderingContext2D.prototype; + for (const m of ['fillText', 'fillRect', 'drawImage', 'clearRect', 'putImageData']) { + const orig = P[m]; + P[m] = function (...a) { window.__bench.drew = true; return orig.apply(this, a); }; + } + addEventListener('wheel', () => window.__bench.wheels.push(performance.now()), { capture: true }); + const tick = (t) => { + window.__bench.frames.push([t, window.__bench.drew]); + window.__bench.drew = false; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + } + window.__bench.frames.length = 0; + window.__bench.wheels.length = 0; +})()`; + +const PAINT_COUNTER_READ = `({ frames: window.__bench.frames, wheels: window.__bench.wheels })`; + +/** What the paint counter recorded: animation frames and wheel event times, page clock. */ +export interface PaintLog { + frames: PaintFrame[]; + wheels: number[]; +} + +/** Installs the paint counter, or clears it when already installed. */ +export async function resetPaintCounter(cdp: Cdp): Promise<void> { + await cdp.eval(PAINT_COUNTER_RESET); +} + +/** Reads what the paint counter recorded since its last reset. */ +export function readPaintCounter(cdp: Cdp): Promise<PaintLog> { + return cdp.eval<PaintLog>(PAINT_COUNTER_READ); +} + +/** The columns a profile averages over, and which colour channels it reads. */ +export interface ProfileRegion { + x: number; + /** At most this many columns; fewer when the frame is narrower. */ + width: number; + /** `rg`: the mean of red and green (the scroll test); `r`: red alone (the stream test). */ + channels: 'rg' | 'r'; +} + +/** An expression decoding `jpegs` (base64) into one rounded profile per frame. */ +export function profileExpression(jpegs: readonly string[], region: ProfileRegion): string { + return `(async () => { + const region = ${JSON.stringify(region)}; + const out = []; + for (const data of ${JSON.stringify(jpegs)}) { + const img = new Image(); + img.src = 'data:image/jpeg;base64,' + data; + await img.decode(); + const canvas = new OffscreenCanvas(img.width, img.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + const w = Math.max(1, Math.min(region.width, img.width - 2 * region.x)); + const px = ctx.getImageData(region.x, 0, w, img.height).data; + const prof = []; + for (let y = 0; y < img.height; y++) { + let sum = 0; + for (let i = 0; i < w; i++) { + const at = (y * w + i) * 4; + sum += region.channels === 'rg' ? (px[at] + px[at + 1]) / 2 : px[at]; + } + prof.push(Math.round((sum / w) * 100) / 100); + } + out.push(prof); + } + return out; + })()`; +} + +/** Frames decoded per evaluation, so no one expression carries megabytes of JPEG. */ +const BATCH = 40; + +/** Decodes screencast JPEGs into profiles in the page, a batch at a time. */ +export async function decodeProfiles( + cdp: Cdp, + jpegs: readonly string[], + region: ProfileRegion, +): Promise<Profile[]> { + const profiles: Profile[] = []; + for (let i = 0; i < jpegs.length; i += BATCH) { + profiles.push( + ...(await cdp.eval<number[][]>(profileExpression(jpegs.slice(i, i + BATCH), region))), + ); + } + return profiles; +} + +/** The middle of the page's viewport, where the wheel events go. */ +export function viewportCentre(cdp: Cdp): Promise<{ x: number; y: number }> { + return cdp.eval(`({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) })`); +} diff --git a/scripts/scroll-bench/scenarios.example.json b/scripts/scroll-bench/scenarios.example.json new file mode 100644 index 0000000..16b81df --- /dev/null +++ b/scripts/scroll-bench/scenarios.example.json @@ -0,0 +1,86 @@ +[ + { + "name": "seq", + "note": "shell scrollback: 5000 lines on the normal screen, scrolled back", + "command": "seq 1 5000", + "waitMs": 2000, + "direction": "up", + "screen": "normal" + }, + { + "name": "vim", + "note": "vim on a big file, scrolled down from the top", + "command": "seq -f 'line %g of a big file for vim to scroll' 1 20000 > /tmp/scroll-bench.txt && vim -u NONE -c 'set mouse=a' /tmp/scroll-bench.txt", + "waitMs": 3000, + "direction": "down", + "screen": "alt" + }, + { + "name": "nvim", + "command": "seq -f 'line %g of a big file for nvim to scroll' 1 20000 > /tmp/scroll-bench.txt && nvim --clean /tmp/scroll-bench.txt", + "waitMs": 3000, + "direction": "down", + "screen": "alt" + }, + { + "name": "yazi", + "note": "a long file list; yazi tracks the mouse", + "command": "yazi /usr/bin", + "waitMs": 3000, + "direction": "down", + "screen": "alt" + }, + { + "name": "less", + "note": "alternate screen without mouse tracking: the wheel becomes arrow keys", + "command": "seq 1 20000 | less", + "waitMs": 2000, + "direction": "down", + "screen": "alt" + }, + { + "name": "pi-fullscreen", + "note": "placeholder: -r opens pi's session picker, the second Enter resumes the first entry", + "command": "pi -r", + "waitMs": 4000, + "enter2": true, + "enter2WaitMs": 6000, + "direction": "up", + "screen": "alt" + }, + { + "name": "pi-classic", + "note": "placeholder: pi drawing on the normal screen", + "command": "pi --tui-mode regular -r", + "waitMs": 4000, + "enter2": true, + "enter2WaitMs": 6000, + "direction": "up", + "screen": "normal" + }, + { + "name": "claude-fullscreen", + "note": "placeholder: replace <id> with a long Claude Code session; --fork-session leaves it untouched", + "command": "claude --resume <id> --fork-session", + "waitMs": 8000, + "direction": "up", + "screen": "alt" + }, + { + "name": "claude-classic", + "note": "placeholder: Claude Code on the normal screen", + "command": "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --resume <id> --fork-session", + "waitMs": 8000, + "direction": "up", + "screen": "normal" + }, + { + "name": "stream", + "note": "the stream test: scroll back while output streams; the view should stay put", + "kind": "stream", + "command": "bash {bench}/stream.sh 40 120", + "waitMs": 4000, + "direction": "up", + "screen": "normal" + } +] diff --git a/scripts/scroll-bench/scenarios.test.ts b/scripts/scroll-bench/scenarios.test.ts new file mode 100644 index 0000000..330659d --- /dev/null +++ b/scripts/scroll-bench/scenarios.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { expandCommand, loadScenarios, parseScenario, parseScenarios } from './scenarios'; + +const VARS = { repo: '/repo', bench: '/repo/scripts/scroll-bench' }; + +describe('parseScenario', () => { + test('fills in the defaults', () => { + expect(parseScenario({ name: 'seq', command: 'seq 1 5000' }, 0, VARS)).toEqual({ + name: 'seq', + kind: 'scroll', + command: 'seq 1 5000', + waitMs: 3000, + enter2: false, + enter2WaitMs: 6000, + direction: 'up', + screen: 'normal', + }); + }); + + test('keeps given fields and expands placeholders', () => { + const s = parseScenario( + { + name: 'stream', + note: 'ignored', + kind: 'stream', + command: 'bash {bench}/stream.sh', + waitMs: 100, + enter2: true, + enter2WaitMs: 0, + direction: 'down', + screen: 'alt', + }, + 0, + VARS, + ); + expect(s).toMatchObject({ + kind: 'stream', + command: 'bash /repo/scripts/scroll-bench/stream.sh', + waitMs: 100, + enter2: true, + enter2WaitMs: 0, + direction: 'down', + screen: 'alt', + }); + }); + + const bad: [string, unknown, string][] = [ + ['not an object', [], 'must be an object'], + ['an unknown field', { name: 'a', command: 'x', wait: 1 }, 'unknown field wait'], + ['an upper-case name', { name: 'Vim', command: 'vim' }, 'name must be'], + ['an empty command', { name: 'a', command: ' ' }, 'command must be'], + ['a bad kind', { name: 'a', command: 'x', kind: 'paint' }, 'kind: must be one of'], + ['a bad direction', { name: 'a', command: 'x', direction: 'left' }, 'direction'], + ['a negative wait', { name: 'a', command: 'x', waitMs: -1 }, 'waitMs'], + ['a string enter2', { name: 'a', command: 'x', enter2: 'yes' }, 'enter2'], + ]; + for (const [name, entry, message] of bad) { + test(`rejects ${name}`, () => expect(() => parseScenario(entry, 3, VARS)).toThrow(message)); + } +}); + +describe('parseScenarios', () => { + test('needs an array of uniquely named scenarios', () => { + expect(() => parseScenarios({}, VARS)).toThrow('must be a JSON array'); + const dup = [ + { name: 'a', command: 'x' }, + { name: 'a', command: 'y' }, + ]; + expect(() => parseScenarios(dup, VARS)).toThrow('duplicate name a'); + }); + + test('expandCommand replaces every placeholder', () => { + expect(expandCommand('{repo} {bench} {repo}', VARS)).toBe( + '/repo /repo/scripts/scroll-bench /repo', + ); + }); + + test('the example file parses, with a stream scenario', () => { + const scenarios = loadScenarios(path.join(import.meta.dir, 'scenarios.example.json'), VARS); + expect(scenarios.map((s) => s.name)).toContain('seq'); + const stream = scenarios.find((s) => s.kind === 'stream'); + expect(stream?.command).toBe('bash /repo/scripts/scroll-bench/stream.sh 40 120'); + }); +}); diff --git a/scripts/scroll-bench/scenarios.ts b/scripts/scroll-bench/scenarios.ts new file mode 100644 index 0000000..a44be8f --- /dev/null +++ b/scripts/scroll-bench/scenarios.ts @@ -0,0 +1,112 @@ +// Benchmark scenarios: what to run in a fresh session before scrolling, read from a JSON +// file given on the command line so personal commands (resuming one pi or Claude Code +// session) stay out of the repo. Parsing and checking are pure; scenarios.example.json +// shows the format; `note` is free text the benchmark ignores (util). + +import fs from 'node:fs'; +import type { Direction } from './inputs'; + +/** What a scenario measures: blinks and paints while scrolling, or the streaming test. */ +export type ScenarioKind = 'scroll' | 'stream'; + +/** One app to start and scroll. */ +export interface Scenario { + name: string; + kind: ScenarioKind; + /** The command typed into the session's shell, after placeholder expansion. */ + command: string; + /** Time for the app to start and draw, in ms. */ + waitMs: number; + /** Press Enter once more after `waitMs` (a picker's first entry, as `pi -r` shows). */ + enter2: boolean; + /** Time after the second Enter, in ms. */ + enter2WaitMs: number; + direction: Direction; + /** Which screen the app draws on; reported with the results. */ + screen: 'alt' | 'normal'; +} + +/** Values for the `{repo}` and `{bench}` placeholders in a command. */ +export interface Placeholders { + repo: string; + bench: string; +} + +const NAME_RE = /^[a-z0-9][a-z0-9-]*$/; +const KNOWN_FIELDS = new Set([ + 'name', + 'note', + 'kind', + 'command', + 'waitMs', + 'enter2', + 'enter2WaitMs', + 'direction', + 'screen', +]); + +function oneOf<T extends string>(where: string, value: unknown, allowed: readonly T[], or: T): T { + if (value === undefined) return or; + if (allowed.includes(value as T)) return value as T; + throw new Error(`${where}: must be one of ${allowed.join(', ')}`); +} + +function ms(where: string, value: unknown, or: number): number { + if (value === undefined) return or; + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value; + throw new Error(`${where}: must be a number of ms, 0 or more`); +} + +/** Replaces `{repo}` and `{bench}` in `command`. */ +export function expandCommand(command: string, vars: Placeholders): string { + return command.replaceAll('{repo}', vars.repo).replaceAll('{bench}', vars.bench); +} + +/** Checks one scenario entry and fills in its defaults; throws naming the bad field. */ +export function parseScenario(entry: unknown, index: number, vars: Placeholders): Scenario { + const where = `scenario ${index}`; + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new Error(`${where}: must be an object`); + } + const raw = entry as Record<string, unknown>; + for (const key of Object.keys(raw)) { + if (!KNOWN_FIELDS.has(key)) throw new Error(`${where}: unknown field ${key}`); + } + if (typeof raw.name !== 'string' || !NAME_RE.test(raw.name)) { + throw new Error(`${where}: name must be lowercase letters, digits and dashes`); + } + if (typeof raw.command !== 'string' || raw.command.trim() === '') { + throw new Error(`${where} (${raw.name}): command must be a non-empty string`); + } + if (raw.enter2 !== undefined && typeof raw.enter2 !== 'boolean') { + throw new Error(`${where} (${raw.name}): enter2 must be true or false`); + } + const at = `${where} (${raw.name})`; + return { + name: raw.name, + kind: oneOf(`${at} kind`, raw.kind, ['scroll', 'stream'], 'scroll'), + command: expandCommand(raw.command, vars), + waitMs: ms(`${at} waitMs`, raw.waitMs, 3000), + enter2: raw.enter2 ?? false, + enter2WaitMs: ms(`${at} enter2WaitMs`, raw.enter2WaitMs, 6000), + direction: oneOf(`${at} direction`, raw.direction, ['up', 'down'], 'up'), + screen: oneOf(`${at} screen`, raw.screen, ['alt', 'normal'], 'normal'), + }; +} + +/** Checks a scenarios file's parsed JSON: an array of entries with unique names. */ +export function parseScenarios(json: unknown, vars: Placeholders): Scenario[] { + if (!Array.isArray(json)) throw new Error('scenarios: must be a JSON array'); + const scenarios = json.map((entry, i) => parseScenario(entry, i, vars)); + const seen = new Set<string>(); + for (const { name } of scenarios) { + if (seen.has(name)) throw new Error(`scenarios: duplicate name ${name}`); + seen.add(name); + } + return scenarios; +} + +/** Reads and checks the scenarios file at `file`. */ +export function loadScenarios(file: string, vars: Placeholders): Scenario[] { + return parseScenarios(JSON.parse(fs.readFileSync(file, 'utf8')), vars); +} diff --git a/scripts/scroll-bench/screencast.ts b/scripts/scroll-bench/screencast.ts new file mode 100644 index 0000000..506e3bb --- /dev/null +++ b/scripts/scroll-bench/screencast.ts @@ -0,0 +1,44 @@ +// Records what the page shows with CDP's screencast: every frame Chrome presents, as a +// JPEG with the time it arrived, acknowledged at once so the stream never stalls +// (service over Cdp). + +import { type Cdp, sleep } from './cdp'; + +/** One recorded frame: its arrival time (Date.now()) and its JPEG, base64. */ +export interface CastFrame { + time: number; + jpeg: string; +} + +/** The screencast's image settings. */ +export interface CastOptions { + quality: number; + maxWidth: number; + maxHeight: number; +} + +/** A running recording; `stop` ends it and returns its frames in arrival order. */ +export interface Recording { + stop(): Promise<CastFrame[]>; +} + +/** Time for the screencast to deliver its first frame before the input starts. */ +const WARM_UP_MS = 300; + +/** Starts recording the page and resolves once the screencast has warmed up. */ +export async function startRecording(cdp: Cdp, options: CastOptions): Promise<Recording> { + const frames: CastFrame[] = []; + const off = cdp.on('Page.screencastFrame', (params) => { + frames.push({ time: Date.now(), jpeg: String(params.data) }); + void cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }); + }); + await cdp.send('Page.startScreencast', { format: 'jpeg', everyNthFrame: 1, ...options }); + await sleep(WARM_UP_MS); + return { + async stop() { + await cdp.send('Page.stopScreencast'); + off(); + return frames; + }, + }; +} diff --git a/scripts/scroll-bench/session-api.test.ts b/scripts/scroll-bench/session-api.test.ts new file mode 100644 index 0000000..9cf3571 --- /dev/null +++ b/scripts/scroll-bench/session-api.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test'; +import { benchSessionId } from './session-api'; + +describe('benchSessionId', () => { + test('a slug of the parts and the time, valid as a session ID', () => { + const id = benchSessionId(['after', 'claude-classic', 'wheel', 2], 36 ** 3); + expect(id).toBe('bench-after-claude-classic-wheel-2-1000'); + expect(id).toMatch(/^[a-z0-9\-_.]{1,64}$/); + }); + + test('folds labels that are not ID characters and stays short', () => { + const id = benchSessionId(['localhost:3001', 'X'.repeat(80)], 0); + expect(id.startsWith('bench-localhost-3001-x')).toBe(true); + expect(id.length).toBeLessThanOrEqual(64); + }); +}); diff --git a/scripts/scroll-bench/session-api.ts b/scripts/scroll-bench/session-api.ts new file mode 100644 index 0000000..727a689 --- /dev/null +++ b/scripts/scroll-bench/session-api.ts @@ -0,0 +1,47 @@ +// The webtty server calls the benchmark makes: create a fresh session for one run, the +// page URL that opens it, and delete it afterwards (POST /api/sessions, GET /s/:id and +// DELETE /api/sessions/:id, ADR 062; service over Bun's global fetch). + +/** A session the benchmark created on one server. */ +export interface BenchSession { + base: string; + id: string; + /** The page that opens the session in a browser tab. */ + url: string; +} + +/** A session ID for one run: valid for the server (`[a-z0-9-_.]`) and unlikely to clash. */ +export function benchSessionId(parts: readonly (string | number)[], now = Date.now()): string { + const slug = parts + .map((p) => + String(p) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-'), + ) + .join('-') + .slice(0, 48); + return `bench-${slug}-${now.toString(36)}`.replace(/-+/g, '-'); +} + +/** Creates the session `id` on the server at `base`. */ +export async function createSession(base: string, id: string): Promise<BenchSession> { + const res = await fetch(`${base}/api/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (res.status !== 201) { + throw new Error(`POST ${base}/api/sessions: ${res.status} ${await res.text()}`); + } + return { base, id, url: `${base}/s/${encodeURIComponent(id)}` }; +} + +/** Deletes the session, ending its shell and whatever runs in it; a missing one is fine. */ +export async function deleteSession(session: BenchSession): Promise<void> { + const res = await fetch(`${session.base}/api/sessions/${encodeURIComponent(session.id)}`, { + method: 'DELETE', + }); + if (res.status !== 204 && res.status !== 404) { + throw new Error(`DELETE ${session.url}: ${res.status}`); + } +} diff --git a/scripts/scroll-bench/stream.sh b/scripts/scroll-bench/stream.sh new file mode 100755 index 0000000..4cf9215 --- /dev/null +++ b/scripts/scroll-bench/stream.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Streams output on the normal screen for the scroll benchmark's stream test: numbered +# lines at a steady rate, with an inverse-video status bar always on the last line. The +# bar is the only bright full-width row, so a recorded frame that shows it is at the +# bottom of the output, and one that does not is scrolled back (metrics.ts atBottom). +# +# bash scripts/scroll-bench/stream.sh [lines-per-second] [seconds] (default 40, 120) + +rate=${1:-40} +seconds=${2:-120} +cols=$(tput cols 2>/dev/null || echo 80) +delay=$(awk "BEGIN { printf \"%.4f\", 1 / $rate }") +total=$((rate * seconds)) + +bar() { + printf '\e[7m%-*s\e[0m' "$cols" " stream $1/$total ${rate} lines/s" +} + +for ((i = 1; i <= total; i++)); do + # Replace the bar with the next line, then draw the bar below it again. + printf '\r\e[2K%06d the quick brown fox jumps over the lazy dog %s\n' "$i" "$((i * 7919 % 100000))" + bar "$i" + sleep "$delay" +done +printf '\r\e[2K' diff --git a/scripts/scroll-bench/summary.test.ts b/scripts/scroll-bench/summary.test.ts new file mode 100644 index 0000000..677c3e2 --- /dev/null +++ b/scripts/scroll-bench/summary.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { + type Result, + resultLine, + type ScrollResult, + type StreamResult, + summaryTable, +} from './summary'; + +const scroll = (base: string, run: number, blinks: number, paintsPerSec: number): ScrollResult => ({ + kind: 'scroll', + base, + scenario: 'seq', + screen: 'normal', + input: 'trackpad', + direction: 'up', + run, + frames: 140, + blinks, + paintsPerSec, + gapMedian: 16, + gapP90: 17, + avgStepPx: 2.5, +}); + +const stream = (run: number, atBottomAtRest: number): StreamResult => ({ + kind: 'stream', + base: 'after', + scenario: 'stream', + screen: 'normal', + input: 'wheel', + direction: 'up', + run, + framesDuringScroll: 90, + atBottomDuringScroll: 2, + framesAtRest: 80, + atBottomAtRest, + restFramesThatMoved: 0, +}); + +describe('resultLine', () => { + test('is one line of JSON holding the whole result', () => { + const line = resultLine(scroll('after', 1, 0, 60)); + expect(line).not.toContain('\n'); + expect(JSON.parse(line)).toEqual(scroll('after', 1, 0, 60)); + }); +}); + +describe('summaryTable', () => { + test('one row per scenario, input and base, with the medians over runs', () => { + const results: Result[] = [ + scroll('before', 1, 4, 30), + scroll('after', 1, 0, 60), + scroll('before', 2, 6, 31), + scroll('after', 2, 1, 59.95), + stream(1, 0), + stream(2, 3), + ]; + expect(summaryTable(results)).toBe( + [ + '### Scroll (median over runs)', + '', + '| scenario | input | base | runs | blinks | frames | paints/s | gap p50 ms | gap p90 ms | step px |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + '| seq | trackpad | before | 2 | 5 | 140 | 30.5 | 16 | 17 | 2.5 |', + '| seq | trackpad | after | 2 | 0.5 | 140 | 60 | 16 | 17 | 2.5 |', + '', + '### Stream (median over runs)', + '', + '| scenario | input | base | runs | at bottom while scrolling | frames while scrolling | at bottom at rest | frames at rest | moved at rest |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- |', + '| stream | wheel | after | 2 | 2 | 90 | 1.5 | 80 | 0 |', + ].join('\n'), + ); + }); + + test('says so when there are no results', () => { + expect(summaryTable([])).toBe('No results.'); + }); +}); diff --git a/scripts/scroll-bench/summary.ts b/scripts/scroll-bench/summary.ts new file mode 100644 index 0000000..a30f426 --- /dev/null +++ b/scripts/scroll-bench/summary.ts @@ -0,0 +1,98 @@ +// The benchmark's output: one result per (base, scenario, input, run), printed as a JSON +// line, and the markdown summary table of each kind's medians over the runs (util). + +import type { Direction, InputName } from './inputs'; +import { median, type PaintStats, type StreamStats } from './metrics'; + +/** What every result names: where and how it was measured. */ +export interface ResultKey { + /** The server's label, from `--base label=url`. */ + base: string; + scenario: string; + screen: 'alt' | 'normal'; + input: InputName; + direction: Direction; + run: number; +} + +/** A scroll scenario's measures: frames recorded, blinks among them, paints and step. */ +export interface ScrollResult extends ResultKey, PaintStats { + kind: 'scroll'; + frames: number; + blinks: number; + avgStepPx: number; +} + +/** A streaming scenario's measures (metrics.ts streamStats). */ +export interface StreamResult extends ResultKey, StreamStats { + kind: 'stream'; +} + +export type Result = ScrollResult | StreamResult; + +/** One summary column: its heading and the result field it takes the median of. */ +interface Column<R> { + heading: string; + field: keyof R & string; +} + +const SCROLL_COLUMNS: Column<ScrollResult>[] = [ + { heading: 'blinks', field: 'blinks' }, + { heading: 'frames', field: 'frames' }, + { heading: 'paints/s', field: 'paintsPerSec' }, + { heading: 'gap p50 ms', field: 'gapMedian' }, + { heading: 'gap p90 ms', field: 'gapP90' }, + { heading: 'step px', field: 'avgStepPx' }, +]; + +const STREAM_COLUMNS: Column<StreamResult>[] = [ + { heading: 'at bottom while scrolling', field: 'atBottomDuringScroll' }, + { heading: 'frames while scrolling', field: 'framesDuringScroll' }, + { heading: 'at bottom at rest', field: 'atBottomAtRest' }, + { heading: 'frames at rest', field: 'framesAtRest' }, + { heading: 'moved at rest', field: 'restFramesThatMoved' }, +]; + +/** A result as the one-line JSON the benchmark prints. */ +export function resultLine(result: Result): string { + return JSON.stringify(result); +} + +/** Rounds a median for the table: whole numbers stay whole, others keep one decimal. */ +function cell(value: number): string { + return String(Math.round(value * 10) / 10); +} + +function table<R extends Result>(title: string, results: readonly R[], columns: Column<R>[]) { + const groups = new Map<string, R[]>(); + for (const r of results) { + const key = `${r.scenario}\u0000${r.input}\u0000${r.base}`; + groups.set(key, [...(groups.get(key) ?? []), r]); + } + const head = ['scenario', 'input', 'base', 'runs', ...columns.map((c) => c.heading)]; + const lines = [ + `### ${title}`, + '', + `| ${head.join(' | ')} |`, + `|${head.map(() => ' --- ').join('|')}|`, + ]; + for (const group of groups.values()) { + const { scenario, input, base } = group[0]; + const medians = columns.map((c) => cell(median(group.map((r) => r[c.field] as number)))); + lines.push(`| ${[scenario, input, base, group.length, ...medians].join(' | ')} |`); + } + return lines.join('\n'); +} + +/** + * The markdown summary: a table per kind that has results, one row per scenario, input + * and base in the order they first came, each measure the median over the runs. + */ +export function summaryTable(results: readonly Result[]): string { + const scroll = results.filter((r): r is ScrollResult => r.kind === 'scroll'); + const stream = results.filter((r): r is StreamResult => r.kind === 'stream'); + const parts: string[] = []; + if (scroll.length) parts.push(table('Scroll (median over runs)', scroll, SCROLL_COLUMNS)); + if (stream.length) parts.push(table('Stream (median over runs)', stream, STREAM_COLUMNS)); + return parts.length ? parts.join('\n\n') : 'No results.'; +} From 3a99e2899c77c080c16f07b532384da367ce1537 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 08:02:14 -0400 Subject: [PATCH 283/295] fix(client): hold painting during an app's synchronized update, timed and ended as Ghostty does Every CSI ? 2026 h restarts a one-second timer (Ghostty's sync_reset_ms) and its expiry turns mode 2026 off in the engine, so DECRQM, the echo render and the next update agree. The render loop, the echo and the kitty overlay ask one SyncHold; the write pipeline only reports what it wrote (ADR 067). --- docs/adrs/067.client.synchronized-output.md | 41 ++++ docs/specs/architecture.md | 3 +- docs/specs/client.md | 3 +- .../features/kitty/graphics/kitty-terminal.ts | 2 + .../kitty/graphics/overlay-canvas.test.ts | 23 +++ .../features/kitty/graphics/overlay-canvas.ts | 8 +- src/client/vendor/ghostty-web/PROVENANCE.md | 2 + .../vendor/ghostty-web/core/host-types.ts | 17 +- .../vendor/ghostty-web/core/render-loop.ts | 8 +- .../vendor/ghostty-web/core/sync-hold.ts | 69 +++++++ .../vendor/ghostty-web/core/sync-scan.ts | 24 +++ .../ghostty-web/core/terminal-controllers.ts | 37 +++- .../vendor/ghostty-web/core/write-pipeline.ts | 38 ++-- .../ghostty-web/engine/engine-config.ts | 7 +- .../ghostty-web/engine/engine-context.ts | 14 +- .../vendor/ghostty-web/engine/engine-types.ts | 2 + .../ghostty-web/engine/official-terminal.ts | 4 + src/client/vendor/ghostty-web/terminal.ts | 14 +- .../vendor/tests/core/render-loop.test.ts | 14 +- .../vendor/tests/core/sync-hold.test.ts | 183 ++++++++++++++++++ .../vendor/tests/core/sync-scan.test.ts | 42 ++++ .../tests/core/terminal-controllers.test.ts | 44 ++++- .../vendor/tests/core/write-pipeline.test.ts | 25 ++- .../vendor/tests/support/terminal-harness.ts | 16 +- 24 files changed, 585 insertions(+), 55 deletions(-) create mode 100644 docs/adrs/067.client.synchronized-output.md create mode 100644 src/client/vendor/ghostty-web/core/sync-hold.ts create mode 100644 src/client/vendor/ghostty-web/core/sync-scan.ts create mode 100644 src/client/vendor/tests/core/sync-hold.test.ts create mode 100644 src/client/vendor/tests/core/sync-scan.test.ts diff --git a/docs/adrs/067.client.synchronized-output.md b/docs/adrs/067.client.synchronized-output.md new file mode 100644 index 0000000..05dca87 --- /dev/null +++ b/docs/adrs/067.client.synchronized-output.md @@ -0,0 +1,41 @@ +# ADR 067: client — Hold painting during an app's synchronized update + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +Scrolling through a pi session made the text and the images blink. Measured in a headless Chromium on a pi session with 277 messages and an image, recording every compositor frame during a slow trackpad scroll: + +- pi tracks the mouse and scrolls its own view. On every wheel tick it redraws the whole screen in one synchronized update (DEC mode 2026, `CSI ? 2026 h` … `CSI ? 2026 l`): it deletes every kitty image placement (`a=d,d=a`), clears and rewrites each row, then places the images again. +- One update is about 13 KB and reaches the browser as about 14 WebSocket messages. The browser can paint between any two of them. +- webtty's render loop painted every frame regardless of mode 2026, and the kitty overlay repainted on the frame after any change. A frame between two messages showed the rows half cleared and the images gone: 12 of 40 frames matched no clean scroll of the frame before. + +libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's renderer does not paint while the mode is on. Its IO thread starts a timer on every `CSI ? 2026 h`, restarting it if one runs (`termio/Thread.zig`, `sync_reset_ms = 1000`), and when the timer fires it turns the mode off (`Termio.resetSynchronizedOutput`) and paints. An app that never ends an update therefore freezes the view for a second, and after that the mode is really off: a DECRQM for 2026 reports it reset, and the next update starts from scratch. + +## Decision + +- `SyncHold` (`core/sync-hold.ts`) holds painting while the engine's mode 2026 is on, for at most `SYNC_HOLD_MAX_MS` (1000 ms) after the update's last begin. +- **Every begin restarts the time**, as in Ghostty. The write pipeline hands each write's output to `SyncHold.wrote`, which looks for `CSI ? 2026 h` with `scanSyncBegin` (`core/sync-scan.ts`): alone or among other modes (`CSI ? 25;2026 h`), and split across writes, an unfinished sequence carried to the next one. A repeated begin inside an update, or an end and a new begin in one write, starts a new second. A mode turned on in a form the scan does not read (a C1 CSI) is timed from the first frame that sees it. +- **When the time runs out, the mode is turned off** in the engine (`GhosttyTerminal.setMode(2026, false)`, `GHOSTTY_TERMINAL_OPT_MODE`), as Ghostty's `resetSynchronizedOutput` does, so DECRQM, the echo and the next update all see it off. +- **One decision for every painter.** The render loop skips only the render of a held frame: `beforeRender`, the find step, the reply drain and the textarea anchoring still run, so replies are not delayed and a glide keeps its time. The echo render after typed input asks the same `SyncHold`, so it waits too, and renders at once again after the update ends or expires. `Terminal.renderHeld(now)` lets the embedder's own painting follow the same rule: the kitty overlay skips its repaint on a held frame and keeps its dirty state, so it repaints on the first frame after the update. +- `Terminal.reset()` (a reconnect replaces the engine before the replay) forgets the update and any unfinished begin. A replay that ends inside an update holds a second at most, like any other. +- Input devices need nothing of their own: wheel ticks to an app are paced by `TickPacer` (ADR 045), not by painting, and a held frame only batches the app's redraws into the frame after the update. + +## Considered Options + +- **Buffer the output on the client until the update ends, then write it.** Rejected: the engine already applies the update correctly; only painting it half way is wrong, and buffering would delay replies the app waits for inside the update. +- **Hold only the kitty overlay.** Rejected: the text blinks too, since its rows are cleared and rewritten across messages. +- **Coalesce WebSocket messages on the server.** Rejected: messages split anywhere, and the browser can still paint between two of them. +- **Time the update from the first frame that sees the mode on, and never touch the mode.** The first version did. Rejected: a repeated begin did not restart the second, so back-to-back updates in one stream were cut short, and a stuck mode stayed on in the engine, so the echo, which read the mode directly, never rendered at once again. +- **Feed `CSI ? 2026 l` to the engine on expiry.** Rejected: it would go through the parser in the middle of whatever sequence the output left unfinished; the mode setter changes only the mode. + +## Consequences + +- Measured again on the same session: 0 of 31 frames scrolling up and 0 of 30 scrolling down were torn, with pi sending the same traffic. +- Any app using synchronized output (pi, Claude Code, neovim, tmux) is painted only between updates, as in Ghostty. +- An app that turns the mode on and never off freezes painting for a second after its last begin; then the mode is off until the app begins another update. +- Each write is scanned for `2026`; output without it costs one `includes` and a look at its last escape. diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index cc43132..c016519 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -1,6 +1,6 @@ # SPEC: Architecture -**Last Updated:** 2026-10-14 (the tree reconciled with the code after the refactor: the ratchet allowlist empty, the coverage targets enforced) +**Last Updated:** 2026-10-15 (`core/sync-hold.ts` and `core/sync-scan.ts`, ADR 067) --- @@ -212,6 +212,7 @@ ghostty-web/ host-types.ts terminal-constants.ts terminal-geometry.ts options-proxy.ts option-changes.ts dom-setup.ts opened-parts.ts (OpenedParts, disposeParts) terminal-emitters.ts viewport.ts focus-tracker.ts find-controller.ts write-pipeline.ts render-loop.ts + sync-hold.ts (painting held during an app's synchronized update) sync-scan.ts mouse-controller.ts wheel-controller.ts scrollbar-controller.ts terminal-controllers.ts (the controllers above made and wired in one place, the input route) render/ renderable.ts default-theme.ts device-pixel-ratio.ts frame-lines.ts cell-range.ts row-band.ts diff --git a/docs/specs/client.md b/docs/specs/client.md index 5ab64d1..1ae1789 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,6 +1,6 @@ # SPEC: Client -**Last Updated:** 2026-10-14 +**Last Updated:** 2026-10-15 --- @@ -393,3 +393,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Configurable padding | Top-level `padding` (px, default `0`) reserves space around the canvas on all sides; the grid is fitted to what remains; the top and left gap is exactly `padding` and the leftover goes to the right and bottom; optional `theme.padding` colours the space `padding` adds (default: `theme.background`) but not the leftover | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Edge extension | The leftover strip at the right and bottom takes the edge cells' own background, by repainting the canvas' last pixel row and column across it; always on, no setting | [ADR 035](../adrs/035.client.edge-extend.md) | ✅ | | Theme key mapping | The config's `purple`, `brightPurple` and `selection` are translated to the terminal's `magenta`, `brightMagenta` and `selectionBackground`, which were previously ignored; `selectionForeground` is new and defaults to `background` when `selection` is set | [ADR 036](../adrs/036.client.theme-key-mapping.md) | ✅ | +| Synchronized output | While the app sends a synchronized update (DEC mode 2026), the terminal, the echo of typed input and the kitty overlay do not paint, so an app that redraws the whole screen across several WebSocket messages is never shown half drawn. As in Ghostty, every `CSI ? 2026 h` restarts a one-second timer, and when it runs out the mode is turned off, so an app that never ends an update cannot freeze the view | [ADR 067](../adrs/067.client.synchronized-output.md) | ✅ | diff --git a/src/client/features/kitty/graphics/kitty-terminal.ts b/src/client/features/kitty/graphics/kitty-terminal.ts index e92831d..601ea2d 100644 --- a/src/client/features/kitty/graphics/kitty-terminal.ts +++ b/src/client/features/kitty/graphics/kitty-terminal.ts @@ -15,6 +15,8 @@ export interface KittyTerminal { readonly scrollOffset: number; }; readonly wasmTerm?: { isAlternateScreen(): boolean; getScrollbackLength(): number }; + /** Whether painting waits for the app's synchronized update to end (mode 2026, ADR 067). */ + renderHeld?(now: number): boolean; } /** A cell's size in CSS pixels, with ghostty-web's defaults before the renderer exists. */ diff --git a/src/client/features/kitty/graphics/overlay-canvas.test.ts b/src/client/features/kitty/graphics/overlay-canvas.test.ts index 1363c0e..e181ad9 100644 --- a/src/client/features/kitty/graphics/overlay-canvas.test.ts +++ b/src/client/features/kitty/graphics/overlay-canvas.test.ts @@ -31,11 +31,16 @@ function setup(withTerminalCanvas = true) { } container.append(canvas); } + const sync = { held: false, at: [] as number[] }; const term = { write: () => {}, buffer: { active: { cursorX: 0, cursorY: 0 } }, viewportY: 0, renderer: { getMetrics: () => ({ width: 10, height: 20 }), scrollOffset: 0 }, + renderHeld: (now: number) => { + sync.at.push(now); + return sync.held; + }, } satisfies KittyTerminal; const placements = new Placements(); const bitmaps = new Map<number, ImageBitmap>(); @@ -71,6 +76,7 @@ function setup(withTerminalCanvas = true) { placements.add(placementAt({}, imageId, { absRow, col }, { x: 2, y: 2 })); return { term, + sync, container, placements, bitmaps, @@ -143,6 +149,23 @@ describe('OverlayCanvas', () => { expect(s.canvas.width).toBe(800); }); + test("holds its repaints while the app's synchronized update runs, then catches up (ADR 067)", () => { + const s = setup(); + s.bitmaps.set(1, fakeBitmap(4, 4)); + s.place(1); + s.sync.held = true; + s.clock.frame(); + s.overlay.markDirty(); + s.clock.frame(); + expect(s.repaints()).toBe(0); + // Each frame asks with its own timestamp. + expect(s.sync.at).toHaveLength(2); + expect(s.sync.at[1]).toBeGreaterThan(s.sync.at[0]); + s.sync.held = false; + s.clock.frame(); + expect([s.repaints(), s.draws.length]).toEqual([1, 1]); + }); + test('a tab coming back to the foreground repaints; hiding it does not', () => { const s = setup(); s.place(1); diff --git a/src/client/features/kitty/graphics/overlay-canvas.ts b/src/client/features/kitty/graphics/overlay-canvas.ts index 8e6b211..4226fd1 100644 --- a/src/client/features/kitty/graphics/overlay-canvas.ts +++ b/src/client/features/kitty/graphics/overlay-canvas.ts @@ -1,6 +1,7 @@ // The canvas kitty images are drawn on, laid over the terminal's own (view, ADR 032): it // repaints on the frame after anything changed, follows the terminal's scroll, size and -// pixel ratio, and frees its backing store while there is nothing to draw. +// pixel ratio, holds while the app sends a synchronized update (ADR 067), and frees its +// backing store while there is nothing to draw. import { TERMINAL_CANVAS_UNDER_OVERLAY, terminalCanvas } from '../../../layout/terminal-canvas'; import { DisposableStore, listen, toDisposable } from '../../../vendor/ghostty-web'; @@ -80,9 +81,12 @@ export class OverlayCanvas { this.store.dispose(); } - private readonly tick = (): void => { + private readonly tick = (now: number): void => { this.frame = this.deps.window.requestAnimationFrame(this.tick); if (this.source.placements.size === 0 && !this.canvas.width) return; + // An app that redraws its images deletes every placement and places them again in + // one synchronized update; painting in between would blink them (ADR 067). + if (this.term.renderHeld?.(now)) return; const canvas = terminalCanvas(this.container, TERMINAL_CANVAS_UNDER_OVERLAY); if (!canvas) return; const cell = cellSize(this.term); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 746ae09..da9d1c8 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -64,6 +64,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). - `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). - `providers/osc8-link-provider.ts`: `provideLinks` scans the row and a private `linkAt` reads one link's run; the scan skips past each run instead of keeping a set of visited columns, which visits the same cells in the same order and returns the same links (ADR 061). +- `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067). - `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). ## webtty-original files @@ -74,6 +75,7 @@ Not from upstream; each starts with `// webtty (ADR NNN), not from upstream.` (` - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. - `core/host-types.ts` (ADR 061): the narrow interfaces `Terminal`'s parts and `SelectionManager` see of their host, where upstream reached into `Terminal` through `any`. - `core/focus-tracker.ts` (ADR 053), `core/find-controller.ts` (ADR 055): `Terminal`'s focus reports and find, which webtty added, moved out of `terminal.ts` (ADR 061). +- `core/sync-hold.ts`, `core/sync-scan.ts` (ADR 067): the hold on painting while the app sends a synchronized update (DEC mode 2026), which upstream's render loop did not honour, and the scanner that finds where an update begins. - `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts`, `render/row-repainter.ts`, `render/scroll-tracker.ts` (ADR 043), `render/decoration-tracker.ts` (ADR 043, ADR 055), `render/viewport-clip.ts` (ADR 040, ADR 043): renderer parts webtty added, moved out of `renderer.ts` (ADR 061). - `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043), `engine/grid-refs.ts` (ADR 042, ADR 050), `engine/selection-binding.ts` (ADR 050, ADR 057), `engine/search-binding.ts` (ADR 055), `engine/mouse-encoder-binding.ts` (ADR 049), `engine/paste-focus-binding.ts` (ADR 049, ADR 053), `engine/engine-effects.ts` (ADR 042, ADR 052, ADR 056), `engine/engine-config.ts` (ADR 042, ADR 046, ADR 049): webtty's binding to the official wasm, moved out of `ghostty.ts` and split by feature (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index 8ac416d..83c4588 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -147,10 +147,23 @@ export interface WriteHost { /** Whether the view is kept on a selected find match (FindController.anchored). */ findAnchored(): boolean; scrollToBottom(): void; - /** Renders now, if the terminal is open, so typed input's echo shows without a frame's wait. */ + /** The output just written, which may begin a synchronized update (SyncHold.wrote). */ + wroteOutput(data: string | Uint8Array): void; + /** + * Renders now, if the terminal is open and no synchronized update holds painting, so + * typed input's echo shows without a frame's wait. + */ renderEcho(): void; } +/** What the SyncHold reads and changes of the engine's mode 2026; nothing before open(). */ +export interface SyncHoldHost { + /** Whether mode 2026 is on. */ + active(): boolean; + /** Turns mode 2026 off, as Ghostty does when an update runs past its time. */ + end(): void; +} + /** The steps of one render-loop frame, called in the order the RenderLoop fixes. */ export interface RenderLoopHost { /** Open and not disposed: the loop stops at the first frame where this is false. */ @@ -158,6 +171,8 @@ export interface RenderLoopHost { /** The embedder's callback (attachBeforeRender), so an animation shows in this frame. */ beforeRender(now: number): void; stepFind(now: number): void; + /** Whether this frame's paint waits for the app's synchronized update to end (SyncHold). */ + renderHeld(now: number): boolean; render(): void; /** Whether replies arose outside a write (a timer, a mode change) and wait for the PTY. */ hasReplies(): boolean; diff --git a/src/client/vendor/ghostty-web/core/render-loop.ts b/src/client/vendor/ghostty-web/core/render-loop.ts index b426840..4f7c0cc 100644 --- a/src/client/vendor/ghostty-web/core/render-loop.ts +++ b/src/client/vendor/ghostty-web/core/render-loop.ts @@ -1,7 +1,8 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The render loop (controller): one animation frame after another while the terminal is // open, each running beforeRender, a find step, the render, the replies that arose outside -// a write and the textarea anchoring, in that order (ADR 042, ADR 045, ADR 055, ADR 061). +// a write and the textarea anchoring, in that order (ADR 042, ADR 045, ADR 055, ADR 061, +// ADR 067). import type { FrameScheduler, RenderLoopHost } from './host-types'; @@ -53,8 +54,9 @@ export class RenderLoop { host.beforeRender(now ?? this.now()); host.stepFind(now ?? this.now()); // Render with the engine's dirty tracking: it updates the render state once, - // redraws only dirty rows and clears the dirty flags at the end - host.render(); + // redraws only dirty rows and clears the dirty flags at the end. Not while the app + // sends a synchronized update: the screen would show it half drawn (webtty, ADR 067) + if (!host.renderHeld(now ?? this.now())) host.render(); // Replies that arose outside write() reach the PTY within a frame (webtty, ADR 042) if (host.hasReplies()) host.drainReplies(); // Keep the input textarea on the cursor cell (webtty) diff --git a/src/client/vendor/ghostty-web/core/sync-hold.ts b/src/client/vendor/ghostty-web/core/sync-hold.ts new file mode 100644 index 0000000..ec2bf43 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/sync-hold.ts @@ -0,0 +1,69 @@ +// webtty (ADR 067), not from upstream. +// The hold on painting while the app updates the screen in a synchronized update (object): +// DEC mode 2026, which libghostty-vt tracks and Ghostty's renderer honours. An app that +// redraws the whole screen (pi on each wheel tick) sends it as one update, and the browser +// can paint between the WebSocket messages it arrives in; holding until the update ends +// keeps those half-drawn frames off the screen. The policy is Ghostty's (termio/Thread.zig +// `sync_reset_ms`, Termio.zig `resetSynchronizedOutput`): every begin restarts a 1 s +// timer, and when it runs out the mode is turned off, so an app that never ends an update +// cannot freeze the view, and DECRQM, the echo and the next update all see it off. + +import type { SyncHoldHost } from './host-types'; +import { scanSyncBegin } from './sync-scan'; + +/** Synchronized output: the app brackets an update with `CSI ? 2026 h` and `CSI ? 2026 l`. */ +export const SYNCHRONIZED_OUTPUT_MODE = 2026; + +/** How long after its last begin an update may hold painting: Ghostty's `sync_reset_ms`. */ +export const SYNC_HOLD_MAX_MS = 1000; + +/** Whether painting waits for the app's synchronized update to end. */ +export class SyncHold { + /** When the current update last began, or null outside one. */ + private since: number | null = null; + /** The unfinished mode sequence the last write ended in. */ + private carry = ''; + + /** `now` is the clock a write is timed on; a frame passes its own timestamp to held(). */ + constructor( + private readonly host: SyncHoldHost, + private readonly now: () => number = () => performance.now(), + ) {} + + /** + * Output was written to the engine: a begin in it, even one inside an update already + * running, restarts the update's time, as each `CSI ? 2026 h` restarts Ghostty's timer. + */ + wrote(data: string | Uint8Array): void { + const text = typeof data === 'string' ? data : LATIN1.decode(data); + const scan = scanSyncBegin(text, this.carry); + this.carry = scan.carry; + if (scan.begun) this.since = this.now(); + else if (!this.host.active()) this.since = null; + } + + /** + * Whether painting at `now` holds: inside an update that began under SYNC_HOLD_MAX_MS + * ago. Past that, the mode is turned off and painting goes on. + */ + held(now: number = this.now()): boolean { + if (!this.host.active()) { + this.since = null; + return false; + } + // An update begun in a form the scan does not read (a C1 CSI) is timed from its first frame + this.since ??= now; + if (now - this.since < SYNC_HOLD_MAX_MS) return true; + this.host.end(); + this.since = null; + return false; + } + + /** Forgets the update and any unfinished sequence: the engine was replaced. */ + reset(): void { + this.since = null; + this.carry = ''; + } +} + +const LATIN1 = new TextDecoder('latin1'); diff --git a/src/client/vendor/ghostty-web/core/sync-scan.ts b/src/client/vendor/ghostty-web/core/sync-scan.ts new file mode 100644 index 0000000..3ab54a0 --- /dev/null +++ b/src/client/vendor/ghostty-web/core/sync-scan.ts @@ -0,0 +1,24 @@ +// webtty (ADR 067), not from upstream. +// The synchronized-update scanner (util): finds where the app begins a synchronized update +// (`CSI ? 2026 h`, alone or among other modes) in output, carrying an unfinished sequence +// across output chunks, as the cursor blink scanner does (engine/cursor-blink.ts). + +/** A DEC private mode set whose modes include 2026. */ +const SYNC_BEGIN_SEQ = /\x1b\[\?(?:\d*;)*2026(?:;\d*)*h/; +/** The start of a DEC private mode set or reset, still open at the end of a chunk. */ +const MODE_SET_PREFIX = /^\x1b(?:\[(?:\?[\d;]*)?)?$/; +/** Longest tail kept as carry: a mode list longer than this is not an app's begin. */ +const MAX_CARRY = 32; + +/** + * Whether `data`, following the `carry` the previous chunk left, holds a begin of a + * synchronized update, and the unfinished sequence at its end to pass back as `carry` with + * the next chunk. Most chunks hold no `2026` and only their tail is looked at. + */ +export function scanSyncBegin(data: string, carry = ''): { begun: boolean; carry: string } { + const text = carry === '' ? data : carry + data; + const begun = text.includes('2026') && SYNC_BEGIN_SEQ.test(text); + const esc = text.lastIndexOf('\x1b'); + const tail = esc >= 0 && text.length - esc <= MAX_CARRY ? text.slice(esc) : ''; + return { begun, carry: MODE_SET_PREFIX.test(tail) ? tail : '' }; +} diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index b93a9f6..4173dcd 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -1,7 +1,8 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // Terminal's controllers, made and wired in one place (composition): focus, find, writes, -// the render loop, the viewport, the scrollbar, the mouse and the wheel, each reaching the -// terminal and the others only through the hosts given here (ADR 061). +// the render loop, the synchronized-update hold, the viewport, the scrollbar, the mouse and +// the wheel, each reaching the terminal and the others only through the hosts given here +// (ADR 061, ADR 067). import type { ListenerTarget } from '../lifecycle/disposable-store'; import type { RightClickBehavior } from '../mouse-route'; @@ -14,6 +15,7 @@ import type { MouseHost, RenderLoopHost, ScrollbarHost, + SyncHoldHost, TimeoutScheduler, WheelHost, WriteHost, @@ -25,6 +27,7 @@ import { ScrollbarController } from './scrollbar-controller'; import type { TerminalEmitters } from './terminal-emitters'; import { Viewport } from './viewport'; import { WheelController } from './wheel-controller'; +import { SYNCHRONIZED_OUTPUT_MODE, SyncHold } from './sync-hold'; import { WritePipeline } from './write-pipeline'; /** What the controllers read of Terminal's public surface, each read made when needed. */ @@ -86,12 +89,15 @@ export class TerminalControllers { readonly mouse: MouseController; /** Wheel events to the embedder, or to the app as ticks or arrows (ADR 045, ADR 049). */ readonly wheel: WheelController; + /** Painting held while the app sends a synchronized update (mode 2026, ADR 067). */ + readonly sync: SyncHold; constructor( private readonly term: ControllersHost, private readonly inner: ControllersInternalsHost, clock?: ControllersClock, ) { + this.sync = new SyncHold(syncHoldHost(inner), clock?.now); this.focus = new FocusTracker(focusHost(term, inner), clock); this.finder = new FindController(findHost(term, inner), clock?.now); this.writes = new WritePipeline(writeHost(term, inner, this), clock); @@ -123,6 +129,18 @@ export class TerminalControllers { this.inner.emit.data.fire(data); } + /** Terminal.reset() replaced the engine: what the controllers knew of the old one goes. */ + engineReplaced(): void { + // The new engine starts with mode 1004 off (webtty, ADR 053) + this.focus.reset(); + // A clear waiting for the old engine's output is not for the new one (webtty, ADR 054) + this.writes.reset(); + // Nor is its synchronized update (webtty, ADR 067) + this.sync.reset(); + // A search goes on in the new engine (webtty, ADR 055) + this.finder.restart(); + } + /** * Adds the listeners open() adds after its parts, in the order it always has: the * scrollbar press, the mouse's, the document's mouseup that ends a scrollbar drag, then @@ -201,7 +219,19 @@ export function writeHost(term: ControllersHost, inner: Inner, core: TerminalCon viewportY: () => term.viewportY, findAnchored: () => core.finder.anchored, scrollToBottom: () => term.scrollToBottom(), - renderEcho: () => inner.render(false), + wroteOutput: (data) => core.sync.wrote(data), + // Not inside a synchronized update: the frame after it ends paints (ADR 067) + renderEcho: () => { + if (!core.sync.held()) inner.render(false); + }, + }; +} + +/** The synchronized-update hold's view of the engine's mode 2026 (ADR 067). */ +export function syncHoldHost(inner: Inner): SyncHoldHost { + return { + active: () => inner.parts()?.engine.getMode(SYNCHRONIZED_OUTPUT_MODE) ?? false, + end: () => inner.parts()?.engine.setMode(SYNCHRONIZED_OUTPUT_MODE, false), }; } @@ -211,6 +241,7 @@ export function renderLoopHost(inner: Inner, core: TerminalControllers): RenderL live: () => inner.live(), beforeRender: (now) => inner.beforeRender(now), stepFind: (now) => core.finder.step(now), + renderHeld: (now) => core.sync.held(now), render: () => inner.render(false), hasReplies: () => inner.parts()?.engine.hasResponse() ?? false, drainReplies: () => core.writes.drainReplies(), diff --git a/src/client/vendor/ghostty-web/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts index bdb160d..e506528 100644 --- a/src/client/vendor/ghostty-web/core/write-pipeline.ts +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -1,8 +1,9 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The write pipeline (controller): a write in its fixed order, a waiting clear at ground, -// the engine write, the replies, the focus-mode edge, the effects, the link cache, the -// auto-scroll and the echo render, plus clear screen and the reply drain (ADR 042, ADR 053, -// ADR 054, ADR 055, ADR 061); dispose() cancels the write callbacks still waiting (ADR 065). +// the engine write, the synchronized update's timing, the replies, the focus-mode edge, the +// effects, the link cache, the auto-scroll and the echo render, plus clear screen and the +// reply drain (ADR 042, ADR 053, ADR 054, ADR 055, ADR 061, ADR 067); dispose() cancels the +// write callbacks still waiting (ADR 065). import type { FrameScheduler, WriteHost } from './host-types'; import { browserFrames } from './render-loop'; @@ -29,19 +30,10 @@ export class WritePipeline { write(data: string | Uint8Array, callback?: () => void): void { const host = this.host; const engine = host.engine()!; - // A waiting clear goes in where the output reaches ground (webtty, ADR 054) - if (this.pendingClear) { - const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; - const n = engine.writeUntilGround(bytes); - if (n !== null) { - this.pendingClear = false; - this.writeClear(); - } - data = bytes.subarray(n ?? bytes.length); - } + this.feed(data); - // The engine parses the VT stream - engine.write(data); + // A begin of the app's synchronized update restarts its time (webtty, ADR 067) + host.wroteOutput(data); // Replies the output asked for (DSR and the like) go back to the PTY this.drainReplies(); @@ -116,6 +108,22 @@ export class WritePipeline { this.callbackFrames.clear(); } + /** The engine parses the VT stream, a waiting clear going in where it reaches ground. */ + private feed(data: string | Uint8Array): void { + const engine = this.host.engine()!; + // A waiting clear goes in where the output reaches ground (webtty, ADR 054) + if (this.pendingClear) { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + const n = engine.writeUntilGround(bytes); + if (n !== null) { + this.pendingClear = false; + this.writeClear(); + } + data = bytes.subarray(n ?? bytes.length); + } + engine.write(data); + } + /** Runs a write's callback on the next frame, remembered until then so dispose() can cancel it. */ private runNextFrame(callback: () => void): void { const id = this.frames.requestAnimationFrame(() => { diff --git a/src/client/vendor/ghostty-web/engine/engine-config.ts b/src/client/vendor/ghostty-web/engine/engine-config.ts index b8308f8..32dbd85 100644 --- a/src/client/vendor/ghostty-web/engine/engine-config.ts +++ b/src/client/vendor/ghostty-web/engine/engine-config.ts @@ -146,11 +146,6 @@ export class EngineConfig { */ private syncBlink(): void { if (this.ctx.getMode(MODE_CURSOR_BLINK) === this.blink) return; - const { at, L } = this.ctx; - const M = L.mode; - const dv = this.ctx.dv(); - dv.setUint16(at.mode + M.mode, MODE_CURSOR_BLINK, true); - dv.setUint8(at.mode + M.value, this.blink ? 1 : 0); - this.ctx.setOption(L.opt.MODE, at.mode, 'MODE 12'); + this.ctx.setMode(MODE_CURSOR_BLINK, this.blink); } } diff --git a/src/client/vendor/ghostty-web/engine/engine-context.ts b/src/client/vendor/ghostty-web/engine/engine-context.ts index ae4f775..d5c88b7 100644 --- a/src/client/vendor/ghostty-web/engine/engine-context.ts +++ b/src/client/vendor/ghostty-web/engine/engine-context.ts @@ -1,7 +1,7 @@ -// webtty (ADR 042, ADR 061), not from upstream. +// webtty (ADR 042, ADR 061, ADR 067), not from upstream. // The state one terminal's bindings share (object): the wasm exports and layout, the // terminal handle, the scratch slots, the grid and cell pixel size, the content generation, -// the terminal info read once per generation, and the mode reads. Memory views are built +// the terminal info read once per generation, and the mode reads and sets. Memory views are built // per call, never stored. import type { EngineHost } from './engine-types'; @@ -125,6 +125,16 @@ export class EngineContext<K extends string = never> { if (rc !== 0) throw new Error(`libghostty-vt terminal_set ${what} failed: ${rc}`); } + /** Sets a DEC private mode as the app would (`GHOSTTY_TERMINAL_OPT_MODE`). */ + setMode(mode: number, on: boolean): void { + const at = this.at; + const M = this.L.mode; + const dv = this.dv(); + dv.setUint16(at.mode + M.mode, mode & 0x7fff, true); + dv.setUint8(at.mode + M.value, on ? 1 : 0); + this.setOption(this.L.opt.MODE, at.mode, `MODE ${mode}`); + } + /** Whether a DEC private (or, with `isAnsi`, an ANSI) mode is set. */ getMode(mode: number, isAnsi = false): boolean { const at = this.at; diff --git a/src/client/vendor/ghostty-web/engine/engine-types.ts b/src/client/vendor/ghostty-web/engine/engine-types.ts index 1dcd557..448d480 100644 --- a/src/client/vendor/ghostty-web/engine/engine-types.ts +++ b/src/client/vendor/ghostty-web/engine/engine-types.ts @@ -174,6 +174,8 @@ export interface GhosttyTerminal { isAlternateScreen(): boolean; hasMouseTracking(): boolean; getMode(mode: number, isAnsi?: boolean): boolean; + /** Sets a DEC private mode, as the app's `CSI ? Pm h` / `l` would. (ADR 067) */ + setMode(mode: number, on: boolean): void; /** * Whether the cursor is at a shell prompt or its input, from OSC 133 marks; false * without them and on the alternate screen. (ADR 054) diff --git a/src/client/vendor/ghostty-web/engine/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts index c2a0595..7f0837b 100644 --- a/src/client/vendor/ghostty-web/engine/official-terminal.ts +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -325,6 +325,10 @@ export class OfficialTerminal implements GhosttyTerminal { return this.ctx.getMode(mode, isAnsi); } + setMode(mode: number, on: boolean): void { + this.ctx.setMode(mode, on); + } + /** A one-byte bool terminal data item; false if Ghostty refuses the read. */ private getFlag(data: number): boolean { const at = this.ctx.at; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index 05f6f58..aaba9a2 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -161,6 +161,11 @@ export class Terminal implements ITerminalCore { return this.parts?.renderer; } + /** Whether painting at `now` waits for the app's synchronized update (mode 2026, ADR 067). */ + renderHeld(now: number): boolean { + return this.core.sync.held(now); + } + /** * Lines scrolled back from the bottom of the scrollback (0 = at the bottom); fractional * while smooth scrolling. Setting it moves the view without firing onScroll. @@ -395,7 +400,7 @@ export class Terminal implements ITerminalCore { return this.renderer ? cellDevicePixels(this.renderer.getMetrics()) : null; } - /** Starts over on a new engine: the title, focus reports and a waiting clear reset. */ + /** Starts over on a new engine: the title and the controllers' engine state reset. */ reset(): void { const parts = this.openParts(); parts.engine.free(); @@ -403,12 +408,7 @@ export class Terminal implements ITerminalCore { this.syncCellPixelSize(); parts.renderer.clear(); this.currentTitle = ''; - // The new engine starts with mode 1004 off (webtty, ADR 053) - this.core.focus.reset(); - // A clear waiting for the old engine's output is not for the new one (webtty, ADR 054) - this.core.writes.reset(); - // A search goes on in the new engine (webtty, ADR 055) - this.core.finder.restart(); + this.core.engineReplaced(); } /** diff --git a/src/client/vendor/tests/core/render-loop.test.ts b/src/client/vendor/tests/core/render-loop.test.ts index e1b93e8..b7128d5 100644 --- a/src/client/vendor/tests/core/render-loop.test.ts +++ b/src/client/vendor/tests/core/render-loop.test.ts @@ -10,11 +10,12 @@ import { RenderLoop } from '../../ghostty-web/core/render-loop'; function loop(start = 100) { const clock = new ManualClock(start); const calls: string[] = []; - const state = { live: true, replies: false }; + const state = { live: true, replies: false, held: false }; const host: RenderLoopHost = { live: () => state.live, beforeRender: (now) => calls.push(`beforeRender ${now}`), stepFind: (now) => calls.push(`stepFind ${now}`), + renderHeld: () => state.held, render: () => calls.push('render'), hasReplies: () => state.replies, drainReplies: () => calls.push('drainReplies'), @@ -54,6 +55,17 @@ describe('RenderLoop', () => { ]); }); + test("a frame inside the app's synchronized update skips only the render (ADR 067)", () => { + const { calls, clock, state, renderLoop } = loop(0); + renderLoop.start(); + calls.length = 0; + state.held = true; + state.replies = true; + clock.frame(16); + expect(calls).toEqual(['beforeRender 16', 'stepFind 16', 'drainReplies', 'anchorTextarea']); + expect(clock.pending.frames).toBe(1); + }); + test('start while running does nothing', () => { const { calls, clock, renderLoop } = loop(); renderLoop.start(); diff --git a/src/client/vendor/tests/core/sync-hold.test.ts b/src/client/vendor/tests/core/sync-hold.test.ts new file mode 100644 index 0000000..a251b6e --- /dev/null +++ b/src/client/vendor/tests/core/sync-hold.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, spyOn, test } from 'bun:test'; +import { SYNC_HOLD_MAX_MS, SyncHold } from '../../ghostty-web/core/sync-hold'; +import { engineOf, openTerminal, rendererOf } from '../support/terminal-harness'; + +// The hold on painting during a synchronized update (DEC mode 2026, ADR 067): its timing +// on a stand-in mode, and the real engine's mode through Terminal.renderHeld. + +/** A hold over a stand-in mode 2026 that the writes below turn on and off. */ +function standIn() { + const mode = { on: false, ends: 0 }; + let now = 0; + const hold = new SyncHold( + { + active: () => mode.on, + end: () => { + mode.on = false; + mode.ends++; + }, + }, + () => now, + ); + /** Writes `data` at `at` ms, the mode following its last 2026 set or reset. */ + const write = (at: number, data: string) => { + now = at; + const last = Math.max(data.lastIndexOf('2026h'), data.lastIndexOf('2026l')); + if (last >= 0) mode.on = data[last + 4] === 'h'; + hold.wrote(data); + }; + return { mode, hold, write }; +} + +/** A printable key as the browser delivers it to the container. */ +function keydown(key: string) { + return { + type: 'keydown', + key, + code: `Key${key.toUpperCase()}`, + keyCode: key.toUpperCase().charCodeAt(0), + isComposing: false, + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + preventDefault: () => {}, + }; +} + +const BEGIN = '\x1b[?2026h'; +const END = '\x1b[?2026l'; + +describe('SyncHold', () => { + test('holds from a begin until the end, for at most SYNC_HOLD_MAX_MS', () => { + const { hold, write } = standIn(); + expect(hold.held(0)).toBe(false); + write(100, `${BEGIN}half`); + expect(hold.held(100 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + write(200, `drawn${END}`); + expect(hold.held(201)).toBe(false); + }); + + test('back-to-back updates each get their own time, even in one write', () => { + const { hold, write } = standIn(); + write(0, `${BEGIN}one${END}${BEGIN}two`); + expect(hold.held(SYNC_HOLD_MAX_MS - 1)).toBe(true); + write(900, `${END}${BEGIN}three`); + // Timed from the second begin at 900, not the first at 0 + expect(hold.held(900 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + }); + + test('every begin restarts the time, as in Ghostty, a repeated one inside an update too', () => { + const { hold, write } = standIn(); + write(0, BEGIN); + write(800, `more${BEGIN}`); + expect(hold.held(1500)).toBe(true); + expect(hold.held(800 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + }); + + test('a begin split across writes is timed from the write that ends it', () => { + const { mode, hold, write } = standIn(); + write(0, '\x1b[?20'); + mode.on = true; + write(600, '26h'); + expect(hold.held(600 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + }); + + test('an update past its time turns the mode off once, and the next begin holds again', () => { + const { mode, hold, write } = standIn(); + write(0, `${BEGIN}stuck`); + expect(hold.held(SYNC_HOLD_MAX_MS)).toBe(false); + expect([mode.on, mode.ends]).toEqual([false, 1]); + expect(hold.held(SYNC_HOLD_MAX_MS + 16)).toBe(false); + expect(mode.ends).toBe(1); + write(5000, BEGIN); + expect(hold.held(5016)).toBe(true); + }); + + test('a mode turned on unseen (a C1 CSI) is timed from the first frame that sees it', () => { + const { mode, hold } = standIn(); + mode.on = true; + expect(hold.held(300)).toBe(true); + expect(hold.held(300 + SYNC_HOLD_MAX_MS)).toBe(false); + expect(mode.ends).toBe(1); + }); + + test('reset forgets the update and an unfinished begin', () => { + const { mode, hold, write } = standIn(); + write(0, BEGIN); + write(10, '\x1b[?20'); + hold.reset(); + mode.on = false; + write(700, '26h'); + // Not a begin after the reset: the mode is on, timed from the first frame + mode.on = true; + expect(hold.held(800 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + }); +}); + +describe('Terminal.renderHeld', () => { + test("follows the app's CSI ? 2026 h / l; the render loop paints nothing until the update ends", async () => { + const h = await openTerminal(); + try { + const { term, clock } = h; + const render = spyOn(rendererOf(term), 'render'); + expect(term.renderHeld(clock.now())).toBe(false); + term.write(`${BEGIN}half`); + expect(term.renderHeld(clock.now())).toBe(true); + render.mockClear(); + clock.frame(); + clock.frame(); + expect(render).not.toHaveBeenCalled(); + term.write(` drawn${END}`); + expect(term.renderHeld(clock.now())).toBe(false); + clock.frame(); + expect(render).toHaveBeenCalledTimes(1); + } finally { + h.close(); + } + }); + + test('an update never ended is turned off after a second: DECRQM, the echo and painting agree', async () => { + const h = await openTerminal(); + try { + const { term, clock, sent } = h; + const render = spyOn(rendererOf(term), 'render'); + term.write(`${BEGIN}stuck`); + // Typed input's echo waits while the update holds + h.container.dispatch(keydown('a')); + render.mockClear(); + term.write('a'); + expect(render).not.toHaveBeenCalled(); + clock.advance(SYNC_HOLD_MAX_MS); + clock.frame(); + expect(render).toHaveBeenCalledTimes(1); + expect(engineOf(term).getMode(2026)).toBe(false); + term.write('\x1b[?2026$p'); + expect(sent.at(-1)).toBe('\x1b[?2026;2$y'); + // The echo renders at once again + h.container.dispatch(keydown('b')); + render.mockClear(); + term.write('b'); + expect(render).toHaveBeenCalledTimes(1); + } finally { + h.close(); + } + }); + + test('reset replaces the engine and forgets its update; a replay ending inside one holds a second at most', async () => { + const h = await openTerminal(); + try { + const { term, clock } = h; + term.write(`${BEGIN}old`); + term.reset(); + expect(term.renderHeld(clock.now())).toBe(false); + // The replay of a session captured mid-update + term.write(`replayed${BEGIN}`); + expect(term.renderHeld(clock.now())).toBe(true); + clock.advance(SYNC_HOLD_MAX_MS); + expect(term.renderHeld(clock.now())).toBe(false); + } finally { + h.close(); + } + }); +}); diff --git a/src/client/vendor/tests/core/sync-scan.test.ts b/src/client/vendor/tests/core/sync-scan.test.ts new file mode 100644 index 0000000..a2c62a9 --- /dev/null +++ b/src/client/vendor/tests/core/sync-scan.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import { scanSyncBegin } from '../../ghostty-web/core/sync-scan'; + +// The synchronized-update scanner (ADR 067): which mode sets begin an update, and a +// begin split across output chunks. + +describe('scanSyncBegin', () => { + test.each([ + ['\x1b[?2026h', true], + ['text\x1b[?2026hmore', true], + ['\x1b[?25;2026h', true], + ['\x1b[?2026;1049h', true], + // The end, other modes and lookalikes + ['\x1b[?2026l', false], + ['\x1b[?12026h', false], + ['\x1b[?20261h', false], + ['\x1b[2026h', false], + ['2026h', false], + ['plain output', false], + ])('%j begins an update: %p', (data, begun) => { + expect(scanSyncBegin(data).begun).toBe(begun); + }); + + test('a begin split across chunks is found in the chunk that ends it, once', () => { + let carry = ''; + const begins: boolean[] = []; + for (const chunk of ['a\x1b', '[?20', '26', 'hb', 'c']) { + const scan = scanSyncBegin(chunk, carry); + carry = scan.carry; + begins.push(scan.begun); + } + expect(begins).toEqual([false, false, false, true, false]); + expect(carry).toBe(''); + }); + + test('carries only an unfinished mode sequence, not a finished one or other text', () => { + expect(scanSyncBegin('x\x1b[?2026').carry).toBe('\x1b[?2026'); + expect(scanSyncBegin('x\x1b[?2026h').carry).toBe(''); + expect(scanSyncBegin('x\x1b[31m').carry).toBe(''); + expect(scanSyncBegin(`\x1b[?${'1;'.repeat(20)}`).carry).toBe(''); + }); +}); diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 8ae607f..779510d 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -10,6 +10,7 @@ import { mouseHost, renderLoopHost, scrollbarHost, + syncHoldHost, TerminalControllers, wheelHost, writeHost, @@ -38,10 +39,10 @@ function controllers(options: { disableStdin?: boolean } = {}) { }; const emit = new TerminalEmitters(); emit.data.event((data) => void log.push(`data ${JSON.stringify(data)}`)); - // The input route reaches the selection, the render loop the engine's replies + // The input route reaches the selection, the render loop the engine's replies and modes const parts = { selection: { clearSelection: () => void log.push('clear selection') }, - engine: { hasResponse: () => false }, + engine: { hasResponse: () => false, getMode: () => false }, }; const core = new TerminalControllers( term, @@ -152,11 +153,17 @@ describe('TerminalControllers wiring', () => { */ function world() { const log: string[] = []; + /** Whether the engine reports the app's synchronized update (mode 2026) on. */ + const sync = { on: false }; const parts = { element: { id: 'element' }, textarea: { id: 'textarea' }, canvas: { id: 'canvas' }, - engine: { hasResponse: () => true }, + engine: { + hasResponse: () => true, + getMode: (mode: number) => mode === 2026 && sync.on, + setMode: (mode: number, on: boolean) => void log.push(`mode ${mode} ${on}`), + }, renderer: { scrollOffset: 7, setSearchHighlights: () => void log.push('highlight'), @@ -195,9 +202,10 @@ function world() { spyOn(core.focus, 'followMode').mockImplementation(() => void log.push('follow focus mode')); spyOn(core.finder, 'step').mockImplementation((now) => void log.push(`find step ${now}`)); spyOn(core.writes, 'drainReplies').mockImplementation(() => void log.push('drain')); + spyOn(core.sync, 'wrote').mockImplementation((data) => void log.push(`wrote ${data}`)); spyOn(core.scrollbar, 'drag').mockImplementation(() => true); spyOn(core.mouse, 'encode').mockImplementation(() => 'report'); - return { log, parts, term, inner, core }; + return { log, parts, term, inner, core, sync }; } // Each host member is one forwarding call to the terminal, its parts, its emitters or @@ -232,7 +240,7 @@ describe('TerminalControllers hosts', () => { }); test('write: the engine, the emitters, the link cache, the view and the controllers', () => { - const { log, parts, term, inner, core } = world(); + const { log, parts, term, inner, core, sync } = world(); const host = writeHost(term, inner, core); expect<unknown[]>([ host.engine(), @@ -248,6 +256,10 @@ describe('TerminalControllers hosts', () => { host.notify({ title: 'n', body: '' }); host.outputChanged(); host.scrollToBottom(); + host.wroteOutput('o'); + host.renderEcho(); + // Inside the app's synchronized update the echo waits for the frame after it (ADR 067) + sync.on = true; host.renderEcho(); expect(log).toEqual([ 'data r', @@ -258,14 +270,32 @@ describe('TerminalControllers hosts', () => { 'notify n', 'links stale', 'scroll to bottom', + 'wrote o', 'render false undefined', ]); }); + test("sync hold: the engine's mode 2026, read and turned off", () => { + const { log, inner, sync } = world(); + const host = syncHoldHost(inner); + expect(host.active()).toBe(false); + sync.on = true; + expect(host.active()).toBe(true); + host.end(); + expect(log).toEqual(['mode 2026 false']); + }); + test("render loop: each of a frame's steps", () => { - const { log, inner, core } = world(); + const { log, inner, core, sync } = world(); const host = renderLoopHost(inner, core); - expect<unknown[]>([host.live(), host.hasReplies()]).toEqual([true, true]); + expect<unknown[]>([host.live(), host.hasReplies(), host.renderHeld(0)]).toEqual([ + true, + true, + false, + ]); + sync.on = true; + expect(host.renderHeld(1)).toBe(true); + sync.on = false; host.beforeRender(1); host.stepFind(2); host.render(); diff --git a/src/client/vendor/tests/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts index 1e298d8..ce6049d 100644 --- a/src/client/vendor/tests/core/write-pipeline.test.ts +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -83,12 +83,13 @@ function recordingHost( viewportY: () => options.viewportY ?? 0, findAnchored: () => options.anchored ?? false, scrollToBottom: () => calls.push('scrollToBottom'), + wroteOutput: (data) => calls.push(`wroteOutput ${JSON.stringify(data)}`), renderEcho: () => calls.push('renderEcho'), }; } describe('WritePipeline order', () => { - test('write, replies, focus edge, effects, link cache, auto-scroll, callback, echo', () => { + test('write, sync timing, replies, focus edge, effects, link cache, auto-scroll, callback, echo', () => { const { calls, clock, pipeline } = recorded({ effects: { bell: true, @@ -107,6 +108,7 @@ describe('WritePipeline order', () => { pipeline.write('x', () => called++); expect(calls).toEqual([ 'engine.write "x"', + 'wroteOutput "x"', 'reply \x1b[1;1R', 'send "\\u001b[1;1R"', 'reply \x1b[0n', @@ -131,7 +133,13 @@ describe('WritePipeline order', () => { test('without effects, at the bottom and with no input sent, only the fixed steps run', () => { const { calls, clock, pipeline } = recorded(); pipeline.write('x'); - expect(calls).toEqual(['engine.write "x"', 'followFocusMode', 'readEffects', 'outputChanged']); + expect(calls).toEqual([ + 'engine.write "x"', + 'wroteOutput "x"', + 'followFocusMode', + 'readEffects', + 'outputChanged', + ]); expect(clock.pending.frames).toBe(0); }); @@ -157,6 +165,19 @@ describe('WritePipeline order', () => { }, ); + test('the sync hold sees all the output, the part a waiting clear came after too (ADR 067)', async () => { + const term = (await loadGhostty()).createTerminal(20, 5); + const calls: string[] = []; + const pipeline = new WritePipeline(recordingHost(calls, () => term, {})); + pipeline.write('\x1b[3'); + pipeline.clearAtGround(); + pipeline.write('C\x1b[?2026h'); + expect(calls.filter((c) => c.startsWith('wroteOutput')).at(-1)).toBe( + 'wroteOutput "C\\u001b[?2026h"', + ); + term.free(); + }); + test('drainReplies sends every queued reply, and nothing without an engine', () => { const { calls, pipeline } = recorded({ replies: ['a', 'b'] }); pipeline.drainReplies(); diff --git a/src/client/vendor/tests/support/terminal-harness.ts b/src/client/vendor/tests/support/terminal-harness.ts index 7ab73f3..0f839bf 100644 --- a/src/client/vendor/tests/support/terminal-harness.ts +++ b/src/client/vendor/tests/support/terminal-harness.ts @@ -1,6 +1,6 @@ // A whole vendored Terminal opened on fakes (test support, ADR 064): the fake document, // window and container of the listener snapshot, the real wasm engine and a manual clock -// for frames and timeouts, so a test drives the terminal through its public API only. The +// for frames, timeouts and performance.now(), so a test drives the terminal through its public API only. The // vendored code reads globals; they are swapped in by openTerminal and put back by close(). import { ManualClock } from '../../../test-support/manual-clock'; @@ -29,7 +29,13 @@ export interface TerminalHarness { close(): void; } -const GLOBALS = ['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'] as const; +const GLOBALS = [ + 'document', + 'window', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', +] as const; /** The fakes in the vendored code's globals, and the call that puts the real ones back. */ export interface StubbedGlobals { @@ -42,8 +48,8 @@ export interface StubbedGlobals { } /** - * Puts a fake document and window, and the clock's animation frames, in the globals the - * vendored code reads; the window's timeouts run on the clock too. + * Puts a fake document and window, and the clock's animation frames and time, in the + * globals the vendored code reads; the window's timeouts run on the clock too. */ export function stubGlobals(): StubbedGlobals { const g = globalThis as Record<string, unknown>; @@ -58,6 +64,8 @@ export function stubGlobals(): StubbedGlobals { g.window = win; g.requestAnimationFrame = clock.requestAnimationFrame; g.cancelAnimationFrame = clock.cancelAnimationFrame; + // Writes are timed on performance.now(), frames on their timestamps: one clock for both + g.performance = { now: () => clock.now() }; return { doc, win, From 06c2f8aebc149f345042a630e1b4927dfe583342 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 08:08:57 -0400 Subject: [PATCH 284/295] fix(scripts): scroll-bench sizes its shift window per input, survives a failed run and a closed tab, and checks its arguments The step and blink measures look for steps as large as each input makes (the TrackPoint's 156-166 px frames), and the step average leaves out pairs that are a different picture or at the window's edge; a blink needs its neighbours to match outright. A failing run prints an error and the bench goes on, the session is deleted even when the browser fails, CDP calls time out and fail when the socket closes, and screencast acks cannot crash the process. Servers take turns going first, inputs may not repeat, labels are table-safe, and USAGE lists the inputs. Tests cover session create and delete against the in-process server, the ID check from the server, the profile expression and the timeout; dead code and references to lost scratch files are gone. --- scripts/scroll-bench/bench-args.test.ts | 31 ++++++++++++- scripts/scroll-bench/bench-args.ts | 25 +++++++++-- scripts/scroll-bench/bench.ts | 37 ++++++++++++---- scripts/scroll-bench/cdp.test.ts | 16 +++++++ scripts/scroll-bench/cdp.ts | 56 +++++++++++++++--------- scripts/scroll-bench/inputs.test.ts | 16 +++---- scripts/scroll-bench/inputs.ts | 28 ++++++++---- scripts/scroll-bench/measure.ts | 9 ++-- scripts/scroll-bench/metrics.test.ts | 40 ++++++++++++++--- scripts/scroll-bench/metrics.ts | 42 +++++++++++++----- scripts/scroll-bench/page.test.ts | 20 +++++++++ scripts/scroll-bench/screencast.ts | 3 +- scripts/scroll-bench/session-api.test.ts | 50 +++++++++++++++++++-- scripts/scroll-bench/session-api.ts | 7 ++- 14 files changed, 298 insertions(+), 82 deletions(-) create mode 100644 scripts/scroll-bench/cdp.test.ts create mode 100644 scripts/scroll-bench/page.test.ts diff --git a/scripts/scroll-bench/bench-args.test.ts b/scripts/scroll-bench/bench-args.test.ts index 29cde74..3dc7523 100644 --- a/scripts/scroll-bench/bench-args.test.ts +++ b/scripts/scroll-bench/bench-args.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from 'bun:test'; -import { DEFAULT_CDP_PORT, DEFAULT_RUNS, parseBase, parseBenchArgs } from './bench-args'; +import { + basesForRun, + DEFAULT_CDP_PORT, + DEFAULT_RUNS, + parseBase, + parseBenchArgs, + USAGE, +} from './bench-args'; import { INPUT_NAMES } from './inputs'; describe('parseBase', () => { @@ -19,6 +26,27 @@ describe('parseBase', () => { expect(() => parseBase('localhost')).toThrow('not a URL'); expect(() => parseBase('a=ws://localhost:1')).toThrow('http or https'); }); + test('rejects a label that would break the summary table', () => { + expect(() => parseBase('a|b=http://localhost:1')).toThrow('a label is'); + expect(() => parseBase('a b=http://localhost:1')).toThrow('a label is'); + }); +}); + +describe('basesForRun', () => { + test('each run starts one server later, so none always goes last', () => { + expect([1, 2, 3, 4].map((run) => basesForRun(['a', 'b', 'c'], run).join(''))).toEqual([ + 'abc', + 'bca', + 'cab', + 'abc', + ]); + }); +}); + +describe('USAGE', () => { + test('describes every input profile', () => { + for (const name of INPUT_NAMES) expect(USAGE).toContain(` ${name} `); + }); }); describe('parseBenchArgs', () => { @@ -59,6 +87,7 @@ describe('parseBenchArgs', () => { ['a flag without a value', ['--base'], 'needs a value'], ['an unknown flag', ['--fast', '1'], 'unknown flag --fast'], ['an unknown input', ['--inputs', 'touch'], 'unknown input touch'], + ['a repeated input', ['--inputs', 'wheel,wheel'], 'repeated'], ['zero runs', ['--runs', '0'], 'whole number'], [ 'a repeated label', diff --git a/scripts/scroll-bench/bench-args.ts b/scripts/scroll-bench/bench-args.ts index 1bb196c..e9fb3ad 100644 --- a/scripts/scroll-bench/bench-args.ts +++ b/scripts/scroll-bench/bench-args.ts @@ -1,7 +1,7 @@ // The benchmark's command line: the servers to compare, the scenarios file, the input // profiles, the runs and the CDP port, parsed and checked without side effects (util). -import { INPUT_NAMES, type InputName, isInputName } from './inputs'; +import { INPUT_NAMES, INPUT_PROFILES, type InputName, isInputName } from './inputs'; /** A server under test: the label results carry and its base URL. */ export interface BaseTarget { @@ -25,11 +25,18 @@ export const DEFAULT_RUNS = 2; export const USAGE = `usage: bun scripts/scroll-bench/bench.ts --base [label=]http://localhost:PORT ... --scenarios FILE [--inputs trackpad,wheel,trackpoint] [--runs N] [--cdp-port PORT] - --base a server to measure; repeat it to compare builds (label defaults to host:port) + --base a server to measure; repeat it to compare builds (label defaults to host:port; + letters, digits and . _ : - only) --scenarios a JSON array of scenarios (see scripts/scroll-bench/scenarios.example.json) --inputs the input profiles to play, comma separated (default: all) --runs runs of each scenario and input per server (default ${DEFAULT_RUNS}) - --cdp-port the --remote-debugging-port of an already running Chrome (default ${DEFAULT_CDP_PORT})`; + --cdp-port the --remote-debugging-port of an already running Chrome (default ${DEFAULT_CDP_PORT}) + +inputs: +${INPUT_NAMES.map((name) => ` ${name.padEnd(12)} ${INPUT_PROFILES[name].description}`).join('\n')}`; + +/** What a label may hold, so it reads plainly in the JSON lines and the markdown table. */ +const LABEL_RE = /^[A-Za-z0-9._:-]+$/; /** Parses `label=url` or a bare URL, whose label is its host and port. */ export function parseBase(value: string): BaseTarget { @@ -45,7 +52,10 @@ export function parseBase(value: string): BaseTarget { if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new Error(`--base: must be http or https: ${raw}`); } - return { label: label || url.host, url: url.origin }; + const name = label || url.host; + if (!LABEL_RE.test(name)) + throw new Error(`--base: a label is letters, digits and . _ : -: ${name}`); + return { label: name, url: url.origin }; } function positiveInt(flag: string, value: string): number { @@ -61,9 +71,16 @@ function parseInputs(value: string): InputName[] { throw new Error(`--inputs: unknown input ${name}; one of ${INPUT_NAMES.join(', ')}`); } } + if (new Set(names).size !== names.length) throw new Error('--inputs: an input is repeated'); return names as InputName[]; } +/** The servers in the order run `run` (from 1) measures them, rotated by one each run. */ +export function basesForRun<T>(bases: readonly T[], run: number): T[] { + const start = (run - 1) % bases.length; + return [...bases.slice(start), ...bases.slice(0, start)]; +} + /** Parses the flags after the script name; throws with the problem on a bad one. */ export function parseBenchArgs(argv: readonly string[]): BenchArgs { const bases: BaseTarget[] = []; diff --git a/scripts/scroll-bench/bench.ts b/scripts/scroll-bench/bench.ts index 52770f5..c06b875 100644 --- a/scripts/scroll-bench/bench.ts +++ b/scripts/scroll-bench/bench.ts @@ -3,6 +3,8 @@ // creates a fresh session, opens it in an already running Chrome, starts the scenario's // app, scrolls with the input while recording the screencast, prints one JSON line of // numbers and deletes the session; then a markdown table of the medians over the runs. +// A run that fails prints an error line and the benchmark goes on with the next; the +// servers take turns going first, so none is always measured after the others. // // Start Chrome or Chromium yourself; the benchmark never launches or closes a browser: // @@ -20,7 +22,7 @@ // theme is assumed: the stream test finds the bottom by stream.sh's bright inverse bar. import path from 'node:path'; -import { parseBenchArgs, USAGE } from './bench-args'; +import { type BenchArgs, basesForRun, parseBenchArgs, USAGE } from './bench-args'; import { type Cdp, connectCdp, sleep } from './cdp'; import { INPUT_PROFILES, type InputName } from './inputs'; import { measureScroll, measureStream, startScenario } from './measure'; @@ -54,7 +56,9 @@ async function runOne( } return { kind: 'scroll', ...key, ...(await measureScroll(cdp, profile, key.direction)) }; } finally { - await cdp.navigate('about:blank'); + // Leave the page first so the session's socket closes, but delete the session even + // when the browser fails, so its app does not keep running. + await cdp.navigate('about:blank').catch(() => {}); await deleteSession(session); } } @@ -64,21 +68,36 @@ async function main(argv: readonly string[]): Promise<number> { console.log(USAGE); return 0; } - const args = parseBenchArgs(argv); + let args: BenchArgs; + try { + args = parseBenchArgs(argv); + } catch (error) { + console.error(`${error instanceof Error ? error.message : error}\n\n${USAGE}`); + return 2; + } const scenarios = loadScenarios(args.scenariosFile, { repo: path.resolve(import.meta.dir, '../..'), bench: import.meta.dir, }); const cdp = await connectCdp(args.cdpPort); const results: Result[] = []; + let failed = 0; try { for (const scenario of scenarios) { for (const input of args.inputs) { for (let run = 1; run <= args.runs; run++) { - for (const base of args.bases) { - const result = await runOne(cdp, base, scenario, input, run); - results.push(result); - console.log(resultLine(result)); + for (const base of basesForRun(args.bases, run)) { + try { + const result = await runOne(cdp, base, scenario, input, run); + results.push(result); + console.log(resultLine(result)); + } catch (error) { + failed++; + const message = error instanceof Error ? error.message : String(error); + console.error( + `# failed ${base.label} ${scenario.name} ${input} run ${run}: ${message}`, + ); + } await sleep(500); } } @@ -88,7 +107,8 @@ async function main(argv: readonly string[]): Promise<number> { cdp.close(); } console.log(`\n${summaryTable(results)}`); - return 0; + if (failed) console.error(`# ${failed} run(s) failed; the table leaves them out`); + return failed ? 1 : 0; } if (import.meta.main) { @@ -96,7 +116,6 @@ if (import.meta.main) { (code) => process.exit(code), (error: unknown) => { console.error(error instanceof Error ? error.message : error); - console.error(USAGE); process.exit(1); }, ); diff --git a/scripts/scroll-bench/cdp.test.ts b/scripts/scroll-bench/cdp.test.ts new file mode 100644 index 0000000..b2fae1c --- /dev/null +++ b/scripts/scroll-bench/cdp.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test'; +import { withTimeout } from './cdp'; + +describe('withTimeout', () => { + test('passes on what settles in time', async () => { + expect(await withTimeout(Promise.resolve(7), 50, 'x')).toBe(7); + await expect(withTimeout(Promise.reject(new Error('no')), 50, 'x')).rejects.toThrow('no'); + }); + + test('rejects, naming the call, when nothing comes', async () => { + const never = new Promise<number>(() => {}); + await expect(withTimeout(never, 5, 'Page.navigate')).rejects.toThrow( + 'Page.navigate: no reply in 5 ms', + ); + }); +}); diff --git a/scripts/scroll-bench/cdp.ts b/scripts/scroll-bench/cdp.ts index d8dfb95..843dd4c 100644 --- a/scripts/scroll-bench/cdp.ts +++ b/scripts/scroll-bench/cdp.ts @@ -1,21 +1,22 @@ // A minimal Chrome DevTools Protocol driver for the scroll benchmark: attaches to the // first page target of a Chrome or Chromium already started with -// `--remote-debugging-port`, and offers the few calls the benchmark needs (service over -// Bun's global fetch and WebSocket; it never launches a browser, see bench.ts for how -// to start one). +// `--remote-debugging-port`, and offers the few calls the benchmark needs. Every call +// times out, and a closed socket fails what is pending, so a crashed tab never hangs the +// run (service over Bun's global fetch and WebSocket; it never launches a browser, see +// bench.ts for how to start one). /** A CDP event handler, given the event's params. */ export type CdpHandler = (params: Record<string, unknown>) => void; /** The driver the benchmark talks to one page through. */ export interface Cdp { - /** Sends a CDP command and resolves with its result; rejects on a protocol error. */ + /** Sends a CDP command and resolves with its result; rejects on an error or timeout. */ send(method: string, params?: object): Promise<Record<string, unknown>>; /** Adds a handler for a CDP event; the returned function removes it. */ on(method: string, handler: CdpHandler): () => void; /** Evaluates `expression` in the page, awaiting a promise, and returns its value. */ eval<T>(expression: string): Promise<T>; - /** Navigates the page and waits for its load event. */ + /** Navigates the page and waits for its load event, at most CALL_TIMEOUT_MS. */ navigate(url: string): Promise<void>; /** Dispatches one mouse wheel event at (x, y). */ wheel(x: number, y: number, deltaY: number): Promise<void>; @@ -25,15 +26,12 @@ export interface Cdp { key(name: KeyName): Promise<void>; /** Clicks the left mouse button at (x, y), to focus what is there. */ click(x: number, y: number): Promise<void>; - /** A PNG screenshot of the page. */ - screenshot(): Promise<Uint8Array>; close(): void; } /** The keys the benchmark presses, with what CDP needs to send each. */ const KEYS = { Enter: { code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' }, - Escape: { code: 'Escape', windowsVirtualKeyCode: 27, text: '\u001b' }, } as const; /** A key `Cdp.key` can press. */ @@ -42,6 +40,18 @@ export type KeyName = keyof typeof KEYS; /** Resolves after `ms` milliseconds. */ export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); +/** The longest a command or a page load may take before the call fails. */ +export const CALL_TIMEOUT_MS = 30_000; + +/** `promise`, or a rejection naming `what` once `ms` have passed without it settling. */ +export function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> { + let timer: ReturnType<typeof setTimeout> | undefined; + const timeout = new Promise<never>((_, reject) => { + timer = setTimeout(() => reject(new Error(`${what}: no reply in ${ms} ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + interface Target { type: string; webSocketDebuggerUrl: string; @@ -69,6 +79,7 @@ export async function connectCdp(port: number): Promise<Cdp> { }); let nextId = 0; + let closed: Error | null = null; const pending = new Map<number, (msg: Message) => void>(); const handlers = new Map<string, Set<CdpHandler>>(); ws.addEventListener('message', (event) => { @@ -79,17 +90,24 @@ export async function connectCdp(port: number): Promise<Cdp> { pending.delete(msg.id); } }); + ws.addEventListener('close', () => { + closed = new Error('the browser closed the CDP connection'); + for (const fail of pending.values()) fail({ error: { message: closed.message } }); + pending.clear(); + }); const send: Cdp['send'] = (method, params = {}) => { + if (closed) return Promise.reject(closed); const id = ++nextId; - ws.send(JSON.stringify({ id, method, params })); - return new Promise((resolve, reject) => + const reply = new Promise<Record<string, unknown>>((resolve, reject) => pending.set(id, (msg) => msg.error ? reject(new Error(`${method}: ${msg.error.message}`)) : resolve(msg.result ?? {}), ), ); + ws.send(JSON.stringify({ id, method, params })); + return withTimeout(reply, CALL_TIMEOUT_MS, method).finally(() => pending.delete(id)); }; const on: Cdp['on'] = (method, handler) => { const set = handlers.get(method) ?? new Set(); @@ -113,14 +131,16 @@ export async function connectCdp(port: number): Promise<Cdp> { return (r.result as { value: T }).value; }, async navigate(url) { + let off = () => {}; const loaded = new Promise<void>((resolve) => { - const off = on('Page.loadEventFired', () => { - off(); - resolve(); - }); + off = on('Page.loadEventFired', () => resolve()); }); - await send('Page.navigate', { url }); - await loaded; + try { + await send('Page.navigate', { url }); + await withTimeout(loaded, CALL_TIMEOUT_MS, `loading ${url}`); + } finally { + off(); + } }, async wheel(x, y, deltaY) { await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY }); @@ -137,10 +157,6 @@ export async function connectCdp(port: number): Promise<Cdp> { await mouse('mousePressed', x, y); await mouse('mouseReleased', x, y); }, - async screenshot() { - const r = await send('Page.captureScreenshot', { format: 'png' }); - return Buffer.from(String(r.data), 'base64'); - }, close: () => ws.close(), }; } diff --git a/scripts/scroll-bench/inputs.test.ts b/scripts/scroll-bench/inputs.test.ts index 8228e5b..80a232d 100644 --- a/scripts/scroll-bench/inputs.test.ts +++ b/scripts/scroll-bench/inputs.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { type Clock, - durationMs, INPUT_NAMES, INPUT_PROFILES, isInputName, @@ -39,13 +38,18 @@ describe('input profiles', () => { expect(hard.slice(1).every((e) => e.gapMs >= 29 && e.gapMs <= 45)).toBe(true); }); - test('every profile is a fixed table: positive deltas and gaps, the same each read', () => { + test('every profile has positive deltas and gaps, and a frame step window', () => { for (const name of INPUT_NAMES) { - const { events } = INPUT_PROFILES[name]; + const { events, maxFramePx } = INPUT_PROFILES[name]; expect(events.every((e) => e.deltaPx > 0 && e.gapMs > 0)).toBe(true); - expect(INPUT_PROFILES[name].events).toEqual(events); + expect(maxFramePx).toBeGreaterThan(0); } }); + + test("the TrackPoint's window covers ADR 045's smoothed 156-166 px frames", () => { + expect(trackpoint.maxFramePx).toBeGreaterThan(166); + expect(wheel.maxFramePx).toBeGreaterThan(100); + }); }); describe('profile helpers', () => { @@ -59,10 +63,6 @@ describe('profile helpers', () => { expect(signedEvents(wheel, 'up').every((e) => e.deltaPx === -100)).toBe(true); expect(signedEvents(wheel, 'down')).toEqual([...wheel.events]); }); - - test('durationMs sums the gaps', () => { - expect(durationMs(trackpad)).toBe(2400); - }); }); describe('playInput', () => { diff --git a/scripts/scroll-bench/inputs.ts b/scripts/scroll-bench/inputs.ts index 0290194..3cc4fcf 100644 --- a/scripts/scroll-bench/inputs.ts +++ b/scripts/scroll-bench/inputs.ts @@ -1,7 +1,9 @@ // The scroll input profiles as data, and playing one through CDP mouse wheel events. // Each profile is a fixed table, so every run sends the same events at the same times: // a trackpad's per-frame pixel deltas, a mouse wheel's notches and a TrackPoint's sparse -// fractional deltas, as ADR 045 measured them in Chrome on macOS (constants + service). +// fractional deltas. Only the TrackPoint's light step and the hard push's peak and gaps +// come from ADR 045's capture in Chrome on macOS; the rest are synthetic tables shaped +// after the devices' usual rates (constants + service). import type { Cdp } from './cdp'; @@ -17,11 +19,16 @@ export type InputName = 'trackpad' | 'wheel' | 'trackpoint'; /** Which way to scroll: up into the scrollback or the file's start, or down. */ export type Direction = 'up' | 'down'; -/** A device's wheel events and what they stand for. */ +/** A device's wheel events, what they stand for, and how far they can move one frame. */ export interface InputProfile { name: InputName; description: string; events: readonly WheelEvent[]; + /** + * The largest step between two recorded frames the scroll test looks for, in px: an + * event's whole delta applied at once, plus room for a dropped screencast frame. + */ + maxFramePx: number; } const repeat = (count: number, gapMs: number, deltaPx: number): WheelEvent[] => @@ -29,9 +36,12 @@ const repeat = (count: number, gapMs: number, deltaPx: number): WheelEvent[] => /** Gaps between a mouse wheel's notches, spun by hand at about 9 notches a second. */ const WHEEL_GAPS = [110, 100, 120, 105, 115, 100, 110, 120, 105, 115, 100, 110, 120, 105]; -/** A light TrackPoint push: 4.000244 px about every 150 ms (ADR 045's capture). */ +/** A light TrackPoint push: ADR 045's 4.000244 px steps, about every 150 ms. */ const TRACKPOINT_LIGHT_GAPS = [150, 145, 155, 150, 148, 152, 150, 150]; -/** A hard TrackPoint push, ramping up to about 335 px and back, every 29-45 ms (ADR 045). */ +/** + * A hard TrackPoint push: a synthetic ramp up to ADR 045's peak of about 335 px and back, + * with gaps within its 29-45 ms. The capture's own deltas are not in the repo. + */ const TRACKPOINT_HARD: readonly [number, number][] = [ [300, 18.5], [38, 46.25], @@ -56,11 +66,13 @@ export const INPUT_PROFILES: Record<InputName, InputProfile> = { name: 'trackpad', description: 'a slow trackpad scroll: 2.5 px every 16 ms, 150 events', events: repeat(150, 16, 2.5), + maxFramePx: 60, }, wheel: { name: 'wheel', description: 'a mouse wheel: 14 notches of 100 px, 100-120 ms apart', events: WHEEL_GAPS.map((gapMs) => ({ gapMs, deltaPx: 100 })), + maxFramePx: 150, }, trackpoint: { name: 'trackpoint', @@ -69,6 +81,9 @@ export const INPUT_PROFILES: Record<InputName, InputProfile> = { ...TRACKPOINT_LIGHT_GAPS.map((gapMs) => ({ gapMs, deltaPx: 4.000244 })), ...TRACKPOINT_HARD.map(([gapMs, deltaPx]) => ({ gapMs, deltaPx })), ], + // ADR 045's smoothed push moves 156-166 px a frame; a build that applies each delta at + // once jumps up to 335 px, more than a frame's rows can match, and reads as a new picture. + maxFramePx: 240, }, }; @@ -86,11 +101,6 @@ export function signedEvents(profile: InputProfile, direction: Direction): Wheel return profile.events.map((e) => ({ gapMs: e.gapMs, deltaPx: sign * e.deltaPx })); } -/** How long the profile takes to send, in ms. */ -export function durationMs(profile: InputProfile): number { - return profile.events.reduce((sum, e) => sum + e.gapMs, 0); -} - /** The clock playInput waits on; tests pass a fake one. */ export interface Clock { now(): number; diff --git a/scripts/scroll-bench/measure.ts b/scripts/scroll-bench/measure.ts index b13c28d..cd5756a 100644 --- a/scripts/scroll-bench/measure.ts +++ b/scripts/scroll-bench/measure.ts @@ -11,8 +11,8 @@ import { matchPairs, type PaintStats, paintStats, - SCROLL_WINDOW, type StreamStats, + scrollWindow, streamStats, } from './metrics'; import { decodeProfiles, readPaintCounter, resetPaintCounter, viewportCentre } from './page'; @@ -82,11 +82,12 @@ export async function measureScroll( cast.map((f) => f.jpeg), { x: 8, width: 600, channels: 'rg' }, ); - const pairs = matchPairs(profiles, SCROLL_WINDOW); + const window = scrollWindow(input.maxFramePx); + const pairs = matchPairs(profiles, window); return { frames: profiles.length, - blinks: blinkFrames(profiles, pairs, SCROLL_WINDOW).length, - avgStepPx: averageStepPx(pairs), + blinks: blinkFrames(profiles, pairs, window).length, + avgStepPx: averageStepPx(pairs, window), ...paintStats(log.frames, log.wheels, TAIL_MS), }; } diff --git a/scripts/scroll-bench/metrics.test.ts b/scripts/scroll-bench/metrics.test.ts index 922ac75..e510dca 100644 --- a/scripts/scroll-bench/metrics.test.ts +++ b/scripts/scroll-bench/metrics.test.ts @@ -10,12 +10,13 @@ import { type PaintFrame, type Profile, paintStats, - SCROLL_WINDOW, type StreamFrame, + scrollWindow, streamStats, } from './metrics'; -const HEIGHT = 320; +const HEIGHT = 640; +const SCROLL_WINDOW = scrollWindow(60); /** A textured page, so every shift of it looks different: brightness of content row y. */ const page = (y: number): number => 60 + 40 * Math.sin(y * 0.37) + 12 * ((y * 31) % 7); @@ -42,16 +43,30 @@ describe('bestShift', () => { const flat = Array.from({ length: HEIGHT }, () => 0); expect(bestShift(frame(0), flat, SCROLL_WINDOW).error).toBeGreaterThan(3); }); + + test("finds a hard TrackPoint push's 160 px step in a window wide enough", () => { + const match = bestShift(frame(0), frame(160), scrollWindow(200)); + expect(match.shift).toBe(-160); + expect(match.error).toBeCloseTo(0, 6); + }); + + test('throws on a margin narrower than the shift, or no rows left to compare', () => { + expect(() => bestShift(frame(0), frame(1), { maxShift: 60, margin: 50 })).toThrow('margin'); + expect(() => bestShift(frame(0), frame(1), scrollWindow(HEIGHT / 2))).toThrow('too short'); + }); }); describe('blinkFrames', () => { const torn = (offset: number): number[] => frame(offset).map((v, y) => (y < HEIGHT / 2 ? page(y + offset + 25) : v)); - const flashed = (): number[] => Array.from({ length: HEIGHT }, () => 0); + const level = (v: number): number[] => Array.from({ length: HEIGHT }, () => v); + const flashed = (): number[] => level(0); const cases: [string, () => number[][], number[]][] = [ ['a smooth scroll', () => smooth(12, 3), []], ['a fast smooth scroll', () => smooth(12, 40), []], + // Errors 20 and 15 to the middle frame, 5 across: relatively close, still different. + ['three frames that all differ (an app redrawing)', () => [0, 20, 5].map(level), []], ['a still view', () => smooth(12, 0), []], ['a single transient frame', () => smooth(12, 3).map((p, k) => (k === 5 ? flashed() : p)), [5]], ['a torn half-frame', () => smooth(12, 3).map((p, k) => (k === 6 ? torn(18) : p)), [6]], @@ -67,12 +82,25 @@ describe('blinkFrames', () => { }); describe('averageStepPx', () => { + const stepOf = (profiles: Profile[]) => + averageStepPx(matchPairs(profiles, SCROLL_WINDOW), SCROLL_WINDOW); + test('the mean distance of the frames that moved', () => { - expect(averageStepPx(matchPairs(smooth(6, 4), SCROLL_WINDOW))).toBe(4); + expect(stepOf(smooth(6, 4))).toBe(4); }); test('0 when nothing moved', () => { - expect(averageStepPx(matchPairs(smooth(4, 0), SCROLL_WINDOW))).toBe(0); - expect(averageStepPx([])).toBe(0); + expect(stepOf(smooth(4, 0))).toBe(0); + expect(averageStepPx([], SCROLL_WINDOW)).toBe(0); + }); + test('leaves out pairs that are a different picture or at the edge of the window', () => { + const pairs = [ + { shift: 4, error: 0.5 }, + { shift: -17, error: 9 }, + { shift: 60, error: 1 }, + { shift: -60, error: 1 }, + { shift: -6, error: 2 }, + ]; + expect(averageStepPx(pairs, SCROLL_WINDOW)).toBe(5); }); }); diff --git a/scripts/scroll-bench/metrics.ts b/scripts/scroll-bench/metrics.ts index 42da612..904d024 100644 --- a/scripts/scroll-bench/metrics.ts +++ b/scripts/scroll-bench/metrics.ts @@ -18,19 +18,30 @@ export interface ShiftMatch { shift: number; } -/** The scroll test's window: any shift a frame of smooth scrolling makes (blink.js). */ -export const SCROLL_WINDOW: ShiftWindow = { maxShift: 60, margin: 70 }; -/** The streaming test's window (t66.js). */ +/** + * The scroll test's window for an input whose view moves at most `maxShift` px between two + * recorded frames; the margin keeps every shifted row inside the frame. + */ +export function scrollWindow(maxShift: number): ShiftWindow { + return { maxShift, margin: maxShift + 10 }; +} +/** + * The streaming test's window: it compares frames at rest, where any move is a fault, so + * a few rows' worth of shift is enough to tell a move from a redraw. + */ export const STREAM_WINDOW: ShiftWindow = { maxShift: 40, margin: 60 }; /** A mean row error above this is a different picture, not JPEG noise. */ export const DIFFERENT = 3; /** * The shift of `b` against `a` with the least mean error over the rows inside the margin; - * ties keep the first shift tried, from `-maxShift` up. + * ties keep the first shift tried, from `-maxShift` up. Throws when the margin is narrower + * than the largest shift or leaves no rows to compare. */ export function bestShift(a: Profile, b: Profile, window: ShiftWindow): ShiftMatch { const { maxShift, margin } = window; + if (margin < maxShift) throw new Error(`shift window: margin ${margin} < maxShift ${maxShift}`); + if (a.length <= 2 * margin) throw new Error(`shift window: a ${a.length} px frame is too short`); let best: ShiftMatch = { error: Number.POSITIVE_INFINITY, shift: 0 }; for (let s = -maxShift; s <= maxShift; s++) { let sum = 0; @@ -39,7 +50,7 @@ export function bestShift(a: Profile, b: Profile, window: ShiftWindow): ShiftMat sum += Math.abs(b[y] - a[y - s]); rows++; } - const error = rows ? sum / rows : 0; + const error = sum / rows; if (error < best.error) best = { error, shift: s }; } return best; @@ -51,9 +62,10 @@ export function matchPairs(profiles: readonly Profile[], window: ShiftWindow): S } /** - * The frames that blink: unlike both neighbours while the neighbours match each other, - * at any shift, so smooth motion never counts but a flashed or half-drawn frame does. - * `pairs` is `matchPairs(profiles, window)`. + * The frames that blink: a different picture from both neighbours at any shift, while the + * neighbours match each other (an error within DIFFERENT, and under half the smaller of the + * two). Smooth motion never counts, nor do three frames that all differ (an app redrawing), + * but a flashed or half-drawn frame does. `pairs` is `matchPairs(profiles, window)`. */ export function blinkFrames( profiles: readonly Profile[], @@ -66,14 +78,20 @@ export function blinkFrames( const after = pairs[k].error; if (before <= DIFFERENT || after <= DIFFERENT) continue; const across = bestShift(profiles[k - 1], profiles[k + 1], window).error; - if (across < Math.min(before, after) / 2) blinks.push(k); + if (across <= DIFFERENT && across < Math.min(before, after) / 2) blinks.push(k); } return blinks; } -/** The mean distance of the frames that moved, in px (0 when none did). */ -export function averageStepPx(pairs: readonly ShiftMatch[]): number { - const moved = pairs.filter((p) => p.shift !== 0); +/** + * The mean distance of the frames that moved, in px (0 when none did). A pair counts only + * when it is the same picture moved: not a different one (a redraw or a cut), and not at + * the window's edge, where the real step may have been larger than the window. + */ +export function averageStepPx(pairs: readonly ShiftMatch[], window: ShiftWindow): number { + const moved = pairs.filter( + (p) => p.shift !== 0 && p.error <= DIFFERENT && Math.abs(p.shift) < window.maxShift, + ); if (moved.length === 0) return 0; return round(moved.reduce((sum, p) => sum + Math.abs(p.shift), 0) / moved.length, 1); } diff --git a/scripts/scroll-bench/page.test.ts b/scripts/scroll-bench/page.test.ts new file mode 100644 index 0000000..0e64bd9 --- /dev/null +++ b/scripts/scroll-bench/page.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { profileExpression } from './page'; + +describe('profileExpression', () => { + const region = { x: 8, width: 600, channels: 'rg' } as const; + const expression = profileExpression(['AAA=', 'BBB='], region); + + test('is one expression the page can evaluate', () => { + // Compiles it without running it: the page's Image and OffscreenCanvas are not here. + expect(() => new Function(`return () => ${expression};`)).not.toThrow(); + }); + + test('carries the region and every frame, quoted as data', () => { + expect(expression).toContain(JSON.stringify(region)); + expect(expression).toContain(JSON.stringify(['AAA=', 'BBB='])); + const quoted = profileExpression(['"]; alert(1); //'], region); + expect(() => new Function(`return () => ${quoted};`)).not.toThrow(); + expect(quoted).toContain(JSON.stringify(['"]; alert(1); //'])); + }); +}); diff --git a/scripts/scroll-bench/screencast.ts b/scripts/scroll-bench/screencast.ts index 506e3bb..db79adc 100644 --- a/scripts/scroll-bench/screencast.ts +++ b/scripts/scroll-bench/screencast.ts @@ -30,7 +30,8 @@ export async function startRecording(cdp: Cdp, options: CastOptions): Promise<Re const frames: CastFrame[] = []; const off = cdp.on('Page.screencastFrame', (params) => { frames.push({ time: Date.now(), jpeg: String(params.data) }); - void cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }); + // A frame that comes as the screencast stops may have its ack refused; that is fine. + cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }).catch(() => {}); }); await cdp.send('Page.startScreencast', { format: 'jpeg', everyNthFrame: 1, ...options }); await sleep(WARM_UP_MS); diff --git a/scripts/scroll-bench/session-api.test.ts b/scripts/scroll-bench/session-api.test.ts index 9cf3571..950c089 100644 --- a/scripts/scroll-bench/session-api.test.ts +++ b/scripts/scroll-bench/session-api.test.ts @@ -1,16 +1,58 @@ -import { describe, expect, test } from 'bun:test'; -import { benchSessionId } from './session-api'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { resolveServerPaths } from '../../src/server/server-paths'; +import { isValidId } from '../../src/server/sessions/session-id'; +import { type LiveServer, startLiveServer } from '../../src/test-support/live-server'; +import { type TmpHome, useTmpHome } from '../../src/test-support/server-harness'; +import { benchSessionId, createSession, deleteSession } from './session-api'; describe('benchSessionId', () => { test('a slug of the parts and the time, valid as a session ID', () => { const id = benchSessionId(['after', 'claude-classic', 'wheel', 2], 36 ** 3); expect(id).toBe('bench-after-claude-classic-wheel-2-1000'); - expect(id).toMatch(/^[a-z0-9\-_.]{1,64}$/); + expect(isValidId(id)).toBe(true); }); test('folds labels that are not ID characters and stays short', () => { const id = benchSessionId(['localhost:3001', 'X'.repeat(80)], 0); expect(id.startsWith('bench-localhost-3001-x')).toBe(true); - expect(id.length).toBeLessThanOrEqual(64); + expect(isValidId(id)).toBe(true); + }); +}); + +// Against the whole server in-process (ADR 064). The tests share one server and run in +// order; deleting its last session stops it, so that comes last. +describe('createSession and deleteSession', () => { + let live: LiveServer; + let home: TmpHome; + const id = benchSessionId(['test', 'session-api'], 0); + + beforeAll(async () => { + home = useTmpHome('scroll-bench-session-api'); + live = await startLiveServer({ paths: resolveServerPaths() }); + }); + + afterAll(() => { + live.close(); + home.restore(); + }); + + test('creates the session and gives the page that opens it', async () => { + const session = await createSession(live.base, id); + expect(session).toEqual({ base: live.base, id, url: `${live.base}/s/${id}` }); + expect((await fetch(`${live.base}/api/sessions/${id}`)).status).toBe(200); + }); + + test('a session that cannot be created throws with the API call', async () => { + await expect(createSession(live.base, id)).rejects.toThrow(`POST ${live.base}/api/sessions`); + }); + + test('deleting a missing session is fine', async () => { + const missing = { base: live.base, id: 'bench-missing', url: `${live.base}/s/bench-missing` }; + await deleteSession(missing); + }); + + test('deletes the session', async () => { + await deleteSession({ base: live.base, id, url: `${live.base}/s/${id}` }); + expect(live.store.get(id)).toBeUndefined(); }); }); diff --git a/scripts/scroll-bench/session-api.ts b/scripts/scroll-bench/session-api.ts index 727a689..39c763a 100644 --- a/scripts/scroll-bench/session-api.ts +++ b/scripts/scroll-bench/session-api.ts @@ -38,10 +38,9 @@ export async function createSession(base: string, id: string): Promise<BenchSess /** Deletes the session, ending its shell and whatever runs in it; a missing one is fine. */ export async function deleteSession(session: BenchSession): Promise<void> { - const res = await fetch(`${session.base}/api/sessions/${encodeURIComponent(session.id)}`, { - method: 'DELETE', - }); + const api = `${session.base}/api/sessions/${encodeURIComponent(session.id)}`; + const res = await fetch(api, { method: 'DELETE' }); if (res.status !== 204 && res.status !== 404) { - throw new Error(`DELETE ${session.url}: ${res.status}`); + throw new Error(`DELETE ${api}: ${res.status}`); } } From ae46c132eae3a7ac1433025921cbbbc06cc8b427 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 08:13:18 -0400 Subject: [PATCH 285/295] fix(client): output keeps a scrolled-back view on its text, through pruned pages too Before each write Ghostty's viewport is pinned on the view's top row (or the part row a smooth scroll shows) and the view goes where the pin went, so the text stays put through new lines, whole pages pruned at the scrollback limit and a cleared scrollback. SmoothScroll follows from any write, keeping each input device's glide on its way; a glide heading for the bottom still ends there (ADR 066). --- .../066.client.output-keeps-scrolled-view.md | 41 +++++ docs/specs/client.md | 5 +- .../scroll/smooth-scroll-devices.test.ts | 166 ++++++++++++++++++ .../features/scroll/smooth-scroll.test.ts | 67 ++++++- src/client/features/scroll/smooth-scroll.ts | 47 ++++- src/client/test-support/fake-terminal.ts | 6 + src/client/vendor/ghostty-web/PROVENANCE.md | 3 +- .../vendor/ghostty-web/core/host-types.ts | 14 +- .../vendor/ghostty-web/core/opened-parts.ts | 3 +- .../ghostty-web/core/terminal-controllers.ts | 10 +- .../vendor/ghostty-web/core/viewport.ts | 46 ++++- .../vendor/ghostty-web/core/write-pipeline.ts | 34 ++-- .../vendor/ghostty-web/engine/engine-types.ts | 9 + .../ghostty-web/engine/official-terminal.ts | 41 +++-- src/client/vendor/ghostty-web/glide.ts | 22 ++- src/client/vendor/ghostty-web/renderer.ts | 5 + src/client/vendor/tests/core/glide.test.ts | 51 ++++++ .../vendor/tests/core/kept-view.test.ts | 101 +++++++++++ .../tests/core/terminal-controllers.test.ts | 22 ++- src/client/vendor/tests/core/terminal.test.ts | 6 +- src/client/vendor/tests/core/viewport.test.ts | 79 ++++++++- .../vendor/tests/core/write-pipeline.test.ts | 48 +++-- 22 files changed, 753 insertions(+), 73 deletions(-) create mode 100644 docs/adrs/066.client.output-keeps-scrolled-view.md create mode 100644 src/client/features/scroll/smooth-scroll-devices.test.ts create mode 100644 src/client/vendor/tests/core/kept-view.test.ts diff --git a/docs/adrs/066.client.output-keeps-scrolled-view.md b/docs/adrs/066.client.output-keeps-scrolled-view.md new file mode 100644 index 0000000..e88abee --- /dev/null +++ b/docs/adrs/066.client.output-keeps-scrolled-view.md @@ -0,0 +1,41 @@ +# ADR 066: client — Output keeps a scrolled-back view on its text + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-14 + +--- + +## Context + +The vendored write pipeline kept coder/ghostty-web's rule: any output while the view is scrolled back moves the view to the bottom (`lib/terminal.ts`, "Auto-scroll to bottom on new output (xterm.js behavior)"). Only a view on a selected find match was exempt (ADR 055). The comment was wrong about xterm.js: xterm.js keeps a scrolled view while the user has scrolled up (`BufferService.isUserScrolling`). + +An app that redraws while it works, such as pi's spinner and status line, writes several times a second. Scrolling back slowly through its scrollback then flickers: each wheel step moves the view up a little, and the next redraw pulls it back to the bottom, even when the redraw only rewrites a line in place. `seq` and an idle app write nothing while the view moves, so they scroll smoothly. Running the real engine headlessly confirmed it: a view 10 lines back went to the bottom on a redraw of one status line. + +Keeping the view is the industry default: Ghostty (`scroll-to-bottom = keystroke, no-output`), kitty, WezTerm, Alacritty, xterm.js and VS Code, and gnome-terminal all leave a scrolled view where it is on output; only xterm jumps, with `scrollTtyOutput` (on by default). Ghostty, WezTerm and xterm.js pin the view to a row of the buffer; kitty and Alacritty count the lines added and move the view back by as many. webtty already scrolls to the bottom on a key or a paste (ADR 055). + +Counting lines by the scrollback's length is not enough. Below the scrollback limit the length grows by the lines added, but at the limit Ghostty prunes whole pages (ADR 046): the length drops by several hundred rows at once (673 at 80 columns with `scrollbackLines: 2000`). A view kept by the length's change jumps by about a page at each prune. libghostty-vt reports neither the lines added nor the rows pruned, but its own viewport is a pin on a row, which Ghostty moves when it prunes: measured on the pinned wasm, a pin 30 rows back stayed on its text through every prune, and a pin on a pruned row went to the oldest row left. webtty already moves that viewport and reads it back for a prompt jump (`promptJumpRow`, ADR 057). + +## Decision + +On the normal screen, output keeps a scrolled-back view on its text, with Ghostty's own pin: + +- **Before each write** the write pipeline pins Ghostty's viewport on the view's top row (`Viewport.pin`, `GhosttyTerminal.pinViewport`: `GHOSTTY_SCROLL_VIEWPORT_ROW`). **After it** the binding reads where the pin went (`GHOSTTY_TERMINAL_DATA_SCROLLBAR`) and puts Ghostty's viewport back at the bottom (`unpinViewport`), and the view goes to that row (`Viewport.unpin`). The same text stays in view through new lines, through rows pruned at the scrollback limit and through a clear: a cleared scrollback brings the view to the bottom, and a view whose own rows were pruned lands on the oldest line left, as in Ghostty. The pin is released in a `finally`, so a write that throws cannot leave Ghostty's viewport off the bottom, where the render state reads it. +- **A smooth scroll's sub-line offset.** Less than a line back, webtty's view is at line 0 and the renderer shows part of the row above (`CanvasRenderer.requestedOffset`). A pin on the active screen follows the output, so that row is pinned instead; it is in view. `Viewport.scrollToBottom` is announced from line 0 too while that row shows, so a key or a paste drops the offset and the view follows the output again. +- **Not a move the user made**: it fires no `onScroll` and does not show the scrollbar. +- **A view at the bottom** pins nothing and follows the output as before. The alternate screen has no scrollback to keep a view in: output there still moves the view to the bottom, dropping a sub-line offset too. A view on a selected find match is kept by the find step as before (ADR 055). +- **`SmoothScroll` follows the kept view** for every input device (ADR 045): a trackpad's deltas applied at once, a mouse wheel notch's ease-out glide, a spinning wheel's and a slow TrackPoint's linear glides, and a fast TrackPoint's spring. After a write it moves its glide by as many lines as the view moved (`Glide.shift`), keeping the fraction, the velocity and, for a timed glide, its start, so the glide goes on covering the distance it had left, frame by frame as without the output. The exception is a glide heading for the bottom: it still ends there (`shift(delta, keepTarget)` stretches a timed glide's remaining curve to start from the moved value, and a spring keeps heading for its target), or a scroll down could never reach output that keeps coming. Writes that do not go through `onOutput` (the status line `PtyConnection` writes after a reconnect) are caught up with on the next frame or wheel event, before the glide moves. + +## Considered Options + +- **Keep coder/ghostty-web's rule.** Rejected: it is the flicker, and no terminal webtty follows does it. +- **Skip the scroll to the bottom only while a glide is running.** Rejected: a slow scroll is a series of short glides, and a view at rest would still jump on every redraw. +- **Move the view by the scrollback's change in length.** The first version did. Rejected: exact below the limit, but at the limit each page Ghostty prunes moved the text in view by about a page. +- **Count lines in the output (newlines at the bottom margin, as kitty and Alacritty do inside their parser).** Rejected: webtty does not parse the output; scroll regions, reverse index, wrapping and resizes would all have to be redone outside the engine, which already tracks the row. +- **Make it configurable like Ghostty's `scroll-to-bottom`.** Not now: `no-output` is Ghostty's default and the behaviour webtty follows elsewhere. A setting can be added if someone asks for xterm's behaviour. + +## Consequences + +- Scrolling back while an app streams or redraws is smooth, and the view stays on its text, at the scrollback limit too. +- A write while scrolled back costs three more wasm calls (pin, read, unpin): measured on the pinned wasm, 5,000 one-line writes took 4.3 ms pinned against 3.2 ms unpinned, and the pin does not mark the render state for a full redraw. A view at the bottom costs nothing. +- Coming back to the bottom takes a key, a paste or a scroll, as in Ghostty. diff --git a/docs/specs/client.md b/docs/specs/client.md index 1ae1789..a0980c1 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -231,7 +231,7 @@ Ctrl+F stays the app's. The keys go through `shortcutFor` and the key dispatcher - **Matching** is Ghostty's: ASCII letters case-insensitively, everything else exactly, soft-wrapped rows joined so a match can span a wrap; scrollback and screen, on the active screen. - **Order**: next goes toward older output (up), previous toward newer, both wrapping. Nothing is selected until the first Enter; the count is Ghostty's `selected/total` (`0/n` before then). -- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, output does not pull the view to the bottom: it stays on its rows, except on the alternate screen. A key or a paste sent to the program scrolls to the bottom, as in Ghostty (`scroll-to-bottom = keystroke, no-output`); mouse reports do not. +- **Scrolling**: if no row of the selected match is in view, the view jumps so its first row is the top row. While a match is selected and the view is scrolled up, the view stays on the match's rows during output, except on the alternate screen. Without a match, output keeps a scrolled view on its text too (ADR 066). A key or a paste sent to the program scrolls to the bottom, as in Ghostty (`scroll-to-bottom = keystroke, no-output`); mouse reports do not. - **Highlights**: every match in `searchBackground` / `searchForeground`, the selected one in `searchSelectedBackground` / `searchSelectedForeground` (Ghostty's defaults); the selection is drawn over both. - **The bar** (`features/find/find-bar.ts`) is a DOM overlay at the top right, outside `#terminal`, coloured from the theme, with a labelled field, the count in a live region, ↑ (next), ↓ (previous) and × buttons. Typing searches after 150 ms; closing ends the search, clears the highlights and refocuses the terminal, and the field keeps its text for the next open. @@ -248,7 +248,7 @@ Shells that mark their prompts with OSC 133 (`133;A` prompt, `133;B` command lin | others | Ctrl+Shift+↑ / Ctrl+Shift+↓ | to the previous / next prompt | | all | Ctrl/Cmd + triple click | select the command's output (see Copy Behavior) | -- **The move is Ghostty's** (`GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`): the prompt becomes the view's top row; up from the bottom goes to the nearest prompt above the screen's top row; past the last prompt the view goes to the bottom. The binding's `promptJumpRow` puts Ghostty's viewport on webtty's top row, moves it, reads it back (`GHOSTTY_TERMINAL_DATA_SCROLLBAR`) and returns it to the bottom; `Terminal.jumpToPrompt` scrolls the view with `scrollToLine`, and output still pulls a scrolled view to the bottom. +- **The move is Ghostty's** (`GHOSTTY_SCROLL_VIEWPORT_DELTA_PROMPT`): the prompt becomes the view's top row; up from the bottom goes to the nearest prompt above the screen's top row; past the last prompt the view goes to the bottom. The binding's `promptJumpRow` puts Ghostty's viewport on webtty's top row, moves it, reads it back (`GHOSTTY_TERMINAL_DATA_SCROLLBAR`) and returns it to the bottom; `Terminal.jumpToPrompt` scrolls the view with `scrollToLine`, and output keeps the view on its text (ADR 066). - The keys are always consumed, as Ghostty's are not `performable:`: without marks, or on the alternate screen, they do nothing and do not reach the app. They go through `shortcutFor` and the listener of the copy and paste keys, so `keyboardBindings` win. - Nothing is sent anywhere, so the replay needs no special handling; its marks come with it. @@ -393,4 +393,5 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Configurable padding | Top-level `padding` (px, default `0`) reserves space around the canvas on all sides; the grid is fitted to what remains; the top and left gap is exactly `padding` and the leftover goes to the right and bottom; optional `theme.padding` colours the space `padding` adds (default: `theme.background`) but not the leftover | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Edge extension | The leftover strip at the right and bottom takes the edge cells' own background, by repainting the canvas' last pixel row and column across it; always on, no setting | [ADR 035](../adrs/035.client.edge-extend.md) | ✅ | | Theme key mapping | The config's `purple`, `brightPurple` and `selection` are translated to the terminal's `magenta`, `brightMagenta` and `selectionBackground`, which were previously ignored; `selectionForeground` is new and defaults to `background` when `selection` is set | [ADR 036](../adrs/036.client.theme-key-mapping.md) | ✅ | +| Output keeps a scrolled view | On the normal screen, output no longer pulls a scrolled-back view to the bottom (coder/ghostty-web's rule): before each write Ghostty's viewport is pinned on the view's top row, and after it the view goes where the pin went, so the same text stays in view through new lines, through the pages Ghostty prunes at the scrollback limit and through a cleared scrollback; the smooth-scroll glide moves with it for every input device. A key or a paste still scrolls to the bottom, as Ghostty's `scroll-to-bottom = keystroke, no-output`. Ends the flicker when scrolling back through an app that redraws while it works | [ADR 066](../adrs/066.client.output-keeps-scrolled-view.md) | ✅ | | Synchronized output | While the app sends a synchronized update (DEC mode 2026), the terminal, the echo of typed input and the kitty overlay do not paint, so an app that redraws the whole screen across several WebSocket messages is never shown half drawn. As in Ghostty, every `CSI ? 2026 h` restarts a one-second timer, and when it runs out the mode is turned off, so an app that never ends an update cannot freeze the view | [ADR 067](../adrs/067.client.synchronized-output.md) | ✅ | diff --git a/src/client/features/scroll/smooth-scroll-devices.test.ts b/src/client/features/scroll/smooth-scroll-devices.test.ts new file mode 100644 index 0000000..53d09ef --- /dev/null +++ b/src/client/features/scroll/smooth-scroll-devices.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from 'bun:test'; +import { easeOutCubic, linear, type Pace, type Terminal } from '../../vendor/ghostty-web'; +import { SmoothScroll } from './smooth-scroll'; +import { WheelStream } from './wheel-stream'; + +// Output while the scrollback glides, for every input device (ADR 045, ADR 066): event +// sequences WheelStream really classifies as a trackpad, a mouse wheel notch, a spinning +// wheel and a TrackPoint, slow and fast. Output arriving mid-glide must leave the text in +// view where it is, and the glide must go on covering the distance it had left, frame by +// frame as if no output had come; one heading for the bottom still ends there. + +/** A wheel event `gap` ms after the one before it. */ +interface Step { + deltaY: number; + deltaMode?: number; + gap: number; +} + +/** Each device: its events (scrolling up), and how WheelStream paces the last one. */ +const DEVICES: [string, Step[], 'at once' | 'ease out' | 'linear' | 'spring'][] = [ + [ + 'trackpad', + Array.from({ length: 8 }, (_, i) => ({ deltaY: -(6.25 + i * 0.75), gap: 16 })), + 'at once', + ], + ['mouse wheel notch', [{ deltaY: -3, deltaMode: 1, gap: 0 }], 'ease out'], + [ + 'spinning mouse wheel', + [0, 60, 60, 60].map((gap) => ({ deltaY: -3, deltaMode: 1, gap })), + 'linear', + ], + [ + 'TrackPoint, slow', + [0, 100, 110, 95].map((gap, i) => ({ deltaY: -(23.5 + i * 1.25), gap })), + 'linear', + ], + [ + 'TrackPoint, fast', + [0, 40, 35, 30, 45, 33, 38, 31].map((gap, i) => ({ deltaY: -(23.5 + i * 1.25), gap })), + 'spring', + ], +]; + +/** The kind of motion a pace makes. */ +function kind(pace: Pace): string { + if (pace.omega) return 'spring'; + if (pace.duration <= 0) return 'at once'; + return pace.ease === easeOutCubic ? 'ease out' : pace.ease === linear ? 'linear' : 'other'; +} + +const CELL = 10; + +/** + * A terminal whose view a write moves as ghostty-web's Viewport.pin / unpin does: a view + * scrolled back, or showing part of the row above, moves up by the lines written. + */ +function terminal() { + const offsets: number[] = []; + let scrolled = (_y: number) => {}; + const term = { + viewportY: 0, + scrollback: 1000, + rows: 40, + wasmTerm: { isAlternateScreen: () => false }, + renderer: { + getMetrics: () => ({ height: CELL }), + setScrollOffset: (offset: number) => offsets.push(offset), + }, + onScroll: (cb: (y: number) => void) => { + scrolled = cb; + return { dispose: () => {} }; + }, + onResize: () => ({ dispose: () => {} }), + getScrollbackLength: () => term.scrollback, + scrollToLine(line: number) { + term.viewportY = line; + }, + }; + const write = (lines: number) => { + term.scrollback += lines; + if (term.viewportY > 0 || (offsets.at(-1) ?? 0) > 0) term.viewportY += lines; + }; + /** Where the view's top is in the text, in lines from the oldest one. */ + const top = () => term.scrollback - term.viewportY - (offsets.at(-1) ?? 0) / CELL; + return { term, write, top, scrolled: (y: number) => scrolled(y) }; +} + +/** + * Plays `steps` from a view `from` lines back, with a frame every 16 ms, writing `lines` + * new lines `at` ms after the last event (through onOutput, or caught up with on the next + * frame when `quiet`), and returns where the view's top is in the text after each frame. + */ +function play( + steps: Step[], + from: number, + output?: { lines: number; at: number; quiet?: boolean }, +) { + const t = terminal(); + const now = { t: 0 }; + const stream = new WheelStream({ matchMedia: () => ({ matches: false }) }); + const scroll = new SmoothScroll(t.term as unknown as Terminal, stream, { + window: { devicePixelRatio: 1 }, + performance: { now: () => now.t }, + }); + t.term.viewportY = from; + t.scrolled(from); + const times = steps.map((_, i) => 1000 + steps.slice(0, i + 1).reduce((a, s) => a + s.gap, 0)); + const last = times[times.length - 1]; + const writeAt = output && last + output.at; + const tops: number[] = []; + const paces: Pace[] = []; + for (now.t = 990; now.t < last + 600; now.t += 1) { + steps.forEach((step, i) => { + if (times[i] !== now.t) return; + const e = { + deltaX: 0, + deltaY: step.deltaY, + deltaMode: step.deltaMode ?? 0, + timeStamp: now.t, + } as WheelEvent; + paces.push(stream.forScrollback(e)); + scroll.handleWheel(e); + }); + if (output && now.t === writeAt) { + t.write(output.lines); + if (!output.quiet) scroll.onOutput(); + } + if (now.t % 16 === 0) { + scroll.frame(now.t); + tops.push(t.top()); + } + } + return { tops, pace: paces.at(-1), term: t.term }; +} + +describe.each(DEVICES)('%s', (_, steps, motion) => { + test(`is paced ${motion}`, () => { + const { pace } = play(steps, 200); + expect(pace && kind(pace)).toBe(motion); + }); + + test.each([ + ['through onOutput', false], + ['caught up with on the next frame', true], + ])('output mid-glide, %s, keeps the text in view and the glide on its way', (_, quiet) => { + const still = play(steps, 200); + const fed = play(steps, 200, { lines: 7, at: 20, quiet }); + // Frame by frame the view shows the same text, with or without the output + expect(fed.tops.map((top) => +top.toFixed(6))).toEqual( + still.tops.map((top) => +top.toFixed(6)), + ); + expect(fed.term.viewportY).toBe(still.term.viewportY + 7); + }); + + test('a scroll down to the bottom ends there, output mid-glide or not', () => { + const down = steps.map((step) => ({ ...step, deltaY: -step.deltaY })); + // From a little less far back than the scroll goes: its last event heads for the bottom + const lines = down.reduce( + (sum, s) => sum + (s.deltaMode === 1 ? s.deltaY : s.deltaY / CELL), + 0, + ); + const fed = play(down, Math.floor(lines) - 1, { lines: 7, at: 20 }); + expect(fed.term.viewportY).toBe(0); + expect(fed.tops.at(-1)).toBe(fed.term.scrollback); + }); +}); diff --git a/src/client/features/scroll/smooth-scroll.test.ts b/src/client/features/scroll/smooth-scroll.test.ts index a4ce10a..01743ac 100644 --- a/src/client/features/scroll/smooth-scroll.test.ts +++ b/src/client/features/scroll/smooth-scroll.test.ts @@ -18,6 +18,7 @@ describe('SmoothScroll', () => { const unsubscribed: string[] = []; const term = { viewportY: 0, + scrollback: 100, rows: 40, wasmTerm: { isAlternateScreen: () => false }, renderer: { @@ -32,7 +33,7 @@ describe('SmoothScroll', () => { resized = cb; return { dispose: () => unsubscribed.push('onResize') }; }, - getScrollbackLength: () => 100, + getScrollbackLength: () => term.scrollback, scrollToLine(line: number) { term.viewportY = line; }, @@ -108,24 +109,74 @@ describe('SmoothScroll', () => { expect(offsets.at(-1)).toBe(10); }); - test('new output at the bottom clears a sub-line offset', () => { + test('less than a line up, output keeps the text in view (ADR 066)', () => { const { term, offsets, scroll } = fake(10); - // 0.55 lines: line 0 with a 5.5 px offset, rounded to 6. - scroll.handleWheel(wheel(-5.5)); + // 0.625 lines (a trackpad delta): line 0 with a 6.25 px offset, rounded to 6. + scroll.handleWheel(wheel(-6.25)); expect([term.viewportY, offsets.at(-1)]).toEqual([0, 6]); + // A redraw in place moves nothing scroll.onOutput(); - expect([term.viewportY, offsets.at(-1)]).toEqual([0, 0]); - // At rest on the bottom, output changes nothing. + expect(offsets).toEqual([6]); + // Three new lines: ghostty-web pinned the part row in view and moved the view up by three + term.scrollback += 3; + term.viewportY = 3; scroll.onOutput(); - expect(offsets).toEqual([6, 0]); + expect([term.viewportY, offsets.at(-1)]).toEqual([3, 6]); }); - test('new output above the bottom is left to ghostty-web', () => { + test('a view ghostty-web kept on its text moves the glide by as much, keeping its offset (ADR 066)', () => { const { term, offsets, scroll } = fake(10); scroll.handleWheel(wheel(-25.75)); expect([term.viewportY, offsets.at(-1)]).toEqual([2, 6]); + // A redraw in place: the view stays. scroll.onOutput(); expect([term.viewportY, offsets.at(-1)]).toEqual([2, 6]); + // Four new lines, or four after rows pruned at the limit: ghostty-web moved its view up by four. + term.viewportY = 6; + scroll.onOutput(); + expect([term.viewportY, offsets.at(-1)]).toEqual([6, 6]); + // The next wheel step goes on from there, not from where the glide was. + scroll.handleWheel(wheel(-10)); + expect([term.viewportY, offsets.at(-1)]).toEqual([7, 6]); + }); + + test('a write without onOutput (a status line) is caught up with on the next frame or wheel event', () => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel(wheel(-25.75)); + term.viewportY = 5; + scroll.frame(16); + expect([term.viewportY, offsets.at(-1)]).toEqual([5, 6]); + term.viewportY = 8; + scroll.handleWheel(wheel(-10)); + expect([term.viewportY, offsets.at(-1)]).toEqual([9, 6]); + }); + + test('a cleared scrollback, or one that shrank under a sub-line offset, brings the glide to the bottom', () => { + const { term, offsets, scroll } = fake(10); + scroll.handleWheel(wheel(-25.75)); + term.scrollback = 0; + term.viewportY = 0; + scroll.onOutput(); + expect([term.viewportY, offsets.at(-1)]).toEqual([0, 0]); + term.scrollback = 100; + scroll.handleWheel(wheel(-6.25)); + term.scrollback = 0; + scroll.onOutput(); + expect([term.viewportY, offsets.at(-1)]).toEqual([0, 0]); + }); + + test('a key at a sub-line offset (scrollToBottom announced from line 0) drops the offset', () => { + const { term, offsets, scroll, scrolled } = fake(10); + scroll.handleWheel(wheel(-6.25)); + scrolled(0); + expect([term.viewportY, offsets.at(-1)]).toEqual([0, 0]); + }); + + test('at rest on the bottom, output changes nothing', () => { + const { term, offsets, scroll } = fake(10); + term.scrollback += 3; + scroll.onOutput(); + expect([term.viewportY, offsets]).toEqual([0, []]); }); test("follows ghostty-web's own moves of the viewport, and resizes", () => { diff --git a/src/client/features/scroll/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts index aa5d150..6b58c5a 100644 --- a/src/client/features/scroll/smooth-scroll.ts +++ b/src/client/features/scroll/smooth-scroll.ts @@ -1,5 +1,6 @@ /** - * Pixel-smooth scrollback scrolling (object over the terminal, ADR 040, ADR 043, ADR 045). + * Pixel-smooth scrollback scrolling (object over the terminal, ADR 040, ADR 043, ADR 045, + * ADR 066). * * ghostty-web draws whole rows only: its viewport is a line count, and a fractional * viewportY is floored when rows are picked. A trackpad reports pixel deltas, so @@ -16,6 +17,10 @@ * a mouse wheel notch or a TrackPoint's sparse deltas glide, trackpad deltas, which * arrive every frame, apply at once. * + * Output keeps a scrolled view on its text: ghostty-web moves its viewport with the text + * on each write, announcing nothing, and the glide moves by as much, keeping its fraction + * and its motion (ADR 066). + * * Only the normal screen's scrollback scrolls this way. On the alternate screen, or * when the app tracks the mouse, the app moves its own view in whole lines. */ @@ -36,17 +41,20 @@ export class SmoothScroll implements Disposable { private readonly store = new DisposableStore(); /** Set while `apply` moves ghostty-web's viewport, whose onScroll is then our own. */ private applying = false; + /** ghostty-web's viewport line as the last `apply` left it. */ + private line = 0; constructor( private readonly term: Terminal, private readonly stream: WheelStream, private readonly deps: SmoothScrollDeps = { window, performance }, ) { - // ghostty-web moves the viewport itself on new output (to the bottom), keyboard - // scrolling and scrollbar drags. Follow it whenever it lands on a different line. + // ghostty-web moves the viewport itself on a key or a paste (to the bottom, which also + // drops a sub-line offset), keyboard scrolling, scrollbar drags and find. Follow it + // whenever it lands anywhere but where the glide is. this.store.add( term.onScroll((y: number) => { - if (!this.applying && y !== Math.floor(this.glide.value)) this.jump(y); + if (!this.applying && y !== this.glide.value) this.jump(y); }), ); this.store.add(term.onResize(() => this.jump(term.viewportY))); @@ -62,6 +70,7 @@ export class SmoothScroll implements Disposable { if (this.term.wasmTerm?.isAlternateScreen()) return false; const metrics = this.term.renderer?.getMetrics(); if (!metrics) return false; + this.follow(); const lines = wheelLines(e, metrics.height, this.term.rows); // A delta during a glide adds to where the glide is heading. const to = this.clamp(this.glide.target - lines); @@ -75,6 +84,7 @@ export class SmoothScroll implements Disposable { * so the frame shows the glide's position for that frame, not the one before. */ frame(now: number): void { + this.follow(); if (!this.glide.active) return; // A spring has arrived once within half a device pixel. const height = this.term.renderer?.getMetrics()?.height ?? 15; @@ -83,9 +93,9 @@ export class SmoothScroll implements Disposable { this.apply(); } - /** New output arrived. ghostty-web only snaps to the bottom when viewportY > 0, so clear a sub-line offset here. */ + /** New output was written: the glide follows where it kept the view. */ onOutput(): void { - if (this.term.viewportY === 0 && this.glide.value !== 0) this.jump(0); + this.follow(); } /** @@ -96,6 +106,30 @@ export class SmoothScroll implements Disposable { this.apply(); } + /** + * Catches up with ghostty-web's view after writes, which move it with its text without + * an onScroll (Viewport.unpin, ADR 066): from any write, so `onOutput`, the next frame and + * the next wheel event all call this. The glide moves by as many lines, keeping its + * fraction and its motion; one heading for the bottom still ends there, so a scroll down + * reaches output that keeps coming. A view back at the bottom (the scrollback was cleared, + * or the alternate screen came up) drops the glide, as does a scrollback that shrank under + * a sub-line offset. + */ + private follow(): void { + const y = this.term.viewportY; + if (y !== this.line) { + if (y === 0) { + this.jump(0); + } else { + this.glide.shift(y - this.line, this.glide.active && this.glide.target === 0); + this.glide.value = this.clamp(this.glide.value); + this.apply(); + } + } else if (this.glide.value > this.term.getScrollbackLength()) { + this.jump(this.clamp(this.glide.value)); + } + } + private jump(line: number): void { this.glide.jump(line); this.apply(); @@ -127,5 +161,6 @@ export class SmoothScroll implements Disposable { } } renderer.setScrollOffset(offset); + this.line = this.term.viewportY; } } diff --git a/src/client/test-support/fake-terminal.ts b/src/client/test-support/fake-terminal.ts index 96e01ef..ce1a9c0 100644 --- a/src/client/test-support/fake-terminal.ts +++ b/src/client/test-support/fake-terminal.ts @@ -40,6 +40,8 @@ export class FakeTerminal { cols = 80; rows = 24; viewportY = 0; + /** What `getScrollbackLength` returns. */ + scrollbackLength = 0; options: Record<string, unknown> = {}; /** Undefined until {@link attachRenderer}, as before `open()`. */ renderer: FakeRenderer | undefined; @@ -159,6 +161,10 @@ export class FakeTerminal { this.calls.push('dispose'); } + getScrollbackLength(): number { + return this.scrollbackLength; + } + /** Records the clear; returns {@link clearable}. */ clearScreen(): boolean { this.calls.push('clearScreen'); diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index da9d1c8..b0caa35 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -64,6 +64,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). - `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). - `providers/osc8-link-provider.ts`: `provideLinks` scans the row and a private `linkAt` reads one link's run; the scan skips past each run instead of keeping a set of visited columns, which visits the same cells in the same order and returns the same links (ADR 061). +- `core/write-pipeline.ts`, `core/viewport.ts`, `core/terminal-controllers.ts`, `core/opened-parts.ts`, `renderer.ts`, `engine/engine-types.ts`: output no longer moves a view scrolled back on the normal screen to the bottom (upstream's `write` did, calling it xterm.js' behaviour, which it is not); before each write `Viewport.pin` puts Ghostty's viewport on the view's top row (`GhosttyTerminal.pinViewport`), or on the part row above it a smooth scroll shows (`CanvasRenderer.requestedOffset`), and after it `Viewport.unpin` moves the view to where Ghostty took that row (`unpinViewport`), without an `onScroll` or the scrollbar, as Ghostty's `scroll-to-bottom = no-output`. `Viewport.scrollToBottom` is announced from line 0 while the part row shows. The alternate screen still goes to the bottom (ADR 066). - `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067). - `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). @@ -71,7 +72,7 @@ webtty edits to these files are tracked in git from the commit that added them; Not from upstream; each starts with `// webtty (ADR NNN), not from upstream.` (`vendor/tests/provenance.test.ts` checks the header and this list). -- `glide.ts`, `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). +- `glide.ts` (ADR 045, ADR 066), `wheel.ts` (ADR 045), `mouse-route.ts` (ADR 051), `search.ts` (ADR 055). - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. - `core/host-types.ts` (ADR 061): the narrow interfaces `Terminal`'s parts and `SelectionManager` see of their host, where upstream reached into `Terminal` through `any`. - `core/focus-tracker.ts` (ADR 053), `core/find-controller.ts` (ADR 055): `Terminal`'s focus reports and find, which webtty added, moved out of `terminal.ts` (ADR 061). diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index 83c4588..f46d6b2 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -58,6 +58,12 @@ export type SelectionEngineSource = Pick< */ export interface ViewportHost { getScrollbackLength(): number; + /** + * Whether the renderer shows part of the row above the view, from the next frame on: a + * smooth scroll's sub-line offset (CanvasRenderer.requestedOffset), which the embedder + * sets, not the Viewport. + */ + partRowShown(): boolean; /** The view moved to `viewportY`. */ scrolled(viewportY: number): void; showScrollbar(): void; @@ -143,10 +149,16 @@ export interface WriteHost { notify(notification: DesktopNotification): void; /** The content changed: the link cache is stale. */ outputChanged(): void; - viewportY(): number; /** Whether the view is kept on a selected find match (FindController.anchored). */ findAnchored(): boolean; scrollToBottom(): void; + /** + * Pins Ghostty's viewport on the view's text for the write that follows; false, pinning + * nothing, when the view is at the bottom (Viewport.pin). + */ + pinView(): boolean; + /** After the write, moves the view to where the pin went with its text (Viewport.unpin). */ + unpinView(): void; /** The output just written, which may begin a synchronized update (SyncHold.wrote). */ wroteOutput(data: string | Uint8Array): void; /** diff --git a/src/client/vendor/ghostty-web/core/opened-parts.ts b/src/client/vendor/ghostty-web/core/opened-parts.ts index c1a177f..a4719a6 100644 --- a/src/client/vendor/ghostty-web/core/opened-parts.ts +++ b/src/client/vendor/ghostty-web/core/opened-parts.ts @@ -11,10 +11,11 @@ import type { LinkSource, MenuSelection, MouseRenderer } from './host-types'; /** * What the controllers reach of the CanvasRenderer: the mouse's reads, the find - * highlights and its teardown. + * highlights, the sub-line offset the view keeps across output (ADR 066) and its teardown. */ export interface RendererPart extends MouseRenderer { setSearchHighlights(highlights: SearchHighlights | null): void; + readonly requestedOffset: number; dispose(): void; } diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index 4173dcd..65d79f1 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -104,6 +104,7 @@ export class TerminalControllers { this.renderLoop = new RenderLoop(renderLoopHost(inner, this), clock, clock?.now); this.viewport = new Viewport({ getScrollbackLength: () => term.getScrollbackLength(), + partRowShown: () => (inner.parts()?.renderer.requestedOffset ?? 0) > 0, scrolled: (viewportY) => inner.emit.scroll.fire(viewportY), showScrollbar: () => this.scrollbar.show(), }); @@ -216,9 +217,16 @@ export function writeHost(term: ControllersHost, inner: Inner, core: TerminalCon clipboardWrite: (text) => inner.emit.clipboardWrite.fire(text), notify: (notification) => inner.emit.desktopNotification.fire(notification), outputChanged: () => inner.parts()?.links.invalidateCache(), - viewportY: () => term.viewportY, findAnchored: () => core.finder.anchored, scrollToBottom: () => term.scrollToBottom(), + pinView: () => { + const engine = inner.parts()?.engine; + return !!engine && core.viewport.pin(engine); + }, + unpinView: () => { + const engine = inner.parts()?.engine; + if (engine) core.viewport.unpin(engine); + }, wroteOutput: (data) => core.sync.wrote(data), // Not inside a synchronized update: the frame after it ends paints (ADR 067) renderEcho: () => { diff --git a/src/client/vendor/ghostty-web/core/viewport.ts b/src/client/vendor/ghostty-web/core/viewport.ts index 9170103..eb468b1 100644 --- a/src/client/vendor/ghostty-web/core/viewport.ts +++ b/src/client/vendor/ghostty-web/core/viewport.ts @@ -1,10 +1,14 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). // The view's position in the scrollback (object): `y` lines back from the bottom, moved -// only within the scrollback, each move announced and the scrollbar shown (ADR 061). +// only within the scrollback, each move announced and the scrollbar shown (ADR 061), and +// kept on its text across output with Ghostty's viewport pin (ADR 066). import type { GhosttyTerminal } from '../engine/engine-types'; import type { ViewportHost } from './host-types'; +/** The engine calls that keep a view on its text across a write (ADR 066). */ +export type ViewPinEngine = Pick<GhosttyTerminal, 'pinViewport' | 'unpinViewport'>; + /** Where the view is: `y` = 0 shows the bottom screen, `y` > 0 is scrolled into history. */ export class Viewport { /** @@ -12,6 +16,8 @@ export class Viewport { * it moves the view without an announcement, as Terminal's `viewportY` always has. */ y = 0; + /** Rows the pinned row is above the view's top row: 1 while part of it shows, else 0. */ + private pinnedAbove = 0; constructor(private readonly host: ViewportHost) {} @@ -27,14 +33,48 @@ export class Viewport { this.moveTo(Math.max(0, Math.min(scrollbackLength, line)), scrollbackLength); } - /** Scrolls to the bottom, the current output. */ + /** + * Scrolls to the bottom, the current output. Announced even from line 0 while part of the + * row above shows, so the embedder drops its sub-line offset. (webtty, ADR 066) + */ scrollToBottom(): void { - if (this.y === 0) return; + if (this.y === 0 && !this.host.partRowShown()) return; this.y = 0; this.host.scrolled(0); if (this.host.getScrollbackLength() > 0) this.host.showScrollbar(); } + /** + * Before a write: pins Ghostty's viewport on the top row in view, so the write moves it + * with its text (`scroll-to-bottom = no-output`, Ghostty's default; webtty, ADR 066). + * While the renderer shows part of the row above the view (a smooth scroll's sub-line + * offset), that row is pinned: it is in view, and a pin on the active screen would follow + * the output. Returns false, pinning nothing, for a view at the bottom, which follows the + * output. + */ + pin(engine: ViewPinEngine): boolean { + const line = Math.floor(this.y); + const partRow = this.host.partRowShown(); + if (line === 0 && !partRow) return false; + const top = this.host.getScrollbackLength() - line; + this.pinnedAbove = partRow && top > 0 ? 1 : 0; + engine.pinViewport(top - this.pinnedAbove); + return true; + } + + /** + * After the write: the view goes where Ghostty moved the pinned row, which keeps its text + * through new lines, rows pruned at the scrollback limit and a cleared scrollback. Not a + * scroll the user made, so it is not announced and shows no scrollbar. (webtty, ADR 066) + */ + unpin(engine: ViewPinEngine): void { + const row = engine.unpinViewport(); + const scrollback = this.host.getScrollbackLength(); + const fraction = this.y - Math.floor(this.y); + const y = scrollback - row - this.pinnedAbove + fraction; + this.y = Math.max(0, Math.min(scrollback, y)); + } + /** * Ghostty's `jump_to_prompt`: moves by `delta` OSC 133 prompts (negative is up), the * prompt landing as the top row; nothing without marks or past the last one. (webtty, diff --git a/src/client/vendor/ghostty-web/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts index e506528..ab61881 100644 --- a/src/client/vendor/ghostty-web/core/write-pipeline.ts +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -1,9 +1,10 @@ // Derived from coder/ghostty-web lib/terminal.ts (MIT, see LICENSE and PROVENANCE.md in ghostty-web/). -// The write pipeline (controller): a write in its fixed order, a waiting clear at ground, -// the engine write, the synchronized update's timing, the replies, the focus-mode edge, the -// effects, the link cache, the auto-scroll and the echo render, plus clear screen and the -// reply drain (ADR 042, ADR 053, ADR 054, ADR 055, ADR 061, ADR 067); dispose() cancels the -// write callbacks still waiting (ADR 065). +// The write pipeline (controller): a write in its fixed order, the view kept on its text +// across it, a waiting clear at ground, the engine write, the synchronized update's timing, +// the replies, the focus-mode edge, the effects, the link cache, the alternate screen's +// scroll to the bottom and the echo render, plus clear screen and the reply drain (ADR 042, +// ADR 053, ADR 054, ADR 055, ADR 061, ADR 066, ADR 067); dispose() cancels the write +// callbacks still waiting (ADR 065). import type { FrameScheduler, WriteHost } from './host-types'; import { browserFrames } from './render-loop'; @@ -30,7 +31,18 @@ export class WritePipeline { write(data: string | Uint8Array, callback?: () => void): void { const host = this.host; const engine = host.engine()!; - this.feed(data); + // A view scrolled back stays on its text, as in Ghostty (`scroll-to-bottom = + // no-output`): an app that redraws while working (a spinner, a status line) would + // otherwise pull it down on every redraw. coder/ghostty-web scrolled to the bottom on + // any output, calling it xterm.js' behaviour, though xterm.js keeps a scrolled view + // too (webtty, ADR 066). A key or a paste still scrolls to the bottom. A view on a + // selected find match is kept by the find step instead (ADR 055). + const pinned = !host.findAnchored() && !engine.isAlternateScreen() && host.pinView(); + try { + this.feed(data); + } finally { + if (pinned) host.unpinView(); + } // A begin of the app's synchronized update restarts its time (webtty, ADR 067) host.wroteOutput(data); @@ -52,12 +64,10 @@ export class WritePipeline { // The content changed, so cached links are stale host.outputChanged(); - // Auto-scroll to bottom on new output (xterm.js behavior), except while the - // view is on a selected match: the find step keeps it on its rows. The alternate - // screen has no scrollback to keep a view in, and its matches are its own (webtty, ADR 055) - if (host.viewportY() !== 0 && (!host.findAnchored() || engine.isAlternateScreen())) { - host.scrollToBottom(); - } + // The alternate screen has no scrollback to keep a view in, and its matches are its + // own: output that is on it, or turned it on, shows its bottom, without a smooth + // scroll's sub-line offset either (webtty, ADR 055, ADR 066) + if (engine.isAlternateScreen()) host.scrollToBottom(); // The callback runs on the next frame, after the render if (callback) this.runNextFrame(callback); diff --git a/src/client/vendor/ghostty-web/engine/engine-types.ts b/src/client/vendor/ghostty-web/engine/engine-types.ts index 448d480..ff665ec 100644 --- a/src/client/vendor/ghostty-web/engine/engine-types.ts +++ b/src/client/vendor/ghostty-web/engine/engine-types.ts @@ -187,6 +187,15 @@ export interface GhosttyTerminal { * is up). Ghostty's own viewport is left at the bottom. (ADR 057) */ promptJumpRow(top: number, delta: number): number; + /** + * Puts Ghostty's viewport on screen row `row` (0 = oldest scrollback row), where output + * moves it with its text, as Ghostty keeps a scrolled view: on new lines, on rows pruned + * at the scrollback limit, to the oldest row left when its own row is pruned. A row on + * the active screen follows the output instead. Until `unpinViewport`. (ADR 066) + */ + pinViewport(row: number): void; + /** Where the pinned row is now; Ghostty's viewport goes back to the bottom. (ADR 066) */ + unpinViewport(): number; // Scrollback, graphemes, hyperlinks getScrollbackLength(): number; diff --git a/src/client/vendor/ghostty-web/engine/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts index 7f0837b..8a08f74 100644 --- a/src/client/vendor/ghostty-web/engine/official-terminal.ts +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -1,8 +1,10 @@ -// webtty (ADR 042, ADR 047, ADR 061), not from upstream. +// webtty (ADR 042, ADR 047, ADR 061, ADR 066), not from upstream. // The terminal on the official libghostty-vt wasm (facade): `OfficialTerminal` implements // the `GhosttyTerminal` interface the rest of the vendored layer uses, on the wasm instance -// (`EngineHost`) that `Ghostty.createTerminal` passes it. It owns the writes, the size and -// the view offset, and delegates every other area to a binding on one `EngineContext`. +// (`EngineHost`) that `Ghostty.createTerminal` passes it. It owns the writes, the size, +// the view offset and Ghostty's own viewport (moved for a prompt jump or pinned across a +// write, then put back at the bottom), and delegates every other area to a binding on one +// `EngineContext`. import { DirtyState, type GhosttyCell, type RenderStateCursor } from './cell-types'; import { type ConfigSlot, configSlots, EngineConfig } from './engine-config'; @@ -154,8 +156,8 @@ export class OfficialTerminal implements GhosttyTerminal { /** vt_write, or vt_write_until_ground: the bytes consumed, null if ground was not reached. */ private feed(bytes: Uint8Array, text: string | undefined, untilGround: boolean): number | null { - // Writes never leave the view scrolled (terminal.ts scrolls to the bottom after - // writing); the first frame at the bottom repaints every row. + // A write puts the view offset back to the bottom; the next render sets it again + // (renderer.ts, from viewportY) and repaints every row. if (this.offset !== 0) { this.offset = 0; this.render.markAllDirty(); @@ -305,22 +307,39 @@ export class OfficialTerminal implements GhosttyTerminal { } promptJumpRow(top: number, delta: number): number { - const { ex, at, L } = this.ctx; - const S = L.scroll; + const S = this.ctx.L.scroll; this.ctx.info(); // Ghostty moves its viewport from its top row, so it is put on webtty's for the move this.ctx.scrollViewport(S.ROW, top); try { this.ctx.scrollViewport(S.DELTA_PROMPT, delta); - const rc = ex.ghostty_terminal_get(this.ctx.term, L.tdata.SCROLLBAR, at.scrollbar); - if (rc !== 0) return top; - // The offset is a u64; a screen row fits in the low 32 bits - return this.ctx.dv().getUint32(at.scrollbar + L.scrollbar.offset, true); + return this.viewportRow() ?? top; } finally { this.ctx.scrollViewport(S.BOTTOM, 0); } } + pinViewport(row: number): void { + this.ctx.info(); + this.ctx.scrollViewport(this.ctx.L.scroll.ROW, row); + } + + unpinViewport(): number { + // An erased scrollback leaves Ghostty's viewport on the screen, row 0 if it is all gone + const row = this.viewportRow() ?? 0; + this.ctx.scrollViewport(this.ctx.L.scroll.BOTTOM, 0); + return row; + } + + /** The screen row at the top of Ghostty's viewport, or null if Ghostty refuses the read. */ + private viewportRow(): number | null { + const { ex, at, L } = this.ctx; + const rc = ex.ghostty_terminal_get(this.ctx.term, L.tdata.SCROLLBAR, at.scrollbar); + if (rc !== 0) return null; + // The offset is a u64; a screen row fits in the low 32 bits + return this.ctx.dv().getUint32(at.scrollbar + L.scrollbar.offset, true); + } + getMode(mode: number, isAnsi: boolean = false): boolean { return this.ctx.getMode(mode, isAnsi); } diff --git a/src/client/vendor/ghostty-web/glide.ts b/src/client/vendor/ghostty-web/glide.ts index 179bf76..a4bb15c 100644 --- a/src/client/vendor/ghostty-web/glide.ts +++ b/src/client/vendor/ghostty-web/glide.ts @@ -1,4 +1,4 @@ -// webtty (ADR 045), not from upstream. +// webtty (ADR 045, ADR 066), not from upstream. // A value that moves toward a target a frame at a time (object): the one motion behind // scrolling's glides, for the scrollback position (the app's smooth-scroll.ts) and for // the scroll ticks paced to apps (wheel.ts). @@ -150,6 +150,26 @@ export class Glide { this.stop(); } + /** + * Moves the value by `delta`, keeping the motion: the scale it moves on shifted under it + * (output kept the view on its text, ADR 066). The target and a timed glide's start move + * with it, so the glide covers the distance it had left. With `keepTarget`, a moving + * glide still ends on its target: a spring heads there at its velocity, and a timed + * glide's remaining curve is stretched to start from the moved value, so it lands on time. + */ + shift(delta: number, keepTarget = false): void { + if (!keepTarget) { + this.target += delta; + this.from += delta; + } else if (this.moving && !this.omega) { + // A timed glide is at target + (from - target) * (1 - ease(t)): scaling from - target + // moves where it is now by delta and keeps its end and its timing + const left = this.value - this.target; + if (left !== 0) this.from = this.target + ((this.from - this.target) * (left + delta)) / left; + } + this.value += delta; + } + /** Stops where the value is. */ stop(): void { this.moving = false; diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index 34b9b45..d38a328 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -411,6 +411,11 @@ export class CanvasRenderer { this.requestedScrollOffset = offset; } + /** The offset setScrollOffset last asked for, which the next frame shows. (webtty, ADR 066) */ + public get requestedOffset(): number { + return this.requestedScrollOffset; + } + /** * The offset the viewport is shown at, in CSS pixels: viewport row 0 starts * this far below the element's top. Hit testing adds it. (webtty, ADR 043) diff --git a/src/client/vendor/tests/core/glide.test.ts b/src/client/vendor/tests/core/glide.test.ts index 50a5283..19ef1fb 100644 --- a/src/client/vendor/tests/core/glide.test.ts +++ b/src/client/vendor/tests/core/glide.test.ts @@ -56,6 +56,57 @@ describe('Glide', () => { expect([g.value, g.active]).toEqual([4, false]); }); + test('a shift moves a running glide by as much, and it goes on the same way (ADR 066)', () => { + const plain = new Glide(); + const shifted = new Glide(); + for (const g of [plain, shifted]) { + g.retarget(4, { duration: 100 }, 0); + g.step(50, 1e-3); + } + shifted.shift(10); + expect([shifted.value, shifted.target]).toEqual([plain.value + 10, 14]); + for (const g of [plain, shifted]) g.step(75, 1e-3); + expect(shifted.value).toBeCloseTo(plain.value + 10); + for (const g of [plain, shifted]) g.step(200, 1e-3); + expect([shifted.value, shifted.active]).toEqual([14, false]); + }); + + test.each([ + ['an eased glide', { duration: 125, ease: easeOutCubic }], + ['a spring', { duration: 125, omega: 28 }], + ])('%s shifted with its target kept moves at once and still ends there (ADR 066)', (_, pace) => { + const g = new Glide(); + g.jump(10); + g.retarget(0, pace, 0); + g.step(FRAME_MS, 1e-3); + g.step(2 * FRAME_MS, 1e-3); + const before = g.value; + g.shift(3, true); + expect([g.value, g.target]).toEqual([before + 3, 0]); + // It keeps heading down from there, and arrives + g.step(3 * FRAME_MS, 1e-3); + expect(g.value).toBeLessThan(before + 3); + for (let t = 4; t < 60; t++) g.step(t * FRAME_MS, 1e-3); + expect([g.value, g.active]).toEqual([0, false]); + }); + + test('a timed glide shifted with its target kept lands on time', () => { + const plain = new Glide(); + const shifted = new Glide(); + for (const g of [plain, shifted]) { + g.jump(10); + g.retarget(0, { duration: 100 }, 0); + g.step(50, 1e-3); + } + shifted.shift(4, true); + // Part of the way there, part of the shift is left + for (const g of [plain, shifted]) g.step(70, 1e-3); + expect(shifted.value).toBeGreaterThan(plain.value); + expect(shifted.value).toBeLessThan(plain.value + 4); + for (const g of [plain, shifted]) g.step(100, 1e-3); + expect([shifted.value, shifted.active]).toEqual([plain.value, plain.active]); + }); + test('a retarget during a glide continues from its latest frame', () => { const g = new Glide(); g.retarget(4, { duration: 100 }, 0); diff --git a/src/client/vendor/tests/core/kept-view.test.ts b/src/client/vendor/tests/core/kept-view.test.ts new file mode 100644 index 0000000..75e5438 --- /dev/null +++ b/src/client/vendor/tests/core/kept-view.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Terminal } from '../../ghostty-web/terminal'; +import { + engineOf, + openTerminal, + rendererOf, + type TerminalHarness, +} from '../support/terminal-harness'; +import { scrollbackText } from '../support/terminal-text'; + +// Output keeps a scrolled-back view on its text (ADR 066), on the real engine: Ghostty's +// viewport pin carries the view through new lines, through the whole pages Ghostty prunes +// at the scrollback limit (ADR 046) and through a cleared scrollback. + +let h: TerminalHarness | undefined; + +afterEach(() => { + h?.close(); + h = undefined; +}); + +/** An 80 × 5 terminal whose scrollback Ghostty prunes a page (hundreds of rows) at a time. */ +async function capped() { + h = await openTerminal({ cols: 80, rows: 5, scrollbackLines: 1500 }); + return h.term; +} + +/** The text of the view's top row. */ +function topRow(term: Terminal): string { + const engine = engineOf(term); + return scrollbackText(engine, engine.getScrollbackLength() - Math.floor(term.viewportY)).trim(); +} + +let next = 0; +/** Writes `n` numbered lines, a few per write. */ +function lines(term: Terminal, n: number) { + for (let i = 0; i < n; i += 4) + term.write(`L${next++}\r\nL${next++}\r\nL${next++}\r\nL${next++}\r\n`); +} + +describe('a scrolled-back view during output', () => { + test('keeps its text through new lines and through pages pruned at the scrollback limit', async () => { + const term = await capped(); + lines(term, 1600); + term.scrollToLine(40); + const text = topRow(term); + const lengths = [term.getScrollbackLength()]; + for (let i = 0; i < 12; i++) { + lines(term, 100); + lengths.push(term.getScrollbackLength()); + expect(topRow(term)).toBe(text); + } + // Ghostty pruned at least once: the scrollback got shorter by much more than a line + const drops = lengths.slice(1).map((n, i) => lengths[i] - n); + expect(Math.max(...drops)).toBeGreaterThan(100); + }); + + test('whose own rows are pruned lands on the oldest line left, as in Ghostty', async () => { + const term = await capped(); + lines(term, 1600); + term.scrollToLine(term.getScrollbackLength()); + lines(term, 1200); + expect(term.viewportY).toBe(term.getScrollbackLength()); + }); + + test('goes to the bottom when a clear and a reprint, split across writes, erase it', async () => { + const term = await capped(); + lines(term, 200); + term.scrollToLine(30); + term.write('\x1b[2J'); + term.write('\x1b[H\x1b['); + term.write('3J'); + expect(term.viewportY).toBe(0); + lines(term, 12); + expect(term.viewportY).toBe(0); + }); + + test('stays put on a redraw in place, and on the status line webtty writes itself', async () => { + const term = await capped(); + lines(term, 200); + term.scrollToLine(30); + const text = topRow(term); + term.write('\x1b[5;1H\x1b[2Kspinner'); + term.write('\r\n\x1b[33m[webtty] reconnecting\x1b[0m\r\n'); + expect(topRow(term)).toBe(text); + }); + + test('less than a line back, the part row in view is kept; the alternate screen drops it', async () => { + const term = await capped(); + lines(term, 200); + // A smooth scroll shows part of the row above line 0 + rendererOf(term).setScrollOffset(6); + const moves: number[] = []; + term.onScroll((y) => moves.push(y)); + lines(term, 12); + expect(term.viewportY).toBe(12); + term.scrollToLine(0); + term.write('\x1b[?1049h'); + expect(moves).toEqual([0, 0]); + }); +}); diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 779510d..66f19fe 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -202,6 +202,13 @@ function world() { spyOn(core.focus, 'followMode').mockImplementation(() => void log.push('follow focus mode')); spyOn(core.finder, 'step').mockImplementation((now) => void log.push(`find step ${now}`)); spyOn(core.writes, 'drainReplies').mockImplementation(() => void log.push('drain')); + spyOn(core.viewport, 'pin').mockImplementation((e) => { + log.push(`pin ${(e as unknown) === parts.engine}`); + return true; + }); + spyOn(core.viewport, 'unpin').mockImplementation( + (e) => void log.push(`unpin ${(e as unknown) === parts.engine}`), + ); spyOn(core.sync, 'wrote').mockImplementation((data) => void log.push(`wrote ${data}`)); spyOn(core.scrollbar, 'drag').mockImplementation(() => true); spyOn(core.mouse, 'encode').mockImplementation(() => 'report'); @@ -242,12 +249,11 @@ describe('TerminalControllers hosts', () => { test('write: the engine, the emitters, the link cache, the view and the controllers', () => { const { log, parts, term, inner, core, sync } = world(); const host = writeHost(term, inner, core); - expect<unknown[]>([ - host.engine(), - host.disableStdin(), - host.viewportY(), - host.findAnchored(), - ]).toEqual([parts.engine, true, 2, core.finder.anchored]); + expect<unknown[]>([host.engine(), host.disableStdin(), host.findAnchored()]).toEqual([ + parts.engine, + true, + core.finder.anchored, + ]); host.send('r'); host.followFocusMode(); host.bell(); @@ -256,6 +262,8 @@ describe('TerminalControllers hosts', () => { host.notify({ title: 'n', body: '' }); host.outputChanged(); host.scrollToBottom(); + expect(host.pinView()).toBe(true); + host.unpinView(); host.wroteOutput('o'); host.renderEcho(); // Inside the app's synchronized update the echo waits for the frame after it (ADR 067) @@ -270,6 +278,8 @@ describe('TerminalControllers hosts', () => { 'notify n', 'links stale', 'scroll to bottom', + 'pin true', + 'unpin true', 'wrote o', 'render false undefined', ]); diff --git a/src/client/vendor/tests/core/terminal.test.ts b/src/client/vendor/tests/core/terminal.test.ts index bb481e3..d4c5a3f 100644 --- a/src/client/vendor/tests/core/terminal.test.ts +++ b/src/client/vendor/tests/core/terminal.test.ts @@ -454,14 +454,16 @@ describe('Terminal find', () => { expect(term.findPrevious()).toBe(true); }); - test('without a selected match, output scrolls to the bottom as before', async () => { + test('without a selected match, output keeps a scrolled view on its text (ADR 066)', async () => { const { term, frames } = await findTerm(); for (let i = 0; i < 20; i++) term.write(`line ${i}\r\n`); term.find('line'); frames(2); term.scrollToLine(5); + const scrollback = term.getScrollbackLength(); term.write('x\r\n'); - expect(term.viewportY).toBe(0); + expect(term.viewportY).toBe(5 + term.getScrollbackLength() - scrollback); + expect(term.viewportY).toBeGreaterThan(5); }); test('a key or a paste scrolls a view on a selected match to the bottom', async () => { diff --git a/src/client/vendor/tests/core/viewport.test.ts b/src/client/vendor/tests/core/viewport.test.ts index f461eaa..e22224a 100644 --- a/src/client/vendor/tests/core/viewport.test.ts +++ b/src/client/vendor/tests/core/viewport.test.ts @@ -1,13 +1,17 @@ import { describe, expect, test } from 'bun:test'; import { Viewport } from '../../ghostty-web/core/viewport'; -// The view's place in the scrollback: clamped moves, each announced, the scrollbar shown. +// The view's place in the scrollback: clamped moves, each announced, the scrollbar shown, +// and the view kept on its text across a write with the engine's viewport pin (ADR 066). function viewport(scrollback = 100) { const calls: string[] = []; const host = { length: scrollback, + /** Whether a smooth scroll's sub-line offset shows part of the row above. */ + partRow: false, getScrollbackLength: () => host.length, + partRowShown: () => host.partRow, scrolled: (y: number) => calls.push(`scroll ${y}`), showScrollbar: () => calls.push('show'), }; @@ -75,6 +79,14 @@ describe('Viewport', () => { expect(host.length).toBe(0); }); + test('scrollToBottom from line 0 is announced only while part of the row above shows', () => { + const { view, host, calls } = viewport(); + view.scrollToBottom(); + host.partRow = true; + view.scrollToBottom(); + expect([view.y, calls]).toEqual([0, ['scroll 0', 'show']]); + }); + test('setting y moves the view without an announcement', () => { const { view, calls } = viewport(); view.y = 7; @@ -111,3 +123,68 @@ describe('Viewport.jumpToPrompt', () => { expect(view.y).toBe(0); }); }); + +describe('Viewport.pin', () => { + // The write moves Ghostty's pinned row with its text; the view follows it (ADR 066). + /** An engine whose pinned row a write moves to `moved(row)`. */ + function pinEngine(calls: string[], moved: (row: number) => number) { + let pinned = -1; + return { + pinViewport: (row: number) => { + calls.push(`pin ${row}`); + pinned = row; + }, + unpinViewport: () => moved(pinned), + }; + } + + test('a view at the bottom pins nothing: it follows the output', () => { + const { view, calls } = viewport(100); + expect(view.pin(pinEngine(calls, (row) => row))).toBe(false); + expect(calls).toEqual([]); + }); + + test("pins the view's top row and moves to where the write took it, quietly", () => { + const { view, host, calls } = viewport(100); + view.y = 10; + // The write adds 5 lines and prunes 40 old ones: the row is 35 rows nearer the top + const engine = pinEngine(calls, (row) => row - 35); + expect(view.pin(engine)).toBe(true); + host.length = 65; + view.unpin(engine); + expect(calls).toEqual(['pin 90']); + expect(view.y).toBe(65 - 55); + }); + + test('with part of the row above in view, pins that row; a whole line up from the bottom', () => { + const { view, host, calls } = viewport(100); + host.partRow = true; + const engine = pinEngine(calls, (row) => row); + view.pin(engine); + host.length = 103; + view.unpin(engine); + expect(calls).toEqual(['pin 99']); + expect(view.y).toBe(3); + }); + + test('keeps a fraction, and stays within the scrollback when it was cleared', () => { + const { view, host, calls } = viewport(100); + view.y = 4.5; + const engine = pinEngine(calls, () => 0); + view.pin(engine); + expect(calls).toEqual(['pin 96']); + host.length = 0; + view.unpin(engine); + expect(view.y).toBe(0); + }); + + test('at the top with part of a row above, there is no row above to pin', () => { + const { view, host, calls } = viewport(100); + view.y = 100; + host.partRow = true; + const engine = pinEngine(calls, (row) => row); + view.pin(engine); + view.unpin(engine); + expect([calls, view.y]).toEqual([['pin 0'], 100]); + }); +}); diff --git a/src/client/vendor/tests/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts index ce6049d..d464988 100644 --- a/src/client/vendor/tests/core/write-pipeline.test.ts +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -6,8 +6,9 @@ import type { TerminalEffects } from '../../ghostty-web/ghostty'; import { loadGhostty } from '../support/wasm'; // The write pipeline: one write's fixed order against a recording engine and host -// (ADR 061), the effects, the auto-scroll, the echo render, the write callback, the -// reply drain and clear screen at ground (ADR 054) on a real engine. +// (ADR 061), the view kept on its text (ADR 066), the effects, the alternate screen's +// scroll to the bottom, the echo render, the write callback, the sync hold's output (ADR +// 067), the reply drain and clear screen at ground (ADR 054) on a real engine. const NO_EFFECTS: TerminalEffects = { bell: false, @@ -24,6 +25,8 @@ interface Options { anchored?: boolean; alternate?: boolean; disableStdin?: boolean; + /** The engine write throws, as on a wasm trap. */ + throws?: boolean; } /** A pipeline on a recording engine and host: `calls` logs both, in order. */ @@ -33,6 +36,7 @@ function recorded(options: Options = {}) { let effects = options.effects ?? NO_EFFECTS; const engine: WriteEngine = { write: (data) => { + if (options.throws) throw new Error('parse'); calls.push(`engine.write ${JSON.stringify(typeof data === 'string' ? data : [...data])}`); }, writeUntilGround: () => null, @@ -80,16 +84,20 @@ function recordingHost( clipboardWrite: (text) => calls.push(`clipboard ${text}`), notify: (n) => calls.push(`notify ${n.title}/${n.body}`), outputChanged: () => calls.push('outputChanged'), - viewportY: () => options.viewportY ?? 0, findAnchored: () => options.anchored ?? false, scrollToBottom: () => calls.push('scrollToBottom'), + pinView: () => { + calls.push('pinView'); + return (options.viewportY ?? 0) > 0; + }, + unpinView: () => calls.push('unpinView'), wroteOutput: (data) => calls.push(`wroteOutput ${JSON.stringify(data)}`), renderEcho: () => calls.push('renderEcho'), }; } describe('WritePipeline order', () => { - test('write, sync timing, replies, focus edge, effects, link cache, auto-scroll, callback, echo', () => { + test('view pinned, write, sync timing, replies, focus edge, effects, link cache, callback, echo', () => { const { calls, clock, pipeline } = recorded({ effects: { bell: true, @@ -107,7 +115,9 @@ describe('WritePipeline order', () => { pipeline.awaitEcho(); pipeline.write('x', () => called++); expect(calls).toEqual([ + 'pinView', 'engine.write "x"', + 'unpinView', 'wroteOutput "x"', 'reply \x1b[1;1R', 'send "\\u001b[1;1R"', @@ -121,7 +131,6 @@ describe('WritePipeline order', () => { 'notify a/1', 'notify b/2', 'outputChanged', - 'scrollToBottom', 'renderEcho', ]); // The callback waits for the next frame @@ -134,6 +143,7 @@ describe('WritePipeline order', () => { const { calls, clock, pipeline } = recorded(); pipeline.write('x'); expect(calls).toEqual([ + 'pinView', 'engine.write "x"', 'wroteOutput "x"', 'followFocusMode', @@ -152,18 +162,22 @@ describe('WritePipeline order', () => { }); test.each([ - // [anchored on a find match, alternate screen, scrolls to the bottom] - [false, false, true], - [true, false, false], - [true, true, true], - ])( - 'a scrolled view, anchored %p, alternate %p: scrolls to the bottom %p', - (anchored, alternate, scrolls) => { - const { calls, pipeline } = recorded({ viewportY: 3, anchored, alternate }); - pipeline.write('x'); - expect(calls.includes('scrollToBottom')).toBe(scrolls); - }, - ); + // [anchored on a find match, alternate screen, what the view does] + [false, false, ['pinView', 'unpinView']], + [true, false, []], + [false, true, ['scrollToBottom']], + [true, true, ['scrollToBottom']], + ])('a scrolled view, anchored %p, alternate %p: %p', (anchored, alternate, moves) => { + const { calls, pipeline } = recorded({ viewportY: 3, anchored, alternate }); + pipeline.write('x'); + expect(calls.filter((c) => /^(pinView|unpinView|scrollToBottom)$/.test(c))).toEqual(moves); + }); + + test('a write that throws still unpins the view', () => { + const { calls, pipeline } = recorded({ viewportY: 3, throws: true }); + expect(() => pipeline.write('x')).toThrow('parse'); + expect(calls).toEqual(['pinView', 'unpinView']); + }); test('the sync hold sees all the output, the part a waiting clear came after too (ADR 067)', async () => { const term = (await loadGhostty()).createTerminal(20, 5); From ee3557a05d38756c2dd73d8bba62ff0958f49ab7 Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 08:13:52 -0400 Subject: [PATCH 286/295] docs(client): clearing the selection after copy-on-select follows herdr, not Claude Code or pi Claude Code and pi in fullscreen keep their own highlight after copying; the clear webtty does on release is herdr's behaviour (ADR 051, client and config SPECs). --- .../adrs/051.client.mouse-tracking-selection-and-right-click.md | 2 +- docs/specs/client.md | 2 +- docs/specs/config.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md index 3600d91..0b660b9 100644 --- a/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md +++ b/docs/adrs/051.client.mouse-tracking-selection-and-right-click.md @@ -55,7 +55,7 @@ A menu is from the keyboard when no right press (or macOS Ctrl+click, which open ### Config - `rightClickBehavior`: `"contextMenu"` (default), `"copyPaste"`, `"paste"`, `"none"`. `"default"`, the old name of `"contextMenu"`, is still accepted. Any other value falls back to `"contextMenu"`. The default is the same on every platform; Windows Terminal users may prefer `"copyPaste"`. -- `copyOnSelect` now reads as: copy a selection made with the mouse when the mouse button is released, then clear it, as Claude Code and pi clear their own selections once copied. A drag the app receives is not a selection. With `copyOnSelect` off the selection stays, to copy with Cmd+C, Ctrl+Shift+C (ADR 054) or the menu. +- `copyOnSelect` now reads as: copy a selection made with the mouse when the mouse button is released, then clear it, as herdr does once it has copied (Claude Code and pi in fullscreen keep their own highlight after copying). A drag the app receives is not a selection. With `copyOnSelect` off the selection stays, to copy with Cmd+C, Ctrl+Shift+C (ADR 054) or the menu. ## Consequences diff --git a/docs/specs/client.md b/docs/specs/client.md index a0980c1..4cc3e91 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -171,7 +171,7 @@ Controlled by two config keys from `GET /api/config`: | Config | Default | Behavior | |--------|---------|----------| -| `copyOnSelect` | `true` | Copies a mouse selection (drag, double or triple click) to the clipboard once, on mouseup, through the Terminal's `copyOnSelect` option, and clears it; off, nothing is copied and the selection stays. | +| `copyOnSelect` | `true` | Copies a mouse selection (drag, double or triple click) to the clipboard once, on mouseup, through the Terminal's `copyOnSelect` option, and clears it, as herdr does; off, nothing is copied and the selection stays. | | `rightClickBehavior` | `"contextMenu"` | What a right-click the app does not take does: `"contextMenu"` the browser menu (over the textarea, so it offers Copy and Paste); `"copyPaste"` copy and clear a selection, else the menu; `"paste"` paste the clipboard (the Ctrl+V path, ADR 037); `"none"` nothing, no menu. `"default"` is read as `"contextMenu"`. | Both can be active simultaneously. The canvas-based terminal has no DOM text selection — these and the copy keys (Cmd+C, Ctrl+Shift+C, Ctrl+Insert; see below) are the only reliable copy paths. diff --git a/docs/specs/config.md b/docs/specs/config.md index b52a023..085b031 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -113,7 +113,7 @@ All keys are optional — omit any key to use the default value. | `rows` | number | `24` | Initial terminal height in rows | | `cursorStyle` | string | `"bar"` | Default cursor shape: `"bar"` (vertical line), `"block"`, or `"underline"`. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. | | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR; `CSI 0 SP q` and a terminal reset restore it. DEC mode 12 (`CSI ? 12 h/l`) is ignored, as in Ghostty when `cursor-style-blink` is set, so an app that turns blinking off that way (vim's `t_ve`) does not leave the cursor steady. | -| `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released, then clear the highlight, as Claude Code and pi do; off, the selection stays to copy with Cmd+C, Ctrl+Shift+C or the menu. While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | +| `copyOnSelect` | boolean | `true` | Copy a mouse selection (drag, double or triple click) to the clipboard when the button is released, then clear the highlight, as herdr does (Claude Code and pi in fullscreen keep theirs); off, the selection stays to copy with Cmd+C, Ctrl+Shift+C or the menu. While the app tracks the mouse (vim `set mouse=a`, htop, Claude Code fullscreen), a drag goes to the app and is not a selection, so nothing is copied; hold Shift to select and copy in webtty instead. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `rightClickBehavior` | string | `"contextMenu"` | What a right-click does when the app does not take it: `"contextMenu"` shows the browser menu; `"copyPaste"` copies and clears an existing selection, otherwise shows the menu; `"paste"` pastes the clipboard as Ctrl+V does; `"none"` does nothing and shows no menu. While the app tracks the mouse, a right-click goes only to the app and no menu is shown; Shift+right-click applies this setting instead and sends nothing to the app. `"default"`, the old name of `"contextMenu"`, is still accepted; invalid values fall back to `"contextMenu"`. Windows Terminal users may prefer `"copyPaste"`. See [ADR 051](../adrs/051.client.mouse-tracking-selection-and-right-click.md). | | `clipboardWrite` | boolean | `true` | Let programs write the clipboard with OSC 52 (`ESC ] 52 ; c ; <base64> BEL`), as Claude Code, pi, Neovim 0.10+ and tmux `set-clipboard` do over SSH; iTerm2's `OSC 1337 ; Copy` and kitty's OSC 5522 writes too. Writes only: a query (`52;c;?`) is never answered. Only the clipboard (`c`, or an empty or unknown selection) is written; `s` and `p` are ignored, as in Ghostty on macOS. A write is held back after each connect until you press a key or click in the tab, so the replayed history never overwrites the clipboard, and the browser may still refuse it (Chrome needs focus, Firefox recent input, Safari refuses most): the console says so once. `false` ignores all program clipboard writes. See [ADR 052](../adrs/052.client.osc52-clipboard-write.md). | | `bellTitle` | boolean | `true` | When a program rings the bell (BEL), put a 🔔 in front of the tab title until the terminal is focused or a key is pressed, as Ghostty's `bell-features = title` does; it shows in a background tab until you come back. Bells in the output replayed on reload or reconnect are ignored. The tab title itself is the program's title (OSC 0/2) as `<title> \| webtty`, or `<session> \| webtty` while it has none. `false` leaves the title alone on a bell. See [ADR 053](../adrs/053.client.focus-reports-title-and-bell.md). | From 5a7893d917b80e44f0121286fb5e95ec4910ae3d Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 08:31:59 -0400 Subject: [PATCH 287/295] fix(client): a redraw in place repaints only what changed under a scrolled view, the sync scan reads bytes, and only the render loop ends an update - A write no longer resets the view offset and marks every row dirty; the scroll tracker repaints a scrolled view in full only for new scrollback, a move or a whole changed screen, and dirty rows map to canvas rows from the view's line - SyncHold scans bytes as bytes; expire(now) ends a stale update from the render loop only, and held(now) is a read - Glide.shift takes the target to end on; SmoothScroll clamps it to the scrollback - Device tests write during the event run too; a test drives SmoothScroll on the real Viewport pin - ADR 066/067 consequences corrected and dated; config SPEC date; harness header --- .../066.client.output-keeps-scrolled-view.md | 9 ++- docs/adrs/067.client.synchronized-output.md | 6 +- docs/specs/client.md | 2 +- docs/specs/config.md | 2 +- .../scroll/smooth-scroll-devices.test.ts | 52 ++++++++----- .../scroll/smooth-scroll-kept-view.test.ts | 73 +++++++++++++++++++ src/client/features/scroll/smooth-scroll.ts | 10 ++- src/client/vendor/ghostty-web/PROVENANCE.md | 2 +- .../vendor/ghostty-web/core/host-types.ts | 5 +- .../vendor/ghostty-web/core/sync-hold.ts | 32 +++++--- .../vendor/ghostty-web/core/sync-scan.ts | 47 +++++++++++- .../ghostty-web/core/terminal-controllers.ts | 5 +- .../ghostty-web/engine/official-terminal.ts | 8 +- src/client/vendor/ghostty-web/glide.ts | 24 +++--- .../ghostty-web/render/scroll-tracker.ts | 14 ++-- src/client/vendor/ghostty-web/renderer.ts | 11 ++- src/client/vendor/tests/core/glide.test.ts | 21 +++++- .../vendor/tests/core/sync-hold.test.ts | 13 +++- .../vendor/tests/core/sync-scan.test.ts | 33 ++++++++- .../vendor/tests/render/renderer.test.ts | 19 +++++ .../vendor/tests/render/row-band.test.ts | 8 +- .../tests/render/scroll-tracker.test.ts | 3 +- .../vendor/tests/support/terminal-harness.ts | 5 +- 23 files changed, 319 insertions(+), 85 deletions(-) create mode 100644 src/client/features/scroll/smooth-scroll-kept-view.test.ts diff --git a/docs/adrs/066.client.output-keeps-scrolled-view.md b/docs/adrs/066.client.output-keeps-scrolled-view.md index e88abee..214b1bb 100644 --- a/docs/adrs/066.client.output-keeps-scrolled-view.md +++ b/docs/adrs/066.client.output-keeps-scrolled-view.md @@ -2,7 +2,7 @@ **SPEC:** [client](../specs/client.md) **Status:** Accepted -**Date:** 2026-10-14 +**Date:** 2026-10-15 --- @@ -21,8 +21,9 @@ Counting lines by the scrollback's length is not enough. Below the scrollback li On the normal screen, output keeps a scrolled-back view on its text, with Ghostty's own pin: - **Before each write** the write pipeline pins Ghostty's viewport on the view's top row (`Viewport.pin`, `GhosttyTerminal.pinViewport`: `GHOSTTY_SCROLL_VIEWPORT_ROW`). **After it** the binding reads where the pin went (`GHOSTTY_TERMINAL_DATA_SCROLLBAR`) and puts Ghostty's viewport back at the bottom (`unpinViewport`), and the view goes to that row (`Viewport.unpin`). The same text stays in view through new lines, through rows pruned at the scrollback limit and through a clear: a cleared scrollback brings the view to the bottom, and a view whose own rows were pruned lands on the oldest line left, as in Ghostty. The pin is released in a `finally`, so a write that throws cannot leave Ghostty's viewport off the bottom, where the render state reads it. -- **A smooth scroll's sub-line offset.** Less than a line back, webtty's view is at line 0 and the renderer shows part of the row above (`CanvasRenderer.requestedOffset`). A pin on the active screen follows the output, so that row is pinned instead; it is in view. `Viewport.scrollToBottom` is announced from line 0 too while that row shows, so a key or a paste drops the offset and the view follows the output again. +- **A smooth scroll's sub-line offset.** Less than a line back, webtty's view is at line 0 and the renderer shows part of the row above (`CanvasRenderer.requestedOffset`). A pin on the active screen follows the output, so that row is pinned instead; it is in view. `Viewport.scrollToBottom` is announced from line 0 too while that row shows, so a key or a paste drops the offset and the view follows the output again. A view less than a line back is therefore a scrolled view: it stays on its text while output streams, as one further back does. Treating it as at the bottom was rejected: it would snap back on every write, so the first deltas of a scroll up through a running stream would fight the output, the flicker this ADR removes. - **Not a move the user made**: it fires no `onScroll` and does not show the scrollbar. +- **Painting what changed.** A write no longer resets the engine's view offset or marks every row dirty (that followed from writes always ending at the bottom). The renderer's scroll tracker repaints a scrolled-back view in full only when the lines may have moved under it: new scrollback, a move, or every screen row changed. A redraw in place (a spinner, a status line) repaints only the dirty screen rows in view, mapped to their canvas rows from the view's line, and none for a view above the screen. - **A view at the bottom** pins nothing and follows the output as before. The alternate screen has no scrollback to keep a view in: output there still moves the view to the bottom, dropping a sub-line offset too. A view on a selected find match is kept by the find step as before (ADR 055). - **`SmoothScroll` follows the kept view** for every input device (ADR 045): a trackpad's deltas applied at once, a mouse wheel notch's ease-out glide, a spinning wheel's and a slow TrackPoint's linear glides, and a fast TrackPoint's spring. After a write it moves its glide by as many lines as the view moved (`Glide.shift`), keeping the fraction, the velocity and, for a timed glide, its start, so the glide goes on covering the distance it had left, frame by frame as without the output. The exception is a glide heading for the bottom: it still ends there (`shift(delta, keepTarget)` stretches a timed glide's remaining curve to start from the moved value, and a spring keeps heading for its target), or a scroll down could never reach output that keeps coming. Writes that do not go through `onOutput` (the status line `PtyConnection` writes after a reconnect) are caught up with on the next frame or wheel event, before the glide moves. @@ -37,5 +38,7 @@ On the normal screen, output keeps a scrolled-back view on its text, with Ghostt ## Consequences - Scrolling back while an app streams or redraws is smooth, and the view stays on its text, at the scrollback limit too. -- A write while scrolled back costs three more wasm calls (pin, read, unpin): measured on the pinned wasm, 5,000 one-line writes took 4.3 ms pinned against 3.2 ms unpinned, and the pin does not mark the render state for a full redraw. A view at the bottom costs nothing. +- A write while scrolled back costs three more wasm calls (pin, read, unpin): measured on the pinned wasm, 5,000 one-line writes took 4.3 ms pinned against 3.2 ms unpinned. A view at the bottom costs nothing. +- Repainted rows per frame, measured on an 80 × 24 terminal 50 lines back: a redraw in place repaints 0 rows (24 before the write stopped marking every row dirty and the tracker stopped repainting a scrolled view on any dirty row), and a cursor move 0 (24 before). A write that adds lines still repaints the whole view (24): the band's rows are kept by their distance from the bottom, which new lines change; moving the band with the pin is left for later. +- A view less than a line back stays on its text during output, like any scrolled view, until a key, a paste or a scroll down brings it to the bottom. - Coming back to the bottom takes a key, a paste or a scroll, as in Ghostty. diff --git a/docs/adrs/067.client.synchronized-output.md b/docs/adrs/067.client.synchronized-output.md index 05dca87..b3a00ee 100644 --- a/docs/adrs/067.client.synchronized-output.md +++ b/docs/adrs/067.client.synchronized-output.md @@ -2,7 +2,7 @@ **SPEC:** [client](../specs/client.md) **Status:** Accepted -**Date:** 2026-10-14 +**Date:** 2026-10-15 --- @@ -20,7 +20,7 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - `SyncHold` (`core/sync-hold.ts`) holds painting while the engine's mode 2026 is on, for at most `SYNC_HOLD_MAX_MS` (1000 ms) after the update's last begin. - **Every begin restarts the time**, as in Ghostty. The write pipeline hands each write's output to `SyncHold.wrote`, which looks for `CSI ? 2026 h` with `scanSyncBegin` (`core/sync-scan.ts`): alone or among other modes (`CSI ? 25;2026 h`), and split across writes, an unfinished sequence carried to the next one. A repeated begin inside an update, or an end and a new begin in one write, starts a new second. A mode turned on in a form the scan does not read (a C1 CSI) is timed from the first frame that sees it. -- **When the time runs out, the mode is turned off** in the engine (`GhosttyTerminal.setMode(2026, false)`, `GHOSTTY_TERMINAL_OPT_MODE`), as Ghostty's `resetSynchronizedOutput` does, so DECRQM, the echo and the next update all see it off. +- **When the time runs out, the mode is turned off** in the engine (`GhosttyTerminal.setMode(2026, false)`, `GHOSTTY_TERMINAL_OPT_MODE`), as Ghostty's `resetSynchronizedOutput` does, so DECRQM, the echo and the next update all see it off. Only the render loop does it, once a frame (`SyncHold.expire`), on its frame timestamps; `SyncHold.held` and `Terminal.renderHeld` are reads that never change the engine. - **One decision for every painter.** The render loop skips only the render of a held frame: `beforeRender`, the find step, the reply drain and the textarea anchoring still run, so replies are not delayed and a glide keeps its time. The echo render after typed input asks the same `SyncHold`, so it waits too, and renders at once again after the update ends or expires. `Terminal.renderHeld(now)` lets the embedder's own painting follow the same rule: the kitty overlay skips its repaint on a held frame and keeps its dirty state, so it repaints on the first frame after the update. - `Terminal.reset()` (a reconnect replaces the engine before the replay) forgets the update and any unfinished begin. A replay that ends inside an update holds a second at most, like any other. - Input devices need nothing of their own: wheel ticks to an app are paced by `TickPacer` (ADR 045), not by painting, and a held frame only batches the app's redraws into the frame after the update. @@ -38,4 +38,4 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - Measured again on the same session: 0 of 31 frames scrolling up and 0 of 30 scrolling down were torn, with pi sending the same traffic. - Any app using synchronized output (pi, Claude Code, neovim, tmux) is painted only between updates, as in Ghostty. - An app that turns the mode on and never off freezes painting for a second after its last begin; then the mode is off until the app begins another update. -- Each write is scanned for `2026`; output without it costs one `includes` and a look at its last escape. +- Each write is scanned for `2026`. Bytes are searched as bytes (`scanSyncBeginBytes`): output without `2026` costs a byte search, plus decoding the few bytes after an unfinished begin carried from the last write and the tail from its last escape when that is short; only a write with `2026` in it is decoded whole. diff --git a/docs/specs/client.md b/docs/specs/client.md index 4cc3e91..013b308 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -393,5 +393,5 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Configurable padding | Top-level `padding` (px, default `0`) reserves space around the canvas on all sides; the grid is fitted to what remains; the top and left gap is exactly `padding` and the leftover goes to the right and bottom; optional `theme.padding` colours the space `padding` adds (default: `theme.background`) but not the leftover | [ADR 034](../adrs/034.client.padding-config.md) | ✅ | | Edge extension | The leftover strip at the right and bottom takes the edge cells' own background, by repainting the canvas' last pixel row and column across it; always on, no setting | [ADR 035](../adrs/035.client.edge-extend.md) | ✅ | | Theme key mapping | The config's `purple`, `brightPurple` and `selection` are translated to the terminal's `magenta`, `brightMagenta` and `selectionBackground`, which were previously ignored; `selectionForeground` is new and defaults to `background` when `selection` is set | [ADR 036](../adrs/036.client.theme-key-mapping.md) | ✅ | -| Output keeps a scrolled view | On the normal screen, output no longer pulls a scrolled-back view to the bottom (coder/ghostty-web's rule): before each write Ghostty's viewport is pinned on the view's top row, and after it the view goes where the pin went, so the same text stays in view through new lines, through the pages Ghostty prunes at the scrollback limit and through a cleared scrollback; the smooth-scroll glide moves with it for every input device. A key or a paste still scrolls to the bottom, as Ghostty's `scroll-to-bottom = keystroke, no-output`. Ends the flicker when scrolling back through an app that redraws while it works | [ADR 066](../adrs/066.client.output-keeps-scrolled-view.md) | ✅ | +| Output keeps a scrolled view | On the normal screen, output no longer pulls a scrolled-back view to the bottom (coder/ghostty-web's rule): before each write Ghostty's viewport is pinned on the view's top row, and after it the view goes where the pin went, so the same text stays in view through new lines, through the pages Ghostty prunes at the scrollback limit and through a cleared scrollback; the smooth-scroll glide moves with it for every input device. A key or a paste still scrolls to the bottom, as Ghostty's `scroll-to-bottom = keystroke, no-output`. A view less than a line back (a smooth scroll's sub-line offset) is a scrolled view too and stays on its text. A redraw in place repaints only the rows it changed in view. Ends the flicker when scrolling back through an app that redraws while it works | [ADR 066](../adrs/066.client.output-keeps-scrolled-view.md) | ✅ | | Synchronized output | While the app sends a synchronized update (DEC mode 2026), the terminal, the echo of typed input and the kitty overlay do not paint, so an app that redraws the whole screen across several WebSocket messages is never shown half drawn. As in Ghostty, every `CSI ? 2026 h` restarts a one-second timer, and when it runs out the mode is turned off, so an app that never ends an update cannot freeze the view | [ADR 067](../adrs/067.client.synchronized-output.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index 085b031..2ba2787 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -1,6 +1,6 @@ # SPEC: Config -**Last Updated:** 2026-10-14 +**Last Updated:** 2026-10-15 --- diff --git a/src/client/features/scroll/smooth-scroll-devices.test.ts b/src/client/features/scroll/smooth-scroll-devices.test.ts index 53d09ef..7d56bfa 100644 --- a/src/client/features/scroll/smooth-scroll-devices.test.ts +++ b/src/client/features/scroll/smooth-scroll-devices.test.ts @@ -7,7 +7,10 @@ import { WheelStream } from './wheel-stream'; // sequences WheelStream really classifies as a trackpad, a mouse wheel notch, a spinning // wheel and a TrackPoint, slow and fast. Output arriving mid-glide must leave the text in // view where it is, and the glide must go on covering the distance it had left, frame by -// frame as if no output had come; one heading for the bottom still ends there. +// frame as if no output had come; one heading for the bottom still ends there. The output +// comes during the events (a trackpad's run, while its view moves) or after the last one +// (mid-glide). The terminal here stands in for the pin; smooth-scroll-kept-view.test.ts +// drives the real one. /** A wheel event `gap` ms after the one before it. */ interface Step { @@ -87,8 +90,9 @@ function terminal() { /** * Plays `steps` from a view `from` lines back, with a frame every 16 ms, writing `lines` - * new lines `at` ms after the last event (through onOutput, or caught up with on the next - * frame when `quiet`), and returns where the view's top is in the text after each frame. + * new lines `at` ms after the last event, or before it when negative (through onOutput, or + * caught up with on the next frame when `quiet`), and returns where the view's top is in + * the text after each frame. */ function play( steps: Step[], @@ -139,12 +143,17 @@ describe.each(DEVICES)('%s', (_, steps, motion) => { expect(pace && kind(pace)).toBe(motion); }); + /** Halfway through the events, ms after the last one: 0 for a single event. */ + const during = -Math.floor(steps.slice(1).reduce((sum, s) => sum + s.gap, 0) / 2); + test.each([ - ['through onOutput', false], - ['caught up with on the next frame', true], - ])('output mid-glide, %s, keeps the text in view and the glide on its way', (_, quiet) => { + ['after the events, through onOutput', 20, false], + ['after the events, caught up with on the next frame', 20, true], + ['during the events, through onOutput', during, false], + ['during the events, caught up with on the next frame', during, true], + ])('output %s keeps the text in view and the motion on its way', (_, at, quiet) => { const still = play(steps, 200); - const fed = play(steps, 200, { lines: 7, at: 20, quiet }); + const fed = play(steps, 200, { lines: 7, at, quiet }); // Frame by frame the view shows the same text, with or without the output expect(fed.tops.map((top) => +top.toFixed(6))).toEqual( still.tops.map((top) => +top.toFixed(6)), @@ -152,15 +161,22 @@ describe.each(DEVICES)('%s', (_, steps, motion) => { expect(fed.term.viewportY).toBe(still.term.viewportY + 7); }); - test('a scroll down to the bottom ends there, output mid-glide or not', () => { - const down = steps.map((step) => ({ ...step, deltaY: -step.deltaY })); - // From a little less far back than the scroll goes: its last event heads for the bottom - const lines = down.reduce( - (sum, s) => sum + (s.deltaMode === 1 ? s.deltaY : s.deltaY / CELL), - 0, - ); - const fed = play(down, Math.floor(lines) - 1, { lines: 7, at: 20 }); - expect(fed.term.viewportY).toBe(0); - expect(fed.tops.at(-1)).toBe(fed.term.scrollback); - }); + // Output during the events keeps the text, so the rest of the scroll ends short of the + // bottom, as in Ghostty; output after the last event, which heads for the bottom, does not + test( + motion === 'at once' + ? 'a scroll down applied at once to the bottom follows output after it' + : 'a glide down to the bottom ends there through output mid-glide', + () => { + const down = steps.map((step) => ({ ...step, deltaY: -step.deltaY })); + // From a little less far back than the scroll goes: its last event heads for the bottom + const lines = down.reduce( + (sum, s) => sum + (s.deltaMode === 1 ? s.deltaY : s.deltaY / CELL), + 0, + ); + const fed = play(down, Math.floor(lines) - 1, { lines: 7, at: 20 }); + expect(fed.term.viewportY).toBe(0); + expect(fed.tops.at(-1)).toBe(fed.term.scrollback); + }, + ); }); diff --git a/src/client/features/scroll/smooth-scroll-kept-view.test.ts b/src/client/features/scroll/smooth-scroll-kept-view.test.ts new file mode 100644 index 0000000..08a525e --- /dev/null +++ b/src/client/features/scroll/smooth-scroll-kept-view.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import type { Terminal } from '../../vendor/ghostty-web'; +import { + engineOf, + openTerminal, + rendererOf, + type TerminalHarness, +} from '../../vendor/tests/support/terminal-harness'; +import { scrollbackText } from '../../vendor/tests/support/terminal-text'; +import { SmoothScroll } from './smooth-scroll'; +import { WheelStream } from './wheel-stream'; + +// SmoothScroll over a whole vendored Terminal on the real engine (ADR 066): the view is +// kept by ghostty-web's own Viewport.pin and unpin, not a stand-in, and a glide running +// through output shows the same text frame by frame as one without it. + +let h: TerminalHarness | undefined; + +afterEach(() => { + h?.close(); + h = undefined; +}); + +/** The view's top row text and the sub-line offset the renderer was asked to show. */ +function view(term: Terminal): string { + const engine = engineOf(term); + const top = engine.getScrollbackLength() - Math.floor(term.viewportY); + return `${scrollbackText(engine, top).trim()} +${rendererOf(term).requestedOffset}`; +} + +/** + * Scrolls a mouse wheel notch up from 30 lines back and returns the view after each frame, + * writing `lines` new lines on the 3rd frame of the glide when given. + */ +async function glide(lines?: number): Promise<string[]> { + h?.close(); + h = await openTerminal({ cols: 20, rows: 5, scrollbackLines: 1000 }); + const { term, clock } = h; + for (let i = 0; i < 200; i++) term.write(`L${i}\r\n`); + const stream = new WheelStream({ matchMedia: () => ({ matches: false }) }); + const scroll = new SmoothScroll(term, stream, { + window: { devicePixelRatio: 1 }, + performance: { now: () => clock.now() }, + }); + term.scrollToLine(30); + const wheel = { deltaX: 0, deltaY: -3, deltaMode: 1, timeStamp: clock.now() } as WheelEvent; + expect(scroll.handleWheel(wheel)).toBe(true); + const views: string[] = []; + for (let frame = 0; frame < 30; frame++) { + if (frame === 3 && lines) { + let out = ''; + for (let i = 0; i < lines; i++) out += `new ${i}\r\n`; + term.write(out); + scroll.onOutput(); + } + clock.advance(16); + scroll.frame(clock.now()); + views.push(view(term)); + } + scroll.dispose(); + return views; +} + +describe('SmoothScroll on the real Viewport pin', () => { + test('a glide through output shows the same text, frame by frame, as one without it', async () => { + const still = await glide(); + const fed = await glide(7); + expect(fed).toEqual(still); + // It moved, eased in between whole lines, and came to rest + expect(new Set(still).size).toBeGreaterThan(3); + expect(still.at(-1)).toBe(`L${200 - 5 - 30 - 3 + 1} +0`); + }); +}); diff --git a/src/client/features/scroll/smooth-scroll.ts b/src/client/features/scroll/smooth-scroll.ts index 6b58c5a..db433ee 100644 --- a/src/client/features/scroll/smooth-scroll.ts +++ b/src/client/features/scroll/smooth-scroll.ts @@ -111,7 +111,8 @@ export class SmoothScroll implements Disposable { * an onScroll (Viewport.unpin, ADR 066): from any write, so `onOutput`, the next frame and * the next wheel event all call this. The glide moves by as many lines, keeping its * fraction and its motion; one heading for the bottom still ends there, so a scroll down - * reaches output that keeps coming. A view back at the bottom (the scrollback was cleared, + * reaches output that keeps coming, and one heading past the oldest line (pruned at the + * scrollback limit) eases in to the oldest line left. A view back at the bottom (the scrollback was cleared, * or the alternate screen came up) drops the glide, as does a scrollback that shrank under * a sub-line offset. */ @@ -121,7 +122,12 @@ export class SmoothScroll implements Disposable { if (y === 0) { this.jump(0); } else { - this.glide.shift(y - this.line, this.glide.active && this.glide.target === 0); + const delta = y - this.line; + const glide = this.glide; + glide.shift( + delta, + glide.active && glide.target === 0 ? 0 : this.clamp(glide.target + delta), + ); this.glide.value = this.clamp(this.glide.value); this.apply(); } diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index b0caa35..09007a3 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -64,7 +64,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `terminal.ts`, `input-handler.ts`, `selection-manager.ts`: a disposed terminal leaves nothing behind (ADR 065). Every listener, timeout, interval and animation frame goes into a `DisposableStore` and is released by `dispose()`, and after a failed `open()`. Upstream left attached the container's `beforeinput` guard, the canvas `mousedown` and `touchend` focus listeners, the window `focus` and `blur`, the document `visibilitychange` and the scrollbar's document `mouseup` (`cleanupComponents` removed the last four only `if (this.isOpen)`, which `dispose()` had already cleared), and `SelectionManager`'s anonymous canvas listeners and document `mousedown`. Also released now: the second `focus()` try, the scrollbar fade's frames, the write callbacks not run yet, and the context menu's reset timer and close listeners. The browser's timeouts and the cursor blink interval are cleared on `window`, where they are set. `InputHandler`'s `compositionupdate` listener, whose handler did nothing, is removed. The targets, event types, flags and order of the other listeners are unchanged (`vendor/tests/core/listener-snapshot.test.ts`). - `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). - `providers/osc8-link-provider.ts`: `provideLinks` scans the row and a private `linkAt` reads one link's run; the scan skips past each run instead of keeping a set of visited columns, which visits the same cells in the same order and returns the same links (ADR 061). -- `core/write-pipeline.ts`, `core/viewport.ts`, `core/terminal-controllers.ts`, `core/opened-parts.ts`, `renderer.ts`, `engine/engine-types.ts`: output no longer moves a view scrolled back on the normal screen to the bottom (upstream's `write` did, calling it xterm.js' behaviour, which it is not); before each write `Viewport.pin` puts Ghostty's viewport on the view's top row (`GhosttyTerminal.pinViewport`), or on the part row above it a smooth scroll shows (`CanvasRenderer.requestedOffset`), and after it `Viewport.unpin` moves the view to where Ghostty took that row (`unpinViewport`), without an `onScroll` or the scrollbar, as Ghostty's `scroll-to-bottom = no-output`. `Viewport.scrollToBottom` is announced from line 0 while the part row shows. The alternate screen still goes to the bottom (ADR 066). +- `core/write-pipeline.ts`, `core/viewport.ts`, `core/terminal-controllers.ts`, `core/opened-parts.ts`, `renderer.ts`, `engine/engine-types.ts`: output no longer moves a view scrolled back on the normal screen to the bottom (upstream's `write` did, calling it xterm.js' behaviour, which it is not); before each write `Viewport.pin` puts Ghostty's viewport on the view's top row (`GhosttyTerminal.pinViewport`), or on the part row above it a smooth scroll shows (`CanvasRenderer.requestedOffset`), and after it `Viewport.unpin` moves the view to where Ghostty took that row (`unpinViewport`), without an `onScroll` or the scrollbar, as Ghostty's `scroll-to-bottom = no-output`. `Viewport.scrollToBottom` is announced from line 0 while the part row shows. The alternate screen still goes to the bottom. A write no longer resets the view offset and marks every row dirty, and `renderer.ts` maps the engine's dirty rows to canvas rows from the view's line, so a redraw in place under a scrolled view repaints only what changed (ADR 066). - `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067). - `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index f46d6b2..d2c2b9d 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -183,7 +183,10 @@ export interface RenderLoopHost { /** The embedder's callback (attachBeforeRender), so an animation shows in this frame. */ beforeRender(now: number): void; stepFind(now: number): void; - /** Whether this frame's paint waits for the app's synchronized update to end (SyncHold). */ + /** + * Whether this frame's paint waits for the app's synchronized update to end, after ending + * one past its time (SyncHold.expire, then SyncHold.held). + */ renderHeld(now: number): boolean; render(): void; /** Whether replies arose outside a write (a timer, a mode change) and wait for the PTY. */ diff --git a/src/client/vendor/ghostty-web/core/sync-hold.ts b/src/client/vendor/ghostty-web/core/sync-hold.ts index ec2bf43..c656b21 100644 --- a/src/client/vendor/ghostty-web/core/sync-hold.ts +++ b/src/client/vendor/ghostty-web/core/sync-hold.ts @@ -9,7 +9,7 @@ // cannot freeze the view, and DECRQM, the echo and the next update all see it off. import type { SyncHoldHost } from './host-types'; -import { scanSyncBegin } from './sync-scan'; +import { scanSyncBegin, scanSyncBeginBytes } from './sync-scan'; /** Synchronized output: the app brackets an update with `CSI ? 2026 h` and `CSI ? 2026 l`. */ export const SYNCHRONIZED_OUTPUT_MODE = 2026; @@ -24,7 +24,7 @@ export class SyncHold { /** The unfinished mode sequence the last write ended in. */ private carry = ''; - /** `now` is the clock a write is timed on; a frame passes its own timestamp to held(). */ + /** `now` is the clock a write is timed on; a frame passes its own timestamp. */ constructor( private readonly host: SyncHoldHost, private readonly now: () => number = () => performance.now(), @@ -35,28 +35,38 @@ export class SyncHold { * running, restarts the update's time, as each `CSI ? 2026 h` restarts Ghostty's timer. */ wrote(data: string | Uint8Array): void { - const text = typeof data === 'string' ? data : LATIN1.decode(data); - const scan = scanSyncBegin(text, this.carry); + const carry = this.carry; + const scan = + typeof data === 'string' ? scanSyncBegin(data, carry) : scanSyncBeginBytes(data, carry); this.carry = scan.carry; if (scan.begun) this.since = this.now(); else if (!this.host.active()) this.since = null; } /** - * Whether painting at `now` holds: inside an update that began under SYNC_HOLD_MAX_MS - * ago. Past that, the mode is turned off and painting goes on. + * The render loop's step, once a frame at `now`: an update past SYNC_HOLD_MAX_MS since + * its last begin is ended, turning the mode off in the engine. Only the loop calls this, so + * asking `held` never changes the engine. */ - held(now: number = this.now()): boolean { + expire(now: number): void { if (!this.host.active()) { this.since = null; - return false; + return; } // An update begun in a form the scan does not read (a C1 CSI) is timed from its first frame this.since ??= now; - if (now - this.since < SYNC_HOLD_MAX_MS) return true; + if (now - this.since < SYNC_HOLD_MAX_MS) return; this.host.end(); this.since = null; - return false; + } + + /** + * Whether painting at `now` holds: inside an update that began under SYNC_HOLD_MAX_MS + * ago, or one the render loop has not timed yet. A read: an update past its time is + * ended by the loop's `expire`. + */ + held(now: number = this.now()): boolean { + return this.host.active() && (this.since === null || now - this.since < SYNC_HOLD_MAX_MS); } /** Forgets the update and any unfinished sequence: the engine was replaced. */ @@ -65,5 +75,3 @@ export class SyncHold { this.carry = ''; } } - -const LATIN1 = new TextDecoder('latin1'); diff --git a/src/client/vendor/ghostty-web/core/sync-scan.ts b/src/client/vendor/ghostty-web/core/sync-scan.ts index 3ab54a0..d33564e 100644 --- a/src/client/vendor/ghostty-web/core/sync-scan.ts +++ b/src/client/vendor/ghostty-web/core/sync-scan.ts @@ -1,7 +1,8 @@ // webtty (ADR 067), not from upstream. // The synchronized-update scanner (util): finds where the app begins a synchronized update // (`CSI ? 2026 h`, alone or among other modes) in output, carrying an unfinished sequence -// across output chunks, as the cursor blink scanner does (engine/cursor-blink.ts). +// across output chunks, as the cursor blink scanner does (engine/cursor-blink.ts). Bytes +// are searched as bytes: only a chunk with `2026` in it is decoded whole. /** A DEC private mode set whose modes include 2026. */ const SYNC_BEGIN_SEQ = /\x1b\[\?(?:\d*;)*2026(?:;\d*)*h/; @@ -22,3 +23,47 @@ export function scanSyncBegin(data: string, carry = ''): { begun: boolean; carry const tail = esc >= 0 && text.length - esc <= MAX_CARRY ? text.slice(esc) : ''; return { begun, carry: MODE_SET_PREFIX.test(tail) ? tail : '' }; } + +const LATIN1 = new TextDecoder('latin1'); + +/** Whether `bytes` hold the digits `2026`. */ +function has2026(bytes: Uint8Array): boolean { + let i = bytes.indexOf(0x32); + while (i >= 0 && i + 3 < bytes.length) { + if (bytes[i + 1] === 0x30 && bytes[i + 2] === 0x32 && bytes[i + 3] === 0x36) return true; + i = bytes.indexOf(0x32, i + 1); + } + return false; +} + +/** A digit or `;`: a byte of a DEC mode list. */ +function isModeListByte(byte: number): boolean { + return (byte >= 0x30 && byte <= 0x39) || byte === 0x3b; +} + +/** + * `scanSyncBegin` over bytes, with the same result, decoding only what it must: a chunk + * without `2026` can only finish a begin the carry started (its mode list and the `h`), and + * leaves only the tail from its last ESC to carry. + */ +export function scanSyncBeginBytes( + data: Uint8Array, + carry = '', +): { begun: boolean; carry: string } { + if (has2026(data)) return scanSyncBegin(LATIN1.decode(data), carry); + let begun = false; + if (carry !== '') { + let end = 0; + // The digits and semicolons that go on the carry's mode list, and the byte after them + while (end < data.length && isModeListByte(data[end])) end++; + begun = SYNC_BEGIN_SEQ.test(carry + LATIN1.decode(data.subarray(0, end + 1))); + } + const esc = data.lastIndexOf(0x1b); + let tail = ''; + if (esc >= 0) { + if (data.length - esc <= MAX_CARRY) tail = LATIN1.decode(data.subarray(esc)); + } else if (carry !== '' && carry.length + data.length <= MAX_CARRY) { + tail = carry + LATIN1.decode(data); + } + return { begun, carry: MODE_SET_PREFIX.test(tail) ? tail : '' }; +} diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index 65d79f1..0c4acdf 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -249,7 +249,10 @@ export function renderLoopHost(inner: Inner, core: TerminalControllers): RenderL live: () => inner.live(), beforeRender: (now) => inner.beforeRender(now), stepFind: (now) => core.finder.step(now), - renderHeld: (now) => core.sync.held(now), + renderHeld: (now) => { + core.sync.expire(now); + return core.sync.held(now); + }, render: () => inner.render(false), hasReplies: () => inner.parts()?.engine.hasResponse() ?? false, drainReplies: () => core.writes.drainReplies(), diff --git a/src/client/vendor/ghostty-web/engine/official-terminal.ts b/src/client/vendor/ghostty-web/engine/official-terminal.ts index 8a08f74..20085b8 100644 --- a/src/client/vendor/ghostty-web/engine/official-terminal.ts +++ b/src/client/vendor/ghostty-web/engine/official-terminal.ts @@ -156,12 +156,8 @@ export class OfficialTerminal implements GhosttyTerminal { /** vt_write, or vt_write_until_ground: the bytes consumed, null if ground was not reached. */ private feed(bytes: Uint8Array, text: string | undefined, untilGround: boolean): number | null { - // A write puts the view offset back to the bottom; the next render sets it again - // (renderer.ts, from viewportY) and repaints every row. - if (this.offset !== 0) { - this.offset = 0; - this.render.markAllDirty(); - } + // The view's offset is kept: a write leaves Ghostty's viewport at the bottom (a pin is + // released after it, ADR 066), and the renderer repaints what the write changed. const { ex, at, L } = this.ctx; let consumed: number | null = bytes.length; // until_ground takes NULL for no bytes, and then only says whether it is at ground diff --git a/src/client/vendor/ghostty-web/glide.ts b/src/client/vendor/ghostty-web/glide.ts index a4bb15c..3e98f62 100644 --- a/src/client/vendor/ghostty-web/glide.ts +++ b/src/client/vendor/ghostty-web/glide.ts @@ -152,21 +152,21 @@ export class Glide { /** * Moves the value by `delta`, keeping the motion: the scale it moves on shifted under it - * (output kept the view on its text, ADR 066). The target and a timed glide's start move - * with it, so the glide covers the distance it had left. With `keepTarget`, a moving - * glide still ends on its target: a spring heads there at its velocity, and a timed - * glide's remaining curve is stretched to start from the moved value, so it lands on time. + * (output kept the view on its text, ADR 066). The glide then ends on `to`, by default + * the target moved by as much, so it covers the distance it had left: a spring heads there + * at its velocity, and a timed glide's remaining curve is stretched to run from the moved + * value to `to`, so it lands on time. The caller passes the old target to keep it, or a + * moved target clamped to where the value may go. */ - shift(delta: number, keepTarget = false): void { - if (!keepTarget) { - this.target += delta; - this.from += delta; - } else if (this.moving && !this.omega) { - // A timed glide is at target + (from - target) * (1 - ease(t)): scaling from - target - // moves where it is now by delta and keeps its end and its timing + shift(delta: number, to = this.target + delta): void { + if (this.moving && !this.omega) { + // A timed glide is at target + (from - target) * (1 - ease(t)): this keeps 1 - ease(t) + // with the moved value and the new target const left = this.value - this.target; - if (left !== 0) this.from = this.target + ((this.from - this.target) * (left + delta)) / left; + if (left === 0) this.from += to - this.target; + else this.from = to + ((this.from - this.target) * (this.value + delta - to)) / left; } + this.target = to; this.value += delta; } diff --git a/src/client/vendor/ghostty-web/render/scroll-tracker.ts b/src/client/vendor/ghostty-web/render/scroll-tracker.ts index 0a6ec12..30fd47a 100644 --- a/src/client/vendor/ghostty-web/render/scroll-tracker.ts +++ b/src/client/vendor/ghostty-web/render/scroll-tracker.ts @@ -23,9 +23,10 @@ export class ScrollTracker { /** * Compares this frame, at `viewportY` (whole line `view`), with the last one. A * fractional viewportY (ghostty-web's own eased scroll) repaints every row, and so do - * lines that moved while scrolled or on a move. At the bottom, the screen's rows are - * repainted as dirty and the rows out of view when they come into view. `forceAll` - * skips the dirty-row scan. + * lines that moved on a move, or under a view scrolled back: new scrollback, or every + * screen row changed. Otherwise the screen's dirty rows are repainted as dirty, at the + * bottom or scrolled back (a redraw in place, which keeps the view on its text, ADR 066), + * and the rows out of view when they come into view. `forceAll` skips the dirty-row scan. */ track(source: RowSource, viewportY: number, view: number, forceAll: boolean): FrameMoves { const dirtyRows: number[] = []; @@ -39,9 +40,10 @@ export class ScrollTracker { forceAll = true; } this.lastViewportY = viewportY; - const linesMoved = - source.scrollbackLength !== this.lastScrollbackLength || dirtyRows.length > 0; - if (linesMoved && (view > 0 || moved)) forceAll = true; + const grew = source.scrollbackLength !== this.lastScrollbackLength; + const linesMoved = grew || dirtyRows.length > 0; + const shifted = grew || dirtyRows.length >= source.rows; + if (linesMoved && (moved || (view > 0 && shifted))) forceAll = true; this.lastScrollbackLength = source.scrollbackLength; return { dirtyRows, linesMoved, repaintAll: forceAll }; } diff --git a/src/client/vendor/ghostty-web/renderer.ts b/src/client/vendor/ghostty-web/renderer.ts index d38a328..7d1b946 100644 --- a/src/client/vendor/ghostty-web/renderer.ts +++ b/src/client/vendor/ghostty-web/renderer.ts @@ -225,7 +225,7 @@ export class CanvasRenderer { const top = this.overscan + this.bandY - view; const state = this.rowState; if (forceAll) state.fill(ROW_INVALID); - else this.invalidateMoved(moves, top, rows); + else this.invalidateMoved(moves, top + view, top, rows); this.decorations.mark(state, top, view, cols, source.scrollbackLength); // The cursor is drawn over its cell after the rows. Repaint the cell the last frame's @@ -298,9 +298,12 @@ export class CanvasRenderer { return Math.min(this.requestedScrollOffset, maxOffset); } - /** Invalidates the rows the engine changed and, when the lines moved, the rows out of view. */ - private invalidateMoved(moves: FrameMoves, top: number, rows: number): void { - for (const y of moves.dirtyRows) changeRow(this.rowState, top + y); + /** + * Invalidates the rows the engine changed, its screen row 0 being canvas row `screenTop`, + * and, when the lines moved, the rows out of the view from `top`. + */ + private invalidateMoved(moves: FrameMoves, screenTop: number, top: number, rows: number): void { + for (const y of moves.dirtyRows) changeRow(this.rowState, screenTop + y); if (moves.linesMoved) invalidateOutOfView(this.rowState, top, rows); } diff --git a/src/client/vendor/tests/core/glide.test.ts b/src/client/vendor/tests/core/glide.test.ts index 19ef1fb..d55866d 100644 --- a/src/client/vendor/tests/core/glide.test.ts +++ b/src/client/vendor/tests/core/glide.test.ts @@ -81,7 +81,7 @@ describe('Glide', () => { g.step(FRAME_MS, 1e-3); g.step(2 * FRAME_MS, 1e-3); const before = g.value; - g.shift(3, true); + g.shift(3, 0); expect([g.value, g.target]).toEqual([before + 3, 0]); // It keeps heading down from there, and arrives g.step(3 * FRAME_MS, 1e-3); @@ -98,7 +98,7 @@ describe('Glide', () => { g.retarget(0, { duration: 100 }, 0); g.step(50, 1e-3); } - shifted.shift(4, true); + shifted.shift(4, 0); // Part of the way there, part of the shift is left for (const g of [plain, shifted]) g.step(70, 1e-3); expect(shifted.value).toBeGreaterThan(plain.value); @@ -107,6 +107,23 @@ describe('Glide', () => { expect([shifted.value, shifted.active]).toEqual([plain.value, plain.active]); }); + test('a timed glide shifted to a clamped target eases in to it on time', () => { + const g = new Glide(); + g.retarget(10, { duration: 100, ease: easeOutCubic }, 0); + g.step(50, 1e-3); + // The target moved past where the value may go (the oldest line): it ends there, at + // 10.1, not at 10.3 + g.shift(0.3, 10.1); + const values: number[] = []; + for (let now = 60; now <= 100; now += 10) { + g.step(now, 1e-3); + values.push(g.value); + } + expect(values).toEqual([...values].sort((a, b) => a - b)); + expect(values.at(-1)).toBe(10.1); + expect(g.active).toBe(false); + }); + test('a retarget during a glide continues from its latest frame', () => { const g = new Glide(); g.retarget(4, { duration: 100 }, 0); diff --git a/src/client/vendor/tests/core/sync-hold.test.ts b/src/client/vendor/tests/core/sync-hold.test.ts index a251b6e..69a8865 100644 --- a/src/client/vendor/tests/core/sync-hold.test.ts +++ b/src/client/vendor/tests/core/sync-hold.test.ts @@ -83,21 +83,29 @@ describe('SyncHold', () => { expect(hold.held(600 + SYNC_HOLD_MAX_MS - 1)).toBe(true); }); - test('an update past its time turns the mode off once, and the next begin holds again', () => { + test("an update past its time is ended once, by the render loop's expire, and the next begin holds again", () => { const { mode, hold, write } = standIn(); write(0, `${BEGIN}stuck`); + // Asking does not end it expect(hold.held(SYNC_HOLD_MAX_MS)).toBe(false); + expect([mode.on, mode.ends]).toEqual([true, 0]); + hold.expire(SYNC_HOLD_MAX_MS); expect([mode.on, mode.ends]).toEqual([false, 1]); + hold.expire(SYNC_HOLD_MAX_MS + 16); expect(hold.held(SYNC_HOLD_MAX_MS + 16)).toBe(false); expect(mode.ends).toBe(1); write(5000, BEGIN); + hold.expire(5016); expect(hold.held(5016)).toBe(true); }); test('a mode turned on unseen (a C1 CSI) is timed from the first frame that sees it', () => { const { mode, hold } = standIn(); mode.on = true; - expect(hold.held(300)).toBe(true); + expect(hold.held(0)).toBe(true); + hold.expire(300); + expect(hold.held(300 + SYNC_HOLD_MAX_MS - 1)).toBe(true); + hold.expire(300 + SYNC_HOLD_MAX_MS); expect(hold.held(300 + SYNC_HOLD_MAX_MS)).toBe(false); expect(mode.ends).toBe(1); }); @@ -111,6 +119,7 @@ describe('SyncHold', () => { write(700, '26h'); // Not a begin after the reset: the mode is on, timed from the first frame mode.on = true; + hold.expire(800); expect(hold.held(800 + SYNC_HOLD_MAX_MS - 1)).toBe(true); }); }); diff --git a/src/client/vendor/tests/core/sync-scan.test.ts b/src/client/vendor/tests/core/sync-scan.test.ts index a2c62a9..697b358 100644 --- a/src/client/vendor/tests/core/sync-scan.test.ts +++ b/src/client/vendor/tests/core/sync-scan.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test'; -import { scanSyncBegin } from '../../ghostty-web/core/sync-scan'; +import { scanSyncBegin, scanSyncBeginBytes } from '../../ghostty-web/core/sync-scan'; -// The synchronized-update scanner (ADR 067): which mode sets begin an update, and a -// begin split across output chunks. +// The synchronized-update scanner (ADR 067): which mode sets begin an update, a begin +// split across output chunks, and the byte scan agreeing with the string scan. describe('scanSyncBegin', () => { test.each([ @@ -40,3 +40,30 @@ describe('scanSyncBegin', () => { expect(scanSyncBegin(`\x1b[?${'1;'.repeat(20)}`).carry).toBe(''); }); }); + +describe('scanSyncBeginBytes', () => { + const encode = (text: string) => Uint8Array.from(text, (c) => c.charCodeAt(0)); + + test('finds what scanSyncBegin finds, and carries the same, over any chunking', () => { + const output = + 'a\x1b[?25l\x1b[?2026hrow\x1b[2K\x1b[?2026l\x1b[?1;2;2026;1049hx\x1b[?12026h' + + `\x1b[?${'2;'.repeat(20)}2026h\x1b[?2026h\x1b[31m\x1b[?2026`; + let seed = 7; + const rng = () => { + seed = (seed * 1103515245 + 12345) % 2 ** 31; + return seed / 2 ** 31; + }; + for (let run = 0; run < 200; run++) { + let text = { begun: false, carry: '' }; + let bytes = { begun: false, carry: '' }; + for (let at = 0; at < output.length; ) { + const end = at + 1 + Math.floor(rng() * 12); + const chunk = output.slice(at, end); + at = end; + text = scanSyncBegin(chunk, text.carry); + bytes = scanSyncBeginBytes(encode(chunk), bytes.carry); + expect(bytes).toEqual(text); + } + } + }); +}); diff --git a/src/client/vendor/tests/render/renderer.test.ts b/src/client/vendor/tests/render/renderer.test.ts index 2bb2ea0..5e660f5 100644 --- a/src/client/vendor/tests/render/renderer.test.ts +++ b/src/client/vendor/tests/render/renderer.test.ts @@ -120,6 +120,25 @@ describe('CanvasRenderer', () => { term.free(); }); + // Output keeps a scrolled-back view on its text (ADR 066): a redraw in place repaints the + // rows it changed (the erased row, and the rows the cursor left and entered) and the rows + // their run draws, not the whole view + test.each([ + ['above the screen paints no row', 20, 0], + ['over five screen rows paints those rows only', 3, 5], + ])('a redraw in place under a view %s', async (_name, view, painted) => { + const t = await writtenTerminal(); + const live = rasterRenderer(1); + live.r.render(t.term, true, view, t.provider, 0); + live.r.render(t.term, false, view, t.provider, 0); + const before = live.drawn.length; + t.term.write('\x1b[2;1H\x1b[2Kspinner'); + live.r.render(t.term, false, view, t.provider, 0); + expect(live.drawn.length - before).toBe(painted); + expect(live.shown()).toEqual(previousFrame(t, view, 0, 1)); + t.term.free(); + }); + test('a buffer that asks for a full redraw gets one', async () => { const { term, provider } = await writtenTerminal(); const live = rasterRenderer(1); diff --git a/src/client/vendor/tests/render/row-band.test.ts b/src/client/vendor/tests/render/row-band.test.ts index 38222bf..40e2230 100644 --- a/src/client/vendor/tests/render/row-band.test.ts +++ b/src/client/vendor/tests/render/row-band.test.ts @@ -172,11 +172,13 @@ function simulate(seed: number, frames: number) { else if (r < 0.6) view = Math.floor(rng() * (scrollback + 1)); view = Math.max(0, Math.min(scrollback, view)); const above = view < scrollback && rng() < 0.3 ? 1 : 0; - const dirty = view === 0 && rng() < 0.3 ? [Math.floor(rng() * rows)] : []; + // Output redraws a screen row in place, at the bottom or under a view kept scrolled back + // (ADR 066); a move with output repaints every row + const moved = view !== lastView; + const dirty = rng() < (moved ? 0.1 : 0.3) ? [Math.floor(rng() * rows)] : []; for (const y of dirty) version[scrollback + y]++; let force = frame === 0; - const moved = view !== lastView; if (dirty.length > 0 && moved) force = true; lastView = view; @@ -202,7 +204,7 @@ function simulate(seed: number, frames: number) { const top = overscan + band - view; if (force) state.fill(ROW_INVALID); else { - for (const y of dirty) changeRow(state, top + y); + for (const y of dirty) changeRow(state, top + view + y); if (dirty.length > 0) invalidateOutOfView(state, top, rows); } diff --git a/src/client/vendor/tests/render/scroll-tracker.test.ts b/src/client/vendor/tests/render/scroll-tracker.test.ts index f42075e..5abbe7e 100644 --- a/src/client/vendor/tests/render/scroll-tracker.test.ts +++ b/src/client/vendor/tests/render/scroll-tracker.test.ts @@ -30,7 +30,8 @@ describe('ScrollTracker', () => { ['dirty rows at the bottom repaint as dirty', [0, 10], [0, 10], [1, 3], true, false], ['new scrollback at the bottom', [0, 10], [0, 11], [], true, false], ['new scrollback while scrolled', [2, 10], [2, 11], [], true, true], - ['a dirty row while scrolled', [2, 10], [2, 10], [0], true, true], + ['a dirty row while scrolled', [2, 10], [2, 10], [0], true, false], + ['every row dirty while scrolled', [2, 10], [2, 10], [0, 1, 2, 3], true, true], ['a whole-line move alone', [0, 10], [3, 10], [], false, false], ['a whole-line move with output', [0, 10], [3, 10], [0], true, true], ['a fractional viewportY', [0, 10], [0.5, 10], [], false, true], diff --git a/src/client/vendor/tests/support/terminal-harness.ts b/src/client/vendor/tests/support/terminal-harness.ts index 0f839bf..6ba2581 100644 --- a/src/client/vendor/tests/support/terminal-harness.ts +++ b/src/client/vendor/tests/support/terminal-harness.ts @@ -1,7 +1,8 @@ // A whole vendored Terminal opened on fakes (test support, ADR 064): the fake document, // window and container of the listener snapshot, the real wasm engine and a manual clock -// for frames, timeouts and performance.now(), so a test drives the terminal through its public API only. The -// vendored code reads globals; they are swapped in by openTerminal and put back by close(). +// for frames, timeouts and performance.now(), so a test drives the terminal through its +// public API only. The vendored code reads globals; they are swapped in by openTerminal and +// put back by close(). import { ManualClock } from '../../../test-support/manual-clock'; import type { ListenerRecord } from '../../../test-support/recording-target'; From ede827a105ffc50665b6dc6888a60820869c3d7f Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 09:31:06 -0400 Subject: [PATCH 288/295] fix(pty): deleting a session hangs up its shell, which an interactive shell ignored SIGTERM from --- src/pty/bun-backend.ts | 5 ++++- src/pty/index.test.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pty/bun-backend.ts b/src/pty/bun-backend.ts index 4c44c83..5286cd2 100644 --- a/src/pty/bun-backend.ts +++ b/src/pty/bun-backend.ts @@ -48,7 +48,10 @@ export function spawnBunPty(options: PtySpawnOptions): PtyProcess { proc.terminal?.resize(cols, rows); }, kill() { - proc.kill(); + // SIGHUP, as a terminal sends when it closes: an interactive shell ignores the + // default SIGTERM, which left it and the apps in it running after a session was + // deleted. node-pty sends SIGHUP too. The shell passes the hangup to its jobs. + proc.kill('SIGHUP'); }, }; } diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 261bb40..1f0f066 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -124,4 +124,21 @@ describe('spawnPty', () => { }, 20000, ); + + test.skipIf(process.platform === 'win32')( + 'kill ends an interactive shell and the app running in it', + async () => { + // An interactive shell ignores SIGTERM; the app is a foreground job of the shell. + const pty = spawnPty({ ...options(), shell: '/bin/sh', env: { ENV: '/dev/null' } }); + const received: string[] = []; + pty.onData((d) => received.push(d)); + const exited = new Promise<void>((resolve) => pty.onExit(() => resolve())); + pty.write("exec sh -i -c 'echo APP-$((40+2)); sleep 30'\n"); + await waitForData(received, 'APP-42'); + pty.kill(); + await exited; + expect(() => process.kill(pty.pid, 0)).toThrow(); + }, + 10000, + ); }); From 28719f900759860334937f165fd34d9fb9a755de Mon Sep 17 00:00:00 2001 From: jesse23 <wenjia.peng@siemens.com> Date: Thu, 15 Oct 2026 09:48:08 -0400 Subject: [PATCH 289/295] fix(scripts): scroll-bench records at the page's size, finds stream.sh's bar, points where the app scrolls, and can dump each run's frames --- scripts/scroll-bench/bench-args.ts | 8 +++- scripts/scroll-bench/bench.ts | 19 ++++++-- scripts/scroll-bench/cdp.ts | 6 +++ scripts/scroll-bench/dump.ts | 26 +++++++++++ scripts/scroll-bench/measure.ts | 51 ++++++++++++++++----- scripts/scroll-bench/page.ts | 9 ++-- scripts/scroll-bench/scenarios.example.json | 14 ++++-- scripts/scroll-bench/scenarios.test.ts | 5 ++ scripts/scroll-bench/scenarios.ts | 26 +++++++++++ scripts/scroll-bench/stream.sh | 5 +- 10 files changed, 143 insertions(+), 26 deletions(-) create mode 100644 scripts/scroll-bench/dump.ts diff --git a/scripts/scroll-bench/bench-args.ts b/scripts/scroll-bench/bench-args.ts index e9fb3ad..5ce6434 100644 --- a/scripts/scroll-bench/bench-args.ts +++ b/scripts/scroll-bench/bench-args.ts @@ -16,6 +16,8 @@ export interface BenchArgs { inputs: InputName[]; runs: number; cdpPort: number; + /** A folder to save each run's frames in, to check a number against the pictures. */ + dump?: string; } /** The CDP port the README's Chrome command opens. */ @@ -24,6 +26,7 @@ export const DEFAULT_RUNS = 2; export const USAGE = `usage: bun scripts/scroll-bench/bench.ts --base [label=]http://localhost:PORT ... --scenarios FILE [--inputs trackpad,wheel,trackpoint] [--runs N] [--cdp-port PORT] + [--dump DIR] --base a server to measure; repeat it to compare builds (label defaults to host:port; letters, digits and . _ : - only) @@ -31,6 +34,7 @@ export const USAGE = `usage: bun scripts/scroll-bench/bench.ts --base [label=]ht --inputs the input profiles to play, comma separated (default: all) --runs runs of each scenario and input per server (default ${DEFAULT_RUNS}) --cdp-port the --remote-debugging-port of an already running Chrome (default ${DEFAULT_CDP_PORT}) + --dump save each run's start screenshot, recorded frames and blink list under DIR inputs: ${INPUT_NAMES.map((name) => ` ${name.padEnd(12)} ${INPUT_PROFILES[name].description}`).join('\n')}`; @@ -88,6 +92,7 @@ export function parseBenchArgs(argv: readonly string[]): BenchArgs { let inputs = INPUT_NAMES; let runs = DEFAULT_RUNS; let cdpPort = DEFAULT_CDP_PORT; + let dump: string | undefined; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; const value = argv[i + 1]; @@ -98,11 +103,12 @@ export function parseBenchArgs(argv: readonly string[]): BenchArgs { else if (flag === '--inputs') inputs = parseInputs(value); else if (flag === '--runs') runs = positiveInt(flag, value); else if (flag === '--cdp-port') cdpPort = positiveInt(flag, value); + else if (flag === '--dump') dump = value; else throw new Error(`unknown flag ${flag}`); } if (bases.length === 0) throw new Error('--base: give at least one server'); if (!scenariosFile) throw new Error('--scenarios: give the scenarios file'); const labels = new Set(bases.map((b) => b.label)); if (labels.size !== bases.length) throw new Error('--base: labels must be unique'); - return { bases, scenariosFile, inputs, runs, cdpPort }; + return { bases, scenariosFile, inputs, runs, cdpPort, ...(dump ? { dump } : {}) }; } diff --git a/scripts/scroll-bench/bench.ts b/scripts/scroll-bench/bench.ts index c06b875..2d8bb45 100644 --- a/scripts/scroll-bench/bench.ts +++ b/scripts/scroll-bench/bench.ts @@ -24,8 +24,9 @@ import path from 'node:path'; import { type BenchArgs, basesForRun, parseBenchArgs, USAGE } from './bench-args'; import { type Cdp, connectCdp, sleep } from './cdp'; +import { writeDump } from './dump'; import { INPUT_PROFILES, type InputName } from './inputs'; -import { measureScroll, measureStream, startScenario } from './measure'; +import { measureScroll, measureStream, type RecordSink, startScenario } from './measure'; import { loadScenarios, type Scenario } from './scenarios'; import { benchSessionId, createSession, deleteSession } from './session-api'; import { type Result, resultLine, summaryTable } from './summary'; @@ -37,8 +38,10 @@ async function runOne( scenario: Scenario, input: InputName, run: number, + dump?: string, ): Promise<Result> { const id = benchSessionId([base.label, scenario.name, input, run]); + const sink: RecordSink | undefined = dump ? (record) => writeDump(dump, id, record) : undefined; const session = await createSession(base.url, id); try { await startScenario(cdp, session.url, scenario); @@ -52,9 +55,17 @@ async function runOne( }; const profile = INPUT_PROFILES[input]; if (scenario.kind === 'stream') { - return { kind: 'stream', ...key, ...(await measureStream(cdp, profile, key.direction)) }; + return { + kind: 'stream', + ...key, + ...(await measureStream(cdp, profile, key.direction, scenario.pointer, sink)), + }; } - return { kind: 'scroll', ...key, ...(await measureScroll(cdp, profile, key.direction)) }; + return { + kind: 'scroll', + ...key, + ...(await measureScroll(cdp, profile, key.direction, scenario.pointer, sink)), + }; } finally { // Leave the page first so the session's socket closes, but delete the session even // when the browser fails, so its app does not keep running. @@ -88,7 +99,7 @@ async function main(argv: readonly string[]): Promise<number> { for (let run = 1; run <= args.runs; run++) { for (const base of basesForRun(args.bases, run)) { try { - const result = await runOne(cdp, base, scenario, input, run); + const result = await runOne(cdp, base, scenario, input, run, args.dump); results.push(result); console.log(resultLine(result)); } catch (error) { diff --git a/scripts/scroll-bench/cdp.ts b/scripts/scroll-bench/cdp.ts index 843dd4c..1d8d3be 100644 --- a/scripts/scroll-bench/cdp.ts +++ b/scripts/scroll-bench/cdp.ts @@ -26,6 +26,8 @@ export interface Cdp { key(name: KeyName): Promise<void>; /** Clicks the left mouse button at (x, y), to focus what is there. */ click(x: number, y: number): Promise<void>; + /** The page as a PNG, base64. */ + screenshot(): Promise<string>; close(): void; } @@ -157,6 +159,10 @@ export async function connectCdp(port: number): Promise<Cdp> { await mouse('mousePressed', x, y); await mouse('mouseReleased', x, y); }, + async screenshot() { + const r = await send('Page.captureScreenshot', { format: 'png' }); + return String(r.data); + }, close: () => ws.close(), }; } diff --git a/scripts/scroll-bench/dump.ts b/scripts/scroll-bench/dump.ts new file mode 100644 index 0000000..2df3032 --- /dev/null +++ b/scripts/scroll-bench/dump.ts @@ -0,0 +1,26 @@ +// Saves what one run recorded, so a number in the results can be checked against the +// pictures: the screen as the input started, every screencast frame and which of them +// counted as blinks (service over the file system). + +import fs from 'node:fs'; +import path from 'node:path'; +import type { CastFrame } from './screencast'; + +/** What a run hands the dump: the start screenshot, its frames and the blink indices. */ +export interface RunRecord { + startPng: string; + frames: readonly CastFrame[]; + blinks: readonly number[]; +} + +/** Writes `record` under `dir/id/`: start.png, frame-NNN.jpg and blinks.txt. */ +export function writeDump(dir: string, id: string, record: RunRecord): void { + const out = path.join(dir, id); + fs.mkdirSync(out, { recursive: true }); + fs.writeFileSync(path.join(out, 'start.png'), Buffer.from(record.startPng, 'base64')); + record.frames.forEach((frame, i) => { + const name = `frame-${String(i).padStart(3, '0')}.jpg`; + fs.writeFileSync(path.join(out, name), Buffer.from(frame.jpeg, 'base64')); + }); + fs.writeFileSync(path.join(out, 'blinks.txt'), `${record.blinks.join('\n')}\n`); +} diff --git a/scripts/scroll-bench/measure.ts b/scripts/scroll-bench/measure.ts index cd5756a..29abd7c 100644 --- a/scripts/scroll-bench/measure.ts +++ b/scripts/scroll-bench/measure.ts @@ -4,6 +4,7 @@ // Service over Cdp; the numbers come from metrics.ts. import { type Cdp, sleep } from './cdp'; +import type { RunRecord } from './dump'; import { type Direction, type InputProfile, playInput } from './inputs'; import { averageStepPx, @@ -15,11 +16,20 @@ import { scrollWindow, streamStats, } from './metrics'; -import { decodeProfiles, readPaintCounter, resetPaintCounter, viewportCentre } from './page'; -import type { Scenario } from './scenarios'; +import { decodeProfiles, readPaintCounter, resetPaintCounter, viewportPoint } from './page'; +import { CENTRE, type Pointer, type Scenario } from './scenarios'; import { type CastOptions, startRecording } from './screencast'; -const CAST: CastOptions = { quality: 80, maxWidth: 1280, maxHeight: 800 }; +/** + * The screencast at the page's own size: a scaled frame moves by fractions of a pixel, and + * the resampling blur that adds to high-contrast text read as blinks while smooth scrolling. + */ +async function castOptions(cdp: Cdp): Promise<CastOptions> { + const size = await cdp.eval<{ width: number; height: number }>( + `({ width: innerWidth * devicePixelRatio, height: innerHeight * devicePixelRatio })`, + ); + return { quality: 80, maxWidth: size.width, maxHeight: size.height }; +} /** How long to wait for the client to draw its terminal canvas after the page loads. */ const READY_TIMEOUT_MS = 10_000; /** Recording kept after the last wheel event, so glides and late paints are seen. */ @@ -43,7 +53,7 @@ export async function startScenario(cdp: Cdp, url: string, scenario: Scenario): await cdp.navigate(url); await waitForTerminal(cdp); await sleep(500); - const centre = await viewportCentre(cdp); + const centre = await viewportPoint(cdp, CENTRE); await cdp.click(centre.x, centre.y); await cdp.text(scenario.command); await cdp.key('Enter'); @@ -54,6 +64,13 @@ export async function startScenario(cdp: Cdp, url: string, scenario: Scenario): } } +/** Takes what a run recorded, when the benchmark keeps it (--dump). */ +export type RecordSink = (record: RunRecord) => void; + +/** The start screenshot, taken only when a sink will keep it. */ +const startShot = (cdp: Cdp, sink?: RecordSink): Promise<string> => + sink ? cdp.screenshot() : Promise.resolve(''); + /** The scroll test's numbers for one input. */ export interface ScrollMeasure extends PaintStats { frames: number; @@ -69,11 +86,14 @@ export async function measureScroll( cdp: Cdp, input: InputProfile, direction: Direction, + pointer: Pointer = CENTRE, + sink?: RecordSink, ): Promise<ScrollMeasure> { - const centre = await viewportCentre(cdp); + const at = await viewportPoint(cdp, pointer); + const startPng = await startShot(cdp, sink); await resetPaintCounter(cdp); - const recording = await startRecording(cdp, CAST); - await playInput(cdp, input, direction, centre); + const recording = await startRecording(cdp, await castOptions(cdp)); + await playInput(cdp, input, direction, at); await sleep(TAIL_MS); const cast = await recording.stop(); const log = await readPaintCounter(cdp); @@ -84,9 +104,11 @@ export async function measureScroll( ); const window = scrollWindow(input.maxFramePx); const pairs = matchPairs(profiles, window); + const blinks = blinkFrames(profiles, pairs, window); + sink?.({ startPng, frames: cast, blinks }); return { frames: profiles.length, - blinks: blinkFrames(profiles, pairs, window).length, + blinks: blinks.length, avgStepPx: averageStepPx(pairs, window), ...paintStats(log.frames, log.wheels, TAIL_MS), }; @@ -101,18 +123,23 @@ export async function measureStream( cdp: Cdp, input: InputProfile, direction: Direction, + pointer: Pointer = CENTRE, + sink?: RecordSink, ): Promise<StreamStats> { - const centre = await viewportCentre(cdp); - const recording = await startRecording(cdp, CAST); - await playInput(cdp, input, direction, centre); + const at = await viewportPoint(cdp, pointer); + const startPng = await startShot(cdp, sink); + const recording = await startRecording(cdp, await castOptions(cdp)); + await playInput(cdp, input, direction, at); const scrollEnd = Date.now(); await sleep(STREAM_SETTLE_MS + STREAM_REST_MS); const cast = await recording.stop(); const profiles = await decodeProfiles( cdp, cast.map((f) => f.jpeg), - { x: 0, width: 4000, channels: 'r' }, + // Where stream.sh's bar is in any terminal at least 50 columns wide: its first columns + { x: 8, width: 400, channels: 'r' }, ); + sink?.({ startPng, frames: cast, blinks: [] }); const frames = cast.map((f, i) => ({ time: f.time, profile: profiles[i] })); return streamStats(frames, scrollEnd, STREAM_SETTLE_MS); } diff --git a/scripts/scroll-bench/page.ts b/scripts/scroll-bench/page.ts index 415ca65..3b26d33 100644 --- a/scripts/scroll-bench/page.ts +++ b/scripts/scroll-bench/page.ts @@ -4,6 +4,7 @@ import type { Cdp } from './cdp'; import type { PaintFrame, Profile } from './metrics'; +import type { Pointer } from './scenarios'; /** * Wraps the canvas drawing calls to flag a frame that drew, records every animation frame @@ -103,7 +104,9 @@ export async function decodeProfiles( return profiles; } -/** The middle of the page's viewport, where the wheel events go. */ -export function viewportCentre(cdp: Cdp): Promise<{ x: number; y: number }> { - return cdp.eval(`({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) })`); +/** The page point at `at`, fractions of its viewport, in CSS pixels. */ +export function viewportPoint(cdp: Cdp, at: Pointer): Promise<{ x: number; y: number }> { + return cdp.eval( + `({ x: Math.round(innerWidth * ${at.x}), y: Math.round(innerHeight * ${at.y}) })`, + ); } diff --git a/scripts/scroll-bench/scenarios.example.json b/scripts/scroll-bench/scenarios.example.json index 16b81df..9d76772 100644 --- a/scripts/scroll-bench/scenarios.example.json +++ b/scripts/scroll-bench/scenarios.example.json @@ -9,26 +9,30 @@ }, { "name": "vim", - "note": "vim on a big file, scrolled down from the top", - "command": "seq -f 'line %g of a big file for vim to scroll' 1 20000 > /tmp/scroll-bench.txt && vim -u NONE -c 'set mouse=a' /tmp/scroll-bench.txt", + "note": "vim on a big file, scrolled down from the top; the lines differ in length so a scrolled frame can be told from the one before", + "command": "awk 'BEGIN { for (i = 1; i <= 20000; i++) printf \"line %d %s\\n\", i, substr(\"of a big file whose lines differ in length so a scrolled frame can be told apart\", 1, i * 37 % 60 + 4) }' > /tmp/scroll-bench.txt && vim -n -u NONE -c 'set mouse=a' /tmp/scroll-bench.txt", "waitMs": 3000, "direction": "down", "screen": "alt" }, { "name": "nvim", - "command": "seq -f 'line %g of a big file for nvim to scroll' 1 20000 > /tmp/scroll-bench.txt && nvim --clean /tmp/scroll-bench.txt", + "command": "awk 'BEGIN { for (i = 1; i <= 20000; i++) printf \"line %d %s\\n\", i, substr(\"of a big file whose lines differ in length so a scrolled frame can be told apart\", 1, i * 37 % 60 + 4) }' > /tmp/scroll-bench.txt && nvim -n --clean /tmp/scroll-bench.txt", "waitMs": 3000, "direction": "down", "screen": "alt" }, { "name": "yazi", - "note": "a long file list; yazi tracks the mouse", + "note": "a long file list; yazi tracks the mouse, and the wheel scrolls the pane under the pointer, so it points at the current directory's list, not the preview", "command": "yazi /usr/bin", "waitMs": 3000, "direction": "down", - "screen": "alt" + "screen": "alt", + "pointer": { + "x": 0.3, + "y": 0.5 + } }, { "name": "less", diff --git a/scripts/scroll-bench/scenarios.test.ts b/scripts/scroll-bench/scenarios.test.ts index 330659d..deaa60f 100644 --- a/scripts/scroll-bench/scenarios.test.ts +++ b/scripts/scroll-bench/scenarios.test.ts @@ -15,6 +15,7 @@ describe('parseScenario', () => { enter2WaitMs: 6000, direction: 'up', screen: 'normal', + pointer: { x: 0.5, y: 0.5 }, }); }); @@ -30,6 +31,7 @@ describe('parseScenario', () => { enter2WaitMs: 0, direction: 'down', screen: 'alt', + pointer: { x: 0.3, y: 0.6 }, }, 0, VARS, @@ -42,6 +44,7 @@ describe('parseScenario', () => { enter2WaitMs: 0, direction: 'down', screen: 'alt', + pointer: { x: 0.3, y: 0.6 }, }); }); @@ -54,6 +57,8 @@ describe('parseScenario', () => { ['a bad direction', { name: 'a', command: 'x', direction: 'left' }, 'direction'], ['a negative wait', { name: 'a', command: 'x', waitMs: -1 }, 'waitMs'], ['a string enter2', { name: 'a', command: 'x', enter2: 'yes' }, 'enter2'], + ['a pointer off the page', { name: 'a', command: 'x', pointer: { x: 1.5, y: 0 } }, 'pointer'], + ['a pointer without y', { name: 'a', command: 'x', pointer: { x: 0.5 } }, 'pointer'], ]; for (const [name, entry, message] of bad) { test(`rejects ${name}`, () => expect(() => parseScenario(entry, 3, VARS)).toThrow(message)); diff --git a/scripts/scroll-bench/scenarios.ts b/scripts/scroll-bench/scenarios.ts index a44be8f..1dc31d1 100644 --- a/scripts/scroll-bench/scenarios.ts +++ b/scripts/scroll-bench/scenarios.ts @@ -24,8 +24,22 @@ export interface Scenario { direction: Direction; /** Which screen the app draws on; reported with the results. */ screen: 'alt' | 'normal'; + /** + * Where the wheel events go, as fractions of the page's width and height: over the pane + * that scrolls in an app with several (yazi's file list, not its preview). + */ + pointer: Pointer; } +/** A point on the page as fractions of its width and height, 0 to 1. */ +export interface Pointer { + x: number; + y: number; +} + +/** The middle of the page. */ +export const CENTRE: Pointer = { x: 0.5, y: 0.5 }; + /** Values for the `{repo}` and `{bench}` placeholders in a command. */ export interface Placeholders { repo: string; @@ -43,6 +57,7 @@ const KNOWN_FIELDS = new Set([ 'enter2WaitMs', 'direction', 'screen', + 'pointer', ]); function oneOf<T extends string>(where: string, value: unknown, allowed: readonly T[], or: T): T { @@ -57,6 +72,16 @@ function ms(where: string, value: unknown, or: number): number { throw new Error(`${where}: must be a number of ms, 0 or more`); } +function pointer(where: string, value: unknown): Pointer { + if (value === undefined) return CENTRE; + const p = value as Partial<Pointer> | null; + const fraction = (n: unknown) => typeof n === 'number' && n >= 0 && n <= 1; + if (typeof p !== 'object' || p === null || !fraction(p.x) || !fraction(p.y)) { + throw new Error(`${where}: must be { "x": 0-1, "y": 0-1 }`); + } + return { x: p.x as number, y: p.y as number }; +} + /** Replaces `{repo}` and `{bench}` in `command`. */ export function expandCommand(command: string, vars: Placeholders): string { return command.replaceAll('{repo}', vars.repo).replaceAll('{bench}', vars.bench); @@ -91,6 +116,7 @@ export function parseScenario(entry: unknown, index: number, vars: Placeholders) enter2WaitMs: ms(`${at} enter2WaitMs`, raw.enter2WaitMs, 6000), direction: oneOf(`${at} direction`, raw.direction, ['up', 'down'], 'up'), screen: oneOf(`${at} screen`, raw.screen, ['alt', 'normal'], 'normal'), + pointer: pointer(`${at} pointer`, raw.pointer), }; } diff --git a/scripts/scroll-bench/stream.sh b/scripts/scroll-bench/stream.sh index 4cf9215..bfe2383 100755 --- a/scripts/scroll-bench/stream.sh +++ b/scripts/scroll-bench/stream.sh @@ -12,13 +12,16 @@ cols=$(tput cols 2>/dev/null || echo 80) delay=$(awk "BEGIN { printf \"%.4f\", 1 / $rate }") total=$((rate * seconds)) +text='the quick brown fox jumps over the lazy dog while the slow cat sleeps' + bar() { printf '\e[7m%-*s\e[0m' "$cols" " stream $1/$total ${rate} lines/s" } for ((i = 1; i <= total; i++)); do # Replace the bar with the next line, then draw the bar below it again. - printf '\r\e[2K%06d the quick brown fox jumps over the lazy dog %s\n' "$i" "$((i * 7919 % 100000))" + # Lines of varying length, so a frame moved by a line does not look like the one before + printf '\r\e[2K%06d %s\n' "$i" "${text:0:$((i * 37 % 50 + 8))}" bar "$i" sleep "$delay" done From 3346e21c5887ab58b473c53ef3866819df50171e Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:43:21 -0400 Subject: [PATCH 290/295] fix(scripts): scroll-bench waits for the shell's prompt and reruns a scroll in which nothing moved --- scripts/scroll-bench/bench.ts | 14 +++++++++++--- scripts/scroll-bench/measure.ts | 5 ++++- scripts/scroll-bench/metrics.test.ts | 10 ++++++++++ scripts/scroll-bench/metrics.ts | 8 ++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/scroll-bench/bench.ts b/scripts/scroll-bench/bench.ts index 2d8bb45..8bf2cf5 100644 --- a/scripts/scroll-bench/bench.ts +++ b/scripts/scroll-bench/bench.ts @@ -3,8 +3,9 @@ // creates a fresh session, opens it in an already running Chrome, starts the scenario's // app, scrolls with the input while recording the screencast, prints one JSON line of // numbers and deletes the session; then a markdown table of the medians over the runs. -// A run that fails prints an error line and the benchmark goes on with the next; the -// servers take turns going first, so none is always measured after the others. +// A scroll run in which nothing moved (the app never started or never took the wheel) is +// run once more. A run that fails prints an error line and the benchmark goes on with the +// next; the servers take turns going first, so none is always measured after the others. // // Start Chrome or Chromium yourself; the benchmark never launches or closes a browser: // @@ -27,6 +28,7 @@ import { type Cdp, connectCdp, sleep } from './cdp'; import { writeDump } from './dump'; import { INPUT_PROFILES, type InputName } from './inputs'; import { measureScroll, measureStream, type RecordSink, startScenario } from './measure'; +import { nothingMoved } from './metrics'; import { loadScenarios, type Scenario } from './scenarios'; import { benchSessionId, createSession, deleteSession } from './session-api'; import { type Result, resultLine, summaryTable } from './summary'; @@ -99,7 +101,13 @@ async function main(argv: readonly string[]): Promise<number> { for (let run = 1; run <= args.runs; run++) { for (const base of basesForRun(args.bases, run)) { try { - const result = await runOne(cdp, base, scenario, input, run, args.dump); + let result = await runOne(cdp, base, scenario, input, run, args.dump); + if (result.kind === 'scroll' && nothingMoved(result)) { + console.error( + `# retried ${base.label} ${scenario.name} ${input} run ${run}: nothing moved`, + ); + result = await runOne(cdp, base, scenario, input, run, args.dump); + } results.push(result); console.log(resultLine(result)); } catch (error) { diff --git a/scripts/scroll-bench/measure.ts b/scripts/scroll-bench/measure.ts index 29abd7c..592c96e 100644 --- a/scripts/scroll-bench/measure.ts +++ b/scripts/scroll-bench/measure.ts @@ -32,6 +32,8 @@ async function castOptions(cdp: Cdp): Promise<CastOptions> { } /** How long to wait for the client to draw its terminal canvas after the page loads. */ const READY_TIMEOUT_MS = 10_000; +/** Time for the session's shell to show its prompt after the terminal appears. */ +const SHELL_START_MS = 1500; /** Recording kept after the last wheel event, so glides and late paints are seen. */ const TAIL_MS = 400; /** The stream test watches the view at rest this long after the scroll. */ @@ -52,7 +54,8 @@ async function waitForTerminal(cdp: Cdp): Promise<void> { export async function startScenario(cdp: Cdp, url: string, scenario: Scenario): Promise<void> { await cdp.navigate(url); await waitForTerminal(cdp); - await sleep(500); + // The shell starts after the canvas: a command typed before its prompt can be lost + await sleep(SHELL_START_MS); const centre = await viewportPoint(cdp, CENTRE); await cdp.click(centre.x, centre.y); await cdp.text(scenario.command); diff --git a/scripts/scroll-bench/metrics.test.ts b/scripts/scroll-bench/metrics.test.ts index e510dca..b0c4a28 100644 --- a/scripts/scroll-bench/metrics.test.ts +++ b/scripts/scroll-bench/metrics.test.ts @@ -7,6 +7,7 @@ import { blinkFrames, matchPairs, median, + nothingMoved, type PaintFrame, type Profile, paintStats, @@ -172,3 +173,12 @@ describe('median', () => { test(`${JSON.stringify(values)} → ${expected}`, () => expect(median(values)).toBe(expected)); } }); + +describe('nothingMoved', () => { + test.each([ + [{ frames: 7, paintsPerSec: 1.9 }, true], + [{ frames: 10, paintsPerSec: 2.9 }, true], + [{ frames: 33, paintsPerSec: 10.2 }, false], + [{ frames: 8, paintsPerSec: 6 }, false], + ])('%j: %p', (run, idle) => expect(nothingMoved(run)).toBe(idle)); +}); diff --git a/scripts/scroll-bench/metrics.ts b/scripts/scroll-bench/metrics.ts index 904d024..e963b01 100644 --- a/scripts/scroll-bench/metrics.ts +++ b/scripts/scroll-bench/metrics.ts @@ -131,6 +131,14 @@ export function paintStats( }; } +/** + * Whether a scroll run saw nothing move: a handful of frames and paints, as from the cursor + * blinking alone. The app never started or never took the wheel, so the run says nothing. + */ +export function nothingMoved(run: { frames: number; paintsPerSec: number }): boolean { + return run.frames <= 10 && run.paintsPerSec < 3; +} + /** A recorded frame of the streaming test: when it came and how it looked. */ export interface StreamFrame { time: number; From 28864924ad725aad1b0d582079e3be5ba8d6c8da Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:43:21 -0400 Subject: [PATCH 291/295] fix(client): after a held frame, a synchronized update is painted as it ends, as Ghostty's renderer wakes --- docs/adrs/067.client.synchronized-output.md | 3 ++ docs/specs/client.md | 2 +- .../features/kitty/graphics/kitty-terminal.ts | 2 + .../kitty/graphics/overlay-canvas.test.ts | 19 +++++++- .../features/kitty/graphics/overlay-canvas.ts | 16 +++++-- src/client/vendor/ghostty-web/PROVENANCE.md | 4 +- .../vendor/ghostty-web/core/host-types.ts | 7 +++ .../vendor/ghostty-web/core/sync-hold.ts | 28 +++++++++++- .../vendor/ghostty-web/core/sync-scan.ts | 21 ++++++++- .../ghostty-web/core/terminal-controllers.ts | 19 ++++++-- .../ghostty-web/core/terminal-emitters.ts | 2 + .../vendor/ghostty-web/core/write-pipeline.ts | 22 +++++++++- src/client/vendor/ghostty-web/terminal.ts | 2 + .../vendor/tests/core/sync-hold.test.ts | 44 ++++++++++++++++++- .../vendor/tests/core/sync-scan.test.ts | 28 +++++++++++- .../vendor/tests/core/write-pipeline.test.ts | 18 ++++++++ 16 files changed, 218 insertions(+), 19 deletions(-) diff --git a/docs/adrs/067.client.synchronized-output.md b/docs/adrs/067.client.synchronized-output.md index b3a00ee..380796e 100644 --- a/docs/adrs/067.client.synchronized-output.md +++ b/docs/adrs/067.client.synchronized-output.md @@ -22,6 +22,7 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - **Every begin restarts the time**, as in Ghostty. The write pipeline hands each write's output to `SyncHold.wrote`, which looks for `CSI ? 2026 h` with `scanSyncBegin` (`core/sync-scan.ts`): alone or among other modes (`CSI ? 25;2026 h`), and split across writes, an unfinished sequence carried to the next one. A repeated begin inside an update, or an end and a new begin in one write, starts a new second. A mode turned on in a form the scan does not read (a C1 CSI) is timed from the first frame that sees it. - **When the time runs out, the mode is turned off** in the engine (`GhosttyTerminal.setMode(2026, false)`, `GHOSTTY_TERMINAL_OPT_MODE`), as Ghostty's `resetSynchronizedOutput` does, so DECRQM, the echo and the next update all see it off. Only the render loop does it, once a frame (`SyncHold.expire`), on its frame timestamps; `SyncHold.held` and `Terminal.renderHeld` are reads that never change the engine. - **One decision for every painter.** The render loop skips only the render of a held frame: `beforeRender`, the find step, the reply drain and the textarea anchoring still run, so replies are not delayed and a glide keeps its time. The echo render after typed input asks the same `SyncHold`, so it waits too, and renders at once again after the update ends or expires. `Terminal.renderHeld(now)` lets the embedder's own painting follow the same rule: the kitty overlay skips its repaint on a held frame and keeps its dirty state, so it repaints on the first frame after the update. +- **An update that ends after a held frame is painted at its end**, before the rest of the output begins the next one, as Ghostty's renderer wakes when an update ends. An app that sends updates back to back is otherwise caught inside one on almost every frame: Claude Code fullscreen, scrolled hard with a TrackPoint, sends updates of 5 ms on the wire (median; 36 ms at most) 1 ms apart, and painting only between them dropped its paints from about 13 to 7 a second, with 380 ms between paints at the 90th percentile. `SyncHold` remembers that the render loop held a frame (`frameHeld`); while it does, `paintPoint` gives the index just past the last `CSI ? 2026 l` in a write (`lastSyncEnd`), and the write pipeline feeds the output up to there, paints (`paintUpdate`) and feeds the rest. Any paint ends the wait (`painted`), so this costs at most one extra render per held frame. `Terminal.onUpdatePainted` tells the embedder, and the kitty overlay paints its images in the same moment, so text and images never disagree. A write whose view is pinned (ADR 066, a scrolled-back normal screen) is fed whole and paints on a frame, since Ghostty's viewport is parked away from the view during it. - `Terminal.reset()` (a reconnect replaces the engine before the replay) forgets the update and any unfinished begin. A replay that ends inside an update holds a second at most, like any other. - Input devices need nothing of their own: wheel ticks to an app are paced by `TickPacer` (ADR 045), not by painting, and a held frame only batches the app's redraws into the frame after the update. @@ -31,6 +32,7 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - **Hold only the kitty overlay.** Rejected: the text blinks too, since its rows are cleared and rewritten across messages. - **Coalesce WebSocket messages on the server.** Rejected: messages split anywhere, and the browser can still paint between two of them. - **Time the update from the first frame that sees the mode on, and never touch the mode.** The first version did. Rejected: a repeated begin did not restart the second, so back-to-back updates in one stream were cut short, and a stuck mode stayed on in the engine, so the echo, which read the mode directly, never rendered at once again. +- **Paint only on animation frames that find the mode off.** The version before the paint at an update's end. Rejected: measured with the scroll benchmark (`scripts/scroll-bench`), an app sending updates back to back starved the screen, as described above. - **Feed `CSI ? 2026 l` to the engine on expiry.** Rejected: it would go through the parser in the middle of whatever sequence the output left unfinished; the mode setter changes only the mode. ## Consequences @@ -38,4 +40,5 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - Measured again on the same session: 0 of 31 frames scrolling up and 0 of 30 scrolling down were torn, with pi sending the same traffic. - Any app using synchronized output (pi, Claude Code, neovim, tmux) is painted only between updates, as in Ghostty. - An app that turns the mode on and never off freezes painting for a second after its last begin; then the mode is off until the app begins another update. +- An app sending updates back to back is painted at each update's end that follows a held frame, at most once per frame, instead of waiting for a frame that falls between two updates. - Each write is scanned for `2026`. Bytes are searched as bytes (`scanSyncBeginBytes`): output without `2026` costs a byte search, plus decoding the few bytes after an unfinished begin carried from the last write and the tail from its last escape when that is short; only a write with `2026` in it is decoded whole. diff --git a/docs/specs/client.md b/docs/specs/client.md index 013b308..a782c39 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -394,4 +394,4 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Edge extension | The leftover strip at the right and bottom takes the edge cells' own background, by repainting the canvas' last pixel row and column across it; always on, no setting | [ADR 035](../adrs/035.client.edge-extend.md) | ✅ | | Theme key mapping | The config's `purple`, `brightPurple` and `selection` are translated to the terminal's `magenta`, `brightMagenta` and `selectionBackground`, which were previously ignored; `selectionForeground` is new and defaults to `background` when `selection` is set | [ADR 036](../adrs/036.client.theme-key-mapping.md) | ✅ | | Output keeps a scrolled view | On the normal screen, output no longer pulls a scrolled-back view to the bottom (coder/ghostty-web's rule): before each write Ghostty's viewport is pinned on the view's top row, and after it the view goes where the pin went, so the same text stays in view through new lines, through the pages Ghostty prunes at the scrollback limit and through a cleared scrollback; the smooth-scroll glide moves with it for every input device. A key or a paste still scrolls to the bottom, as Ghostty's `scroll-to-bottom = keystroke, no-output`. A view less than a line back (a smooth scroll's sub-line offset) is a scrolled view too and stays on its text. A redraw in place repaints only the rows it changed in view. Ends the flicker when scrolling back through an app that redraws while it works | [ADR 066](../adrs/066.client.output-keeps-scrolled-view.md) | ✅ | -| Synchronized output | While the app sends a synchronized update (DEC mode 2026), the terminal, the echo of typed input and the kitty overlay do not paint, so an app that redraws the whole screen across several WebSocket messages is never shown half drawn. As in Ghostty, every `CSI ? 2026 h` restarts a one-second timer, and when it runs out the mode is turned off, so an app that never ends an update cannot freeze the view | [ADR 067](../adrs/067.client.synchronized-output.md) | ✅ | +| Synchronized output | While the app sends a synchronized update (DEC mode 2026), the terminal, the echo of typed input and the kitty overlay do not paint, so an app that redraws the whole screen across several WebSocket messages is never shown half drawn. As in Ghostty, every `CSI ? 2026 h` restarts a one-second timer, and when it runs out the mode is turned off, so an app that never ends an update cannot freeze the view. After a held frame, an update is painted as it ends, before the next one begins, so an app sending updates back to back (Claude Code scrolled hard) is not starved of paints | [ADR 067](../adrs/067.client.synchronized-output.md) | ✅ | diff --git a/src/client/features/kitty/graphics/kitty-terminal.ts b/src/client/features/kitty/graphics/kitty-terminal.ts index 601ea2d..4836f78 100644 --- a/src/client/features/kitty/graphics/kitty-terminal.ts +++ b/src/client/features/kitty/graphics/kitty-terminal.ts @@ -17,6 +17,8 @@ export interface KittyTerminal { readonly wasmTerm?: { isAlternateScreen(): boolean; getScrollbackLength(): number }; /** Whether painting waits for the app's synchronized update to end (mode 2026, ADR 067). */ renderHeld?(now: number): boolean; + /** Fires when a synchronized update was painted as it ended, between frames (ADR 067). */ + readonly onUpdatePainted?: (listener: () => void) => { dispose(): void }; } /** A cell's size in CSS pixels, with ghostty-web's defaults before the renderer exists. */ diff --git a/src/client/features/kitty/graphics/overlay-canvas.test.ts b/src/client/features/kitty/graphics/overlay-canvas.test.ts index e181ad9..001c2b0 100644 --- a/src/client/features/kitty/graphics/overlay-canvas.test.ts +++ b/src/client/features/kitty/graphics/overlay-canvas.test.ts @@ -31,7 +31,7 @@ function setup(withTerminalCanvas = true) { } container.append(canvas); } - const sync = { held: false, at: [] as number[] }; + const sync = { held: false, at: [] as number[], painted: new Set<() => void>() }; const term = { write: () => {}, buffer: { active: { cursorX: 0, cursorY: 0 } }, @@ -41,6 +41,10 @@ function setup(withTerminalCanvas = true) { sync.at.push(now); return sync.held; }, + onUpdatePainted: (listener: () => void) => { + sync.painted.add(listener); + return { dispose: () => sync.painted.delete(listener) }; + }, } satisfies KittyTerminal; const placements = new Placements(); const bitmaps = new Map<number, ImageBitmap>(); @@ -166,6 +170,19 @@ describe('OverlayCanvas', () => { expect([s.repaints(), s.draws.length]).toEqual([1, 1]); }); + test('paints with the text when an update is painted as it ends, between held frames (ADR 067)', () => { + const s = setup(); + s.bitmaps.set(1, fakeBitmap(4, 4)); + s.place(1); + s.sync.held = true; + s.clock.frame(); + expect(s.repaints()).toBe(0); + for (const listener of s.sync.painted) listener(); + expect([s.repaints(), s.draws.length]).toEqual([1, 1]); + s.overlay.dispose(); + expect(s.sync.painted.size).toBe(0); + }); + test('a tab coming back to the foreground repaints; hiding it does not', () => { const s = setup(); s.place(1); diff --git a/src/client/features/kitty/graphics/overlay-canvas.ts b/src/client/features/kitty/graphics/overlay-canvas.ts index 4226fd1..d4b6057 100644 --- a/src/client/features/kitty/graphics/overlay-canvas.ts +++ b/src/client/features/kitty/graphics/overlay-canvas.ts @@ -1,7 +1,7 @@ // The canvas kitty images are drawn on, laid over the terminal's own (view, ADR 032): it // repaints on the frame after anything changed, follows the terminal's scroll, size and -// pixel ratio, holds while the app sends a synchronized update (ADR 067), and frees its -// backing store while there is nothing to draw. +// pixel ratio, holds while the app sends a synchronized update and paints with the text when +// one ends (ADR 067), and frees its backing store while there is nothing to draw. import { TERMINAL_CANVAS_UNDER_OVERLAY, terminalCanvas } from '../../../layout/terminal-canvas'; import { DisposableStore, listen, toDisposable } from '../../../vendor/ghostty-web'; @@ -55,6 +55,9 @@ export class OverlayCanvas { this.store.add(toDisposable(() => canvas.remove())); this.frame = deps.window.requestAnimationFrame(this.tick); this.store.add(toDisposable(() => deps.window.cancelAnimationFrame(this.frame))); + // The text was painted at the end of an update, between frames: the images in it too + const painted = term.onUpdatePainted?.(() => this.paint()); + if (painted) this.store.add(painted); // Chromium can discard a canvas's contents while its tab is hidden. The // overlay's geometry does not change when the tab comes back, so nothing // else would make it repaint. @@ -83,10 +86,15 @@ export class OverlayCanvas { private readonly tick = (now: number): void => { this.frame = this.deps.window.requestAnimationFrame(this.tick); - if (this.source.placements.size === 0 && !this.canvas.width) return; // An app that redraws its images deletes every placement and places them again in // one synchronized update; painting in between would blink them (ADR 067). if (this.term.renderHeld?.(now)) return; + this.paint(); + }; + + /** Repaints if anything changed: placements, bitmaps, the scroll, the size or the scale. */ + private paint(): void { + if (this.source.placements.size === 0 && !this.canvas.width) return; const canvas = terminalCanvas(this.container, TERMINAL_CANVAS_UNDER_OVERLAY); if (!canvas) return; const cell = cellSize(this.term); @@ -106,7 +114,7 @@ export class OverlayCanvas { this.dirty = false; this.lastSignature = signature; this.draw(canvas, cell); - }; + } private draw(canvas: HTMLCanvasElement, cell: CellSize): void { const dpr = this.deps.window.devicePixelRatio || 1; diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 09007a3..75287c6 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -65,7 +65,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `interfaces.ts`, `link-detector.ts`: upstream's top doc comment is replaced by the origin header, which keeps what it said; `interfaces.ts` drops its section banner and its exported types get a line each on what they are for (ADR 061). `types.ts`, after the split only a re-export shim, is deleted (the last row of the table below). - `providers/osc8-link-provider.ts`: `provideLinks` scans the row and a private `linkAt` reads one link's run; the scan skips past each run instead of keeping a set of visited columns, which visits the same cells in the same order and returns the same links (ADR 061). - `core/write-pipeline.ts`, `core/viewport.ts`, `core/terminal-controllers.ts`, `core/opened-parts.ts`, `renderer.ts`, `engine/engine-types.ts`: output no longer moves a view scrolled back on the normal screen to the bottom (upstream's `write` did, calling it xterm.js' behaviour, which it is not); before each write `Viewport.pin` puts Ghostty's viewport on the view's top row (`GhosttyTerminal.pinViewport`), or on the part row above it a smooth scroll shows (`CanvasRenderer.requestedOffset`), and after it `Viewport.unpin` moves the view to where Ghostty took that row (`unpinViewport`), without an `onScroll` or the scrollbar, as Ghostty's `scroll-to-bottom = no-output`. `Viewport.scrollToBottom` is announced from line 0 while the part row shows. The alternate screen still goes to the bottom. A write no longer resets the view offset and marks every row dirty, and `renderer.ts` maps the engine's dirty rows to canvas rows from the view's line, so a redraw in place under a scrolled view repaints only what changed (ADR 066). -- `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067). +- `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `core/terminal-emitters.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; after a held frame the write pipeline feeds output up to where an update ends, paints and feeds the rest, and `Terminal.onUpdatePainted` (a new `TerminalEmitters.updatePainted`) tells the embedder; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067). - `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). ## webtty-original files @@ -76,7 +76,7 @@ Not from upstream; each starts with `// webtty (ADR NNN), not from upstream.` (` - `lifecycle/disposable-store.ts` (ADR 060, ADR 065): `DisposableStore`, `listen` and `toDisposable`, the one teardown primitive of the app and, from ADR 065, of this layer. - `core/host-types.ts` (ADR 061): the narrow interfaces `Terminal`'s parts and `SelectionManager` see of their host, where upstream reached into `Terminal` through `any`. - `core/focus-tracker.ts` (ADR 053), `core/find-controller.ts` (ADR 055): `Terminal`'s focus reports and find, which webtty added, moved out of `terminal.ts` (ADR 061). -- `core/sync-hold.ts`, `core/sync-scan.ts` (ADR 067): the hold on painting while the app sends a synchronized update (DEC mode 2026), which upstream's render loop did not honour, and the scanner that finds where an update begins. +- `core/sync-hold.ts`, `core/sync-scan.ts` (ADR 067): the hold on painting while the app sends a synchronized update (DEC mode 2026), which upstream's render loop did not honour, and the scanner that finds where an update begins and where the last one in a write ends. - `render/device-pixel-ratio.ts` (ADR 039), `render/frame-lines.ts` (ADR 042), `render/row-band.ts`, `render/row-repainter.ts`, `render/scroll-tracker.ts` (ADR 043), `render/decoration-tracker.ts` (ADR 043, ADR 055), `render/viewport-clip.ts` (ADR 040, ADR 043): renderer parts webtty added, moved out of `renderer.ts` (ADR 061). - `engine/wasm-exports.ts`, `engine/vt-layout.ts`, `engine/official-layout.ts`, `engine/key-tables.ts`, `engine/scratch.ts` (ADR 041, ADR 042), `engine/cursor-blink.ts` (ADR 049), `engine/text-mime.ts` (ADR 052), `engine/kitty-keyboard-reply.ts` (ADR 042), `engine/key-encoder-binding.ts`, `engine/official-terminal.ts` (ADR 042, ADR 047), `engine/engine-context.ts`, `engine/render-state.ts`, `engine/scrollback-pages.ts`, `engine/row-ref.ts` (ADR 042, ADR 043), `engine/grid-refs.ts` (ADR 042, ADR 050), `engine/selection-binding.ts` (ADR 050, ADR 057), `engine/search-binding.ts` (ADR 055), `engine/mouse-encoder-binding.ts` (ADR 049), `engine/paste-focus-binding.ts` (ADR 049, ADR 053), `engine/engine-effects.ts` (ADR 042, ADR 052, ADR 056), `engine/engine-config.ts` (ADR 042, ADR 046, ADR 049): webtty's binding to the official wasm, moved out of `ghostty.ts` and split by feature (ADR 061). Upstream's `ghostty.ts` ran the Coder wasm at fixed offsets and had none of them. diff --git a/src/client/vendor/ghostty-web/core/host-types.ts b/src/client/vendor/ghostty-web/core/host-types.ts index d2c2b9d..ff74249 100644 --- a/src/client/vendor/ghostty-web/core/host-types.ts +++ b/src/client/vendor/ghostty-web/core/host-types.ts @@ -159,6 +159,13 @@ export interface WriteHost { pinView(): boolean; /** After the write, moves the view to where the pin went with its text (Viewport.unpin). */ unpinView(): void; + /** + * Where to paint inside output about to be written: just past the synchronized update a + * held frame waits for, or -1 (SyncHold.paintPoint). + */ + syncPaintPoint(data: string | Uint8Array): number; + /** Paints now, at the end of the update a held frame waited for, and says so. */ + paintUpdate(): void; /** The output just written, which may begin a synchronized update (SyncHold.wrote). */ wroteOutput(data: string | Uint8Array): void; /** diff --git a/src/client/vendor/ghostty-web/core/sync-hold.ts b/src/client/vendor/ghostty-web/core/sync-hold.ts index c656b21..90f9f89 100644 --- a/src/client/vendor/ghostty-web/core/sync-hold.ts +++ b/src/client/vendor/ghostty-web/core/sync-hold.ts @@ -6,10 +6,13 @@ // keeps those half-drawn frames off the screen. The policy is Ghostty's (termio/Thread.zig // `sync_reset_ms`, Termio.zig `resetSynchronizedOutput`): every begin restarts a 1 s // timer, and when it runs out the mode is turned off, so an app that never ends an update -// cannot freeze the view, and DECRQM, the echo and the next update all see it off. +// cannot freeze the view, and DECRQM, the echo and the next update all see it off. And, as +// Ghostty's renderer wakes when an update ends, an update that ends after a held frame is +// painted at its end, before the next one begins: an app that sends updates back to back +// would otherwise be caught inside one on every frame. import type { SyncHoldHost } from './host-types'; -import { scanSyncBegin, scanSyncBeginBytes } from './sync-scan'; +import { lastSyncEnd, scanSyncBegin, scanSyncBeginBytes } from './sync-scan'; /** Synchronized output: the app brackets an update with `CSI ? 2026 h` and `CSI ? 2026 l`. */ export const SYNCHRONIZED_OUTPUT_MODE = 2026; @@ -23,6 +26,8 @@ export class SyncHold { private since: number | null = null; /** The unfinished mode sequence the last write ended in. */ private carry = ''; + /** A frame was held since the last paint: the next update to end paints at its end. */ + private waiting = false; /** `now` is the clock a write is timed on; a frame passes its own timestamp. */ constructor( @@ -69,9 +74,28 @@ export class SyncHold { return this.host.active() && (this.since === null || now - this.since < SYNC_HOLD_MAX_MS); } + /** The render loop held a frame's paint. */ + frameHeld(): void { + this.waiting = true; + } + + /** The terminal painted: nothing waits for an update's end. */ + painted(): void { + this.waiting = false; + } + + /** + * Where to paint in output about to be written: just past the last update that ends in + * it, while a held frame waits for one; -1 to write it whole and paint on a frame. + */ + paintPoint(data: string | Uint8Array): number { + return this.waiting ? lastSyncEnd(data) : -1; + } + /** Forgets the update and any unfinished sequence: the engine was replaced. */ reset(): void { this.since = null; this.carry = ''; + this.waiting = false; } } diff --git a/src/client/vendor/ghostty-web/core/sync-scan.ts b/src/client/vendor/ghostty-web/core/sync-scan.ts index d33564e..5594fe8 100644 --- a/src/client/vendor/ghostty-web/core/sync-scan.ts +++ b/src/client/vendor/ghostty-web/core/sync-scan.ts @@ -1,11 +1,14 @@ // webtty (ADR 067), not from upstream. // The synchronized-update scanner (util): finds where the app begins a synchronized update // (`CSI ? 2026 h`, alone or among other modes) in output, carrying an unfinished sequence -// across output chunks, as the cursor blink scanner does (engine/cursor-blink.ts). Bytes -// are searched as bytes: only a chunk with `2026` in it is decoded whole. +// across output chunks, as the cursor blink scanner does (engine/cursor-blink.ts), and +// where the last update in a chunk ends. Bytes are searched as bytes: only a chunk with +// `2026` in it is decoded whole. /** A DEC private mode set whose modes include 2026. */ const SYNC_BEGIN_SEQ = /\x1b\[\?(?:\d*;)*2026(?:;\d*)*h/; +/** A DEC private mode reset whose modes include 2026, the end of an update. */ +const SYNC_END_SEQ = /\x1b\[\?(?:\d*;)*2026(?:;\d*)*l/g; /** The start of a DEC private mode set or reset, still open at the end of a chunk. */ const MODE_SET_PREFIX = /^\x1b(?:\[(?:\?[\d;]*)?)?$/; /** Longest tail kept as carry: a mode list longer than this is not an app's begin. */ @@ -67,3 +70,17 @@ export function scanSyncBeginBytes( } return { begun, carry: MODE_SET_PREFIX.test(tail) ? tail : '' }; } + +/** + * Where the last synchronized update that ends in `data` ends: the index just past its + * `CSI ? 2026 l`, in UTF-16 units for a string and bytes for bytes, or -1 if none ends in + * it. An end split across chunks is not found; the update then paints on the next frame. + */ +export function lastSyncEnd(data: string | Uint8Array): number { + if (typeof data !== 'string' && !has2026(data)) return -1; + const text = typeof data === 'string' ? data : LATIN1.decode(data); + if (typeof data === 'string' && !text.includes('2026')) return -1; + let end = -1; + for (const match of text.matchAll(SYNC_END_SEQ)) end = match.index + match[0].length; + return end; +} diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index 0c4acdf..2d21f7b 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -227,14 +227,25 @@ export function writeHost(term: ControllersHost, inner: Inner, core: TerminalCon const engine = inner.parts()?.engine; if (engine) core.viewport.unpin(engine); }, + syncPaintPoint: (data) => core.sync.paintPoint(data), + paintUpdate: () => { + paint(inner, core); + inner.emit.updatePainted.fire(); + }, wroteOutput: (data) => core.sync.wrote(data), // Not inside a synchronized update: the frame after it ends paints (ADR 067) renderEcho: () => { - if (!core.sync.held()) inner.render(false); + if (!core.sync.held()) paint(inner, core); }, }; } +/** Renders what changed, which ends any wait for an update's end (SyncHold.painted). */ +function paint(inner: Inner, core: TerminalControllers): void { + inner.render(false); + core.sync.painted(); +} + /** The synchronized-update hold's view of the engine's mode 2026 (ADR 067). */ export function syncHoldHost(inner: Inner): SyncHoldHost { return { @@ -251,9 +262,11 @@ export function renderLoopHost(inner: Inner, core: TerminalControllers): RenderL stepFind: (now) => core.finder.step(now), renderHeld: (now) => { core.sync.expire(now); - return core.sync.held(now); + const held = core.sync.held(now); + if (held) core.sync.frameHeld(); + return held; }, - render: () => inner.render(false), + render: () => paint(inner, core), hasReplies: () => inner.parts()?.engine.hasResponse() ?? false, drainReplies: () => core.writes.drainReplies(), anchorTextarea: () => inner.anchorTextarea(), diff --git a/src/client/vendor/ghostty-web/core/terminal-emitters.ts b/src/client/vendor/ghostty-web/core/terminal-emitters.ts index e16deff..75f018f 100644 --- a/src/client/vendor/ghostty-web/core/terminal-emitters.ts +++ b/src/client/vendor/ghostty-web/core/terminal-emitters.ts @@ -16,6 +16,7 @@ export class TerminalEmitters { readonly clipboardWrite = new EventEmitter<string>(); readonly desktopNotification = new EventEmitter<DesktopNotification>(); readonly findChange = new EventEmitter<SearchState>(); + readonly updatePainted = new EventEmitter<void>(); /** Drops every listener of every emitter. */ dispose(): void { @@ -28,5 +29,6 @@ export class TerminalEmitters { this.clipboardWrite.dispose(); this.desktopNotification.dispose(); this.findChange.dispose(); + this.updatePainted.dispose(); } } diff --git a/src/client/vendor/ghostty-web/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts index ab61881..63c2488 100644 --- a/src/client/vendor/ghostty-web/core/write-pipeline.ts +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -39,7 +39,9 @@ export class WritePipeline { // selected find match is kept by the find step instead (ADR 055). const pinned = !host.findAnchored() && !engine.isAlternateScreen() && host.pinView(); try { - this.feed(data); + // A pinned write keeps Ghostty's viewport away from the view, so it paints on a frame + if (pinned) this.feed(data); + else this.feedPainting(data); } finally { if (pinned) host.unpinView(); } @@ -118,6 +120,24 @@ export class WritePipeline { this.callbackFrames.clear(); } + /** + * Feeds output, painting where the synchronized update a held frame waits for ends, + * before the rest of the output begins the next one, as Ghostty's renderer wakes when an + * update ends (webtty, ADR 067). + */ + private feedPainting(data: string | Uint8Array): void { + const at = this.host.syncPaintPoint(data); + if (at < 0) { + this.feed(data); + return; + } + const split = (from: number, to?: number) => + typeof data === 'string' ? data.slice(from, to) : data.subarray(from, to); + this.feed(split(0, at)); + this.host.paintUpdate(); + if (at < data.length) this.feed(split(at)); + } + /** The engine parses the VT stream, a waiting clear going in where it reaches ground. */ private feed(data: string | Uint8Array): void { const engine = this.host.engine()!; diff --git a/src/client/vendor/ghostty-web/terminal.ts b/src/client/vendor/ghostty-web/terminal.ts index aaba9a2..3d16d8b 100644 --- a/src/client/vendor/ghostty-web/terminal.ts +++ b/src/client/vendor/ghostty-web/terminal.ts @@ -108,6 +108,8 @@ export class Terminal implements ITerminalCore { this.emit.desktopNotification.event; /** The match count or the selected match changed (webtty, ADR 055) */ public readonly onFindChange: IEvent<SearchState> = this.emit.findChange.event; + /** A synchronized update was painted as it ended, between frames (webtty, ADR 067) */ + public readonly onUpdatePainted: IEvent<void> = this.emit.updatePainted.event; // Focus, find, writes, the render loop, the view, the scrollbar, the mouse and the wheel private readonly core = new TerminalControllers(this, { diff --git a/src/client/vendor/tests/core/sync-hold.test.ts b/src/client/vendor/tests/core/sync-hold.test.ts index 69a8865..54dcf8a 100644 --- a/src/client/vendor/tests/core/sync-hold.test.ts +++ b/src/client/vendor/tests/core/sync-hold.test.ts @@ -124,6 +124,22 @@ describe('SyncHold', () => { }); }); +describe('SyncHold paint point', () => { + test('only after a held frame: past the last update that ends, until the terminal paints', () => { + const { hold } = standIn(); + const data = `${BEGIN}1${END}${BEGIN}2`; + expect(hold.paintPoint(data)).toBe(-1); + hold.frameHeld(); + expect(hold.paintPoint(data)).toBe(BEGIN.length + 1 + END.length); + expect(hold.paintPoint(`${BEGIN}still drawing`)).toBe(-1); + hold.painted(); + expect(hold.paintPoint(data)).toBe(-1); + hold.frameHeld(); + hold.reset(); + expect(hold.paintPoint(data)).toBe(-1); + }); +}); + describe('Terminal.renderHeld', () => { test("follows the app's CSI ? 2026 h / l; the render loop paints nothing until the update ends", async () => { const h = await openTerminal(); @@ -137,10 +153,12 @@ describe('Terminal.renderHeld', () => { clock.frame(); clock.frame(); expect(render).not.toHaveBeenCalled(); + // Frames were held, so the update is painted as it ends, without a frame's wait term.write(` drawn${END}`); expect(term.renderHeld(clock.now())).toBe(false); - clock.frame(); expect(render).toHaveBeenCalledTimes(1); + clock.frame(); + expect(render).toHaveBeenCalledTimes(2); } finally { h.close(); } @@ -173,6 +191,30 @@ describe('Terminal.renderHeld', () => { } }); + test('after a held frame, an update is painted as it ends, before the next one is written', async () => { + const h = await openTerminal(); + try { + const { term, clock } = h; + const render = spyOn(rendererOf(term), 'render'); + const painted: number[] = []; + term.onUpdatePainted(() => painted.push(render.mock.calls.length)); + term.write(`${BEGIN}first`); + render.mockClear(); + clock.frame(); + expect(render).not.toHaveBeenCalled(); + // Back to back: the first ends and the second begins in one write + term.write(` drawn${END}${BEGIN}second`); + expect(render).toHaveBeenCalledTimes(1); + expect(painted).toEqual([1]); + expect(term.renderHeld(clock.now())).toBe(true); + // Painted at that end, nothing waits until a frame is held again + term.write(` drawn${END}${BEGIN}third`); + expect(render).toHaveBeenCalledTimes(1); + } finally { + h.close(); + } + }); + test('reset replaces the engine and forgets its update; a replay ending inside one holds a second at most', async () => { const h = await openTerminal(); try { diff --git a/src/client/vendor/tests/core/sync-scan.test.ts b/src/client/vendor/tests/core/sync-scan.test.ts index 697b358..4da6d9b 100644 --- a/src/client/vendor/tests/core/sync-scan.test.ts +++ b/src/client/vendor/tests/core/sync-scan.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { scanSyncBegin, scanSyncBeginBytes } from '../../ghostty-web/core/sync-scan'; +import { lastSyncEnd, scanSyncBegin, scanSyncBeginBytes } from '../../ghostty-web/core/sync-scan'; // The synchronized-update scanner (ADR 067): which mode sets begin an update, a begin -// split across output chunks, and the byte scan agreeing with the string scan. +// split across output chunks, the byte scan agreeing with the string scan, and where the +// last update in a chunk ends. describe('scanSyncBegin', () => { test.each([ @@ -67,3 +68,26 @@ describe('scanSyncBeginBytes', () => { } }); }); + +describe('lastSyncEnd', () => { + test.each([ + ['a\x1b[?2026lb', 9], + ['\x1b[?2026h1\x1b[?2026l\x1b[?2026h2\x1b[?2026l', 34], + ['\x1b[?2026h1\x1b[?2026l\x1b[?2026h2', 17], + ['\x1b[?25;2026l', 11], + // A begin, other modes and lookalikes end nothing + ['\x1b[?2026h', -1], + ['\x1b[?12026l', -1], + ['2026l', -1], + ['plain output', -1], + ])('%j ends its last update at %p', (data, end) => { + expect(lastSyncEnd(data)).toBe(end); + expect(lastSyncEnd(new TextEncoder().encode(data))).toBe(end); + }); + + test('an index in bytes for bytes, past multi-byte characters', () => { + const data = '─\x1b[?2026l'; + expect(lastSyncEnd(data)).toBe(9); + expect(lastSyncEnd(new TextEncoder().encode(data))).toBe(11); + }); +}); diff --git a/src/client/vendor/tests/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts index d464988..830cc0a 100644 --- a/src/client/vendor/tests/core/write-pipeline.test.ts +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -27,6 +27,8 @@ interface Options { disableStdin?: boolean; /** The engine write throws, as on a wasm trap. */ throws?: boolean; + /** Where SyncHold.paintPoint says to paint; -1 by default. */ + paintAt?: number; } /** A pipeline on a recording engine and host: `calls` logs both, in order. */ @@ -91,6 +93,8 @@ function recordingHost( return (options.viewportY ?? 0) > 0; }, unpinView: () => calls.push('unpinView'), + syncPaintPoint: () => options.paintAt ?? -1, + paintUpdate: () => calls.push('paintUpdate'), wroteOutput: (data) => calls.push(`wroteOutput ${JSON.stringify(data)}`), renderEcho: () => calls.push('renderEcho'), }; @@ -192,6 +196,20 @@ describe('WritePipeline order', () => { term.free(); }); + test('an update a held frame waits for is painted at its end, before the rest is written (ADR 067)', () => { + const writes = (c: string[]) => c.filter((x) => /^(engine\.write|paintUpdate)/.test(x)); + const { calls, pipeline } = recorded({ paintAt: 3 }); + pipeline.write('endnext'); + expect(writes(calls)).toEqual(['engine.write "end"', 'paintUpdate', 'engine.write "next"']); + const atEnd = recorded({ paintAt: 3 }); + atEnd.pipeline.write(new TextEncoder().encode('end')); + expect(writes(atEnd.calls)).toEqual(['engine.write [101,110,100]', 'paintUpdate']); + // A pinned write keeps Ghostty's viewport off the view: it paints on a frame + const pinned = recorded({ paintAt: 3, viewportY: 2 }); + pinned.pipeline.write('endnext'); + expect(writes(pinned.calls)).toEqual(['engine.write "endnext"']); + }); + test('drainReplies sends every queued reply, and nothing without an engine', () => { const { calls, pipeline } = recorded({ replies: ['a', 'b'] }); pipeline.drainReplies(); From bde50f49283212d640360d2e272126540782195c Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:46:31 -0400 Subject: [PATCH 292/295] docs(scroll-benchmark): the scroll benchmark as a reference SPEC, and ADR 068 on app modes and scroll behaviour --- ...enchmark.app-modes-and-scroll-behaviour.md | 51 ++++++ docs/specs/architecture.md | 6 +- docs/specs/scroll-benchmark.md | 161 ++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md create mode 100644 docs/specs/scroll-benchmark.md diff --git a/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md b/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md new file mode 100644 index 0000000..3962fe6 --- /dev/null +++ b/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md @@ -0,0 +1,51 @@ +# ADR 068: scroll-benchmark — App modes and scroll behaviour, measured + +**SPEC:** [scroll-benchmark](../specs/scroll-benchmark.md) +**Status:** Accepted +**Date:** 2026-10-15 + +--- + +## Context + +Scrolling through pi blinked. Two fixes came out of it: holding painting during an app's synchronized update ([ADR 067](067.client.synchronized-output.md)), and keeping a scrolled-back view on its text when output arrives ([ADR 066](066.client.output-keeps-scrolled-view.md)). Both pi and Claude Code also offer two modes, and the question was which to run in webtty and what webtty should support: + +- **Fullscreen** (pi's default since 1.0; Claude Code's default for new users, `/tui fullscreen`, `CLAUDE_CODE_NO_FLICKER=1`): the alternate screen, the app tracks the mouse and scrolls its own view by lines. +- **Classic** (pi `--tui-mode regular`, Claude Code `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): the normal screen; webtty's own scrollback scrolls, pixel by pixel. + +What was known, besides the measurements below: + +- **Where the apps are heading.** pi made fullscreen the default in 1.0.0 and has added only fullscreen features since (search, selection, scrollbar, jump to latest). Claude Code makes fullscreen the default for new users and always renders agent-view sessions fullscreen; classic is not deprecated. +- **Classic reprints.** pi regular clears the screen and the scrollback and reprints the whole transcript on every width change, most height changes and some updates (`tui-main-screen.js`, `ESC[2J ESC[H ESC[3J`). Claude Code classic replays its history on resize, with open reports of duplicated output (anthropics/claude-code#98411, #100728). webtty resizes the PTY on every browser resize and zoom, and a long transcript also outgrows the 10,000-line scrollback. +- **What fullscreen costs.** Scrolling moves by the app's lines. Selection and search are the app's own; Shift-drag still gives webtty's selection, and Cmd+F does not see the conversation. +- **Other programs.** vim (9.1+, when the terminal reports mode 2026), neovim (`termsync`, on by default), yazi, fzf, btop, lazygit, zellij and herdr wrap their redraws in synchronized updates; all run on the alternate screen. Only normal-screen output gains from ADR 066: shells, `tail -f`, build logs, `less -X` (git's pager), fzf's inline mode and the classic modes. +- **Other terminals.** Synchronized output with a timeout is near universal (Ghostty, foot, iTerm2 and xterm.js 1 s; kitty 2 s; Alacritty 150 ms; Windows Terminal ~100 ms), and no terminal makes the timeout a setting. Keeping a scrolled view on output is the default everywhere but xterm (Ghostty `scroll-to-bottom = keystroke, no-output`, kitty, WezTerm, Alacritty, xterm.js, gnome-terminal); a key returns to the bottom. + +The scroll benchmark ([SPEC](../specs/scroll-benchmark.md)) measured four builds (neither fix, each alone, both) on ten scenarios with a trackpad, a mouse wheel and a TrackPoint: + +- **Blinks.** Without ADR 067, pi fullscreen and Claude Code fullscreen blink with every input (up to 6.5 and 7.5 frames a run). With it, 0, for every app and input. No other scenario ever blinked. +- **Smoothness.** In the classic modes webtty paints 111 times a second with a trackpad, moving 1.5 px a frame; fullscreen apps paint about 11 times a second, a line (14 px) at a time, the same with or without the fixes. With a wheel or a TrackPoint both modes glide, classic in 13–31 px frames and fullscreen in the app's lines. +- **The kept view.** While output streams, a scrolled-back view is pulled to the bottom on almost every frame without ADR 066, and never at rest with it, for all three inputs. +- **Back-to-back updates.** ADR 067's first version starved Claude Code fullscreen, which sends updates 1 ms apart, of paints. Painting an update as it ends after a held frame, as Ghostty's renderer wakes, fixed it. + +## Decision + +1. **Run pi and Claude Code in fullscreen.** It is where both apps are going, it is blink-free in webtty with ADR 067, and it avoids classic's reprint on every resize and zoom. Classic stays fully supported, also blink-free, as the choice when native scrollback, search and copy matter more. webtty does not configure the apps or wrap their commands: the mode is the app's setting. +2. **Keep ADR 066 and ADR 067 always on, with no setting.** Both are what Ghostty and almost every other terminal do. ADR 067 changes nothing for apps that do not use synchronized output, and ADR 066 changes nothing on the alternate screen. A switch would be code nobody needs, and turning either off only brings a fault back: blinks, or a view pulled to the bottom. +3. **One behaviour for every input device.** The trackpad, the mouse wheel and the TrackPoint go through the same hold, paint and kept-view paths; ADR 045 only paces them differently. The benchmark plays all three, and ADR 066's tests drive each through output mid-glide. +4. **The scroll benchmark is the reference.** A change to scrolling, painting, the wheel or the write path is measured with `bun run bench:scroll` against the build before it, on the SPEC's scenarios and all three inputs, and its numbers go in the change's ADR. + +## Considered Options + +- **Recommend classic for its smoothness.** Classic scrolls by pixels at the display's rate, about ten times as often as fullscreen with a trackpad. Rejected as the default: it reprints the transcript on every resize and zoom in webtty, both apps develop fullscreen, and fullscreen is blink-free now. +- **Set the apps' modes from webtty** (an env var such as `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN` in webtty's config, a wrapper for `pi`). Tried and rolled back: it surprises users who chose a mode, and the shell in a webtty session is not the one the wrapper was written for. +- **Make ADR 066 and 067 configurable** (Ghostty's `scroll-to-bottom`, a sync timeout). Not now: no terminal offers the timeout, the scroll rule's default is the industry's, and no one has asked. A setting can be added on a real request. +- **Drop ADR 066 to keep the write path simple.** Rejected: for normal-screen output (shells, logs, classic modes) the difference is the whole problem, a view yanked down on every redraw, and every major terminal but xterm keeps the view. + +## Consequences + +- With both fixes, no measured app blinks with any input, and a scrolled-back view holds still while output streams. +- Users who prefer classic modes get pixel-smooth scrolling and native selection and search, and the reprint on resize that comes with them. +- Fullscreen apps scroll by their own lines; webtty cannot smooth that. Their scroll speed is the app's setting (pi `fullscreenWheelScrollLines`, Claude Code `CLAUDE_CODE_SCROLL_SPEED`). +- The benchmark needs a running Chrome and a few minutes per scenario; the numbers vary with machine load, so builds are compared in the same run, and blinks are checked against `--dump`'s frames before they are believed. +- Measuring also found a server fault: deleting a session sent SIGTERM, which an interactive shell ignores, so its apps kept running. The server now sends SIGHUP. diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index c016519..ced6698 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -1,6 +1,6 @@ # SPEC: Architecture -**Last Updated:** 2026-10-15 (`core/sync-hold.ts` and `core/sync-scan.ts`, ADR 067) +**Last Updated:** 2026-10-15 (`core/sync-hold.ts` and `core/sync-scan.ts`, ADR 067; `scripts/scroll-bench/`, ADR 068) --- @@ -180,6 +180,10 @@ scripts/ check-coverage.ts entry: per-layer coverage targets and the pure-module list over bun's lcov report (run by test:coverage) (+ test) check-pkg-manager.ts clean-pkg-scripts.ts package-manager guard and publish-time script cleanup (headers; pure parts tested) + scroll-bench/ the scroll benchmark (SPEC scroll-benchmark, ADR 068): bench.ts entry (bench:scroll); + bench-args.ts, cdp.ts (a running Chrome's DevTools), screencast.ts, inputs.ts (trackpad, + wheel, TrackPoint), scenarios.ts, measure.ts, page.ts, metrics.ts (pure), summary.ts, + session-api.ts, dump.ts; stream.sh and scenarios.example.json; pure parts tested update-ghostty-vt.ts CLI only (--commit, --verify) ghostty-vt/ minisign.ts, sidecar.ts, inspect-wasm.ts, release-fetch.ts (fetch injected), each with its test test-support/ minisign-signer.ts (a throwaway key), tiny-wasm.ts (the required exports), fake-release.ts (fake fetch) diff --git a/docs/specs/scroll-benchmark.md b/docs/specs/scroll-benchmark.md new file mode 100644 index 0000000..bcd19fd --- /dev/null +++ b/docs/specs/scroll-benchmark.md @@ -0,0 +1,161 @@ +# SPEC: Scroll Benchmark + +**Last Updated:** 2026-10-15 + +--- + +## Description + +The reference test for how scrolling looks in webtty: real apps in real sessions, scrolled with a trackpad, a mouse wheel and a TrackPoint, in a headless Chrome whose every presented frame is recorded and reduced to numbers. It compares builds side by side, so a change to scrolling, painting, the wheel or the output path is measured against the build before it, not judged by eye. + +**Persona:** webtty developers changing the client's scroll, paint, wheel or write paths (the smooth scroll, ADR 040–045; the kept view, ADR 066; synchronized output, ADR 067). + +It answers three questions for each app and input device: + +- **Does it blink?** A frame that shows something no clean scroll could: a half-drawn screen, images gone for a frame. +- **How smooth is it?** Paints per second while scrolling, the gaps between paints, and how far the view moves per frame. +- **Does the view stay put?** Scrolled back while output streams, does the view stay on its text or get pulled to the bottom? + +[ADR 068](../adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md) records the decisions taken from its results. + +## How it works + +``` + Chrome (headless or not, --remote-debugging-port=9333) + │ CDP: navigate, type, Input.dispatchMouseEvent (mouseWheel), Page.startScreencast + ▼ + scripts/scroll-bench/bench.ts ──► POST /api/sessions {"id"} ┐ one fresh session per + for each scenario, input, run, open /s/:id, type the │ measurement, on each + and each build (--base) command, wait │ server under test + ──► play the input, record │ + frames + paint counter │ + ──► DELETE /api/sessions/:id ┘ + ▼ + one JSON line per measurement, then a markdown table of the medians over the runs +``` + +| Module | Role | +|--------|------| +| `bench.ts` | Entry (`bun run bench:scroll`): the loop over scenarios, inputs, runs and builds; rotates which build goes first each run; reruns a scroll in which nothing moved | +| `bench-args.ts` | The command line, checked without side effects | +| `cdp.ts` | A minimal DevTools Protocol driver for a Chrome you started; every call times out | +| `screencast.ts` | Records every presented frame as a JPEG at the page's own size | +| `inputs.ts` | The input profiles as data, and playing one as wheel events | +| `scenarios.ts` | Reads a scenarios JSON file of your own | +| `measure.ts` | Starts a scenario, then one scroll or stream measurement | +| `page.ts` | What runs in the page: the paint counter, decoding frames into row-brightness profiles | +| `metrics.ts` | Pure functions over numbers: matching frames, blinks, paints, steps, the stream counts | +| `summary.ts`, `session-api.ts`, `dump.ts` | The output, the server's session API, and saving a run's frames | +| `stream.sh` | The streaming app for the stream test | + +## Running it + +1. Build each webtty to compare and start it on its own port. A build before a change is a `git worktree` at that commit; a build with one change switched off is a worktree with that change neutralised. Start each server without the outer terminal's identity, so apps do not take it for that terminal: + + ``` + env -u TERM_PROGRAM -u TERM_PROGRAM_VERSION PORT=2401 bun run dist/server/index.js + ``` + +2. Start Chrome with a DevTools port (the benchmark never launches one): + + ``` + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \ + --remote-debugging-port=9333 --user-data-dir=/tmp/scroll-bench-chrome \ + --window-size=1400,800 --no-first-run about:blank + ``` + +3. Write a scenarios file (`scripts/scroll-bench/scenarios.example.json` shows every field) and run: + + ``` + bun run bench:scroll --base before=http://localhost:2401 --base after=http://localhost:2400 \ + --scenarios my-scenarios.json --inputs trackpad,wheel,trackpoint --runs 2 [--dump DIR] + ``` + + `--dump DIR` saves each run's start screenshot, every recorded frame and the indices of the frames counted as blinks, so any number can be checked against the pictures. + +Commands that resume a personal pi or Claude Code session belong in your own scenarios file, not the repo. Resume Claude Code with `--fork-session` so the session is not changed. Call apps by a name the session's shell finds: a wrapper earlier on your interactive `PATH` may not be on the server's. + +### Inputs + +| Input | Events | What it stands for | +|-------|--------|--------------------| +| `trackpad` | 150 deltas of 2.5 px, 16 ms apart | A slow two-finger scroll: a small delta every frame | +| `wheel` | 14 notches of 100 px, 100–120 ms apart | A mouse wheel spun by hand, as Chromium on macOS reports a notch | +| `trackpoint` | 8 light 4.000244 px steps ~150 ms apart, then a hard push of 15 fractional deltas up to 334.75 px, 29–45 ms apart | ADR 045's TrackPoint capture: its light step, its peak and its gaps (the push's ramp is synthetic) | + +### Scenarios measured + +| Scenario | Screen | Who scrolls | Command (in the reference run) | +|----------|--------|-------------|--------------------------------| +| pi fullscreen | alternate | pi, by lines | `pi -r`, first session (277 messages, an image) | +| pi classic | normal | webtty's scrollback | `pi --tui-mode regular -r` | +| Claude Code fullscreen | alternate | Claude Code, by lines | `CLAUDE_CODE_NO_FLICKER=1 claude --resume <4.3 MB session> --fork-session` | +| Claude Code classic | normal | webtty's scrollback | `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude --resume … --fork-session` | +| vim, nvim | alternate | the editor (mouse on) | a 20,000-line file whose lines differ in length | +| yazi | alternate | yazi (mouse on) | `yazi`, the pointer over the file list (`pointer` field) | +| less | alternate | less, by arrow keys | `seq 1 20000 \| less` | +| seq | normal | webtty's scrollback | `seq 1 5000` | +| stream | normal | webtty's scrollback | `stream.sh 40 120`: 40 lines a second under an inverse status bar | + +## Measures + +- **Frames** come from `Page.startScreencast` at the page's own size, each reduced to a profile: the mean brightness of each pixel row over the left 600 px. Two frames *match* at the vertical shift with the least mean row error; an error above 3 is a different picture. +- **Blink**: a frame that is a different picture from both neighbours at any shift, while the neighbours match each other (error ≤ 3 and under half the smaller of the two). Smooth motion never counts, nor do three frames that all differ (an app redrawing); a half-drawn or flashed frame does. Calibrated at 0 on `seq`, and every reference blink was checked in its saved frames. +- **Paints/s, gap p50/p90**: from a counter in the page that flags each animation frame in which the canvas was drawn, between the first wheel event and 400 ms after the last. +- **Step px**: the mean shift of the frames that moved and still matched. In a fullscreen app it is the app's own step (a line is 14 px). +- **Stream test**: frames showing `stream.sh`'s bar are at the bottom. It counts them while scrolling back and at rest after, and counts at-rest frames that moved. + +### Pitfalls the harness guards against + +| Pitfall | Effect | Guard | +|---------|--------|-------| +| Recording smaller than the page | A smooth step becomes a fractional shift; the resampling blur on high-contrast text reads as blinks | Record at `innerWidth × devicePixelRatio` | +| Lines that look alike | A frame moved by a line matches the one before; motion reads as 0 | Test files and `stream.sh` vary their line lengths | +| A brightness test over the whole row | `stream.sh`'s 80-column bar is too dim averaged over 1400 px | The stream test reads the left 400 px | +| The wheel over the wrong pane | yazi scrolls the pane under the pointer (its preview, at the centre) | A scenario's `pointer` field | +| Typing before the shell's prompt | The command is lost; nothing moves | A 1.5 s wait, and a scroll in which nothing moved runs again (`# retried` on stderr) | +| Apps left running after a session is deleted | Hundreds of leaked processes load the machine | Fixed in the server: deleting a session hangs up its shell (SIGHUP) | + +## Reference results + +Run on 2026-10-15 in headless Chrome 151 (1400×657, DPR 1) on macOS, two runs each, medians shown. Four builds of this branch: **none** (before ADR 066 and 067, `58ea652`), **066** and **067** (one fix each), and **both** (`2886492`). The machine was loaded by background indexing during part of the run; paints per second of apps that scroll by lines vary by about ±20% between runs, the blink and stream counts do not. + +**Blinks per run** (trackpad / wheel / TrackPoint): + +| Scenario | none | 066 | 067 | both | +|----------|------|-----|-----|------| +| pi fullscreen | 1 / 1 / 1.5 | 6.5 / 1 / 1 | 0 / 0 / 0 | **0 / 0 / 0** | +| Claude Code fullscreen | 0.5 / 6 / 0.5 | 0 / 7.5 / 1.5 | 0 / 0 / 0.5 | **0 / 0 / 0** | +| pi classic, Claude Code classic, vim, nvim, yazi, less, seq | 0 | 0 | 0 | 0 | + +**Paints per second, build "both"** (trackpad / wheel / TrackPoint), with the step per frame: + +| Scenario | Paints/s | Step | +|----------|----------|------| +| pi classic, Claude Code classic, seq | 111 / 67 / 61 | 1.5 / 13 / 31 px: webtty's pixel-smooth scrollback | +| pi fullscreen | 11 / 45 / 18 | 14 / 17 / 72 px: pi's lines | +| Claude Code fullscreen | 11 / 43 / 9.5 | 14 / 78 / 65 px: Claude Code's lines | +| vim, nvim | 11 / 51 / 19 and 9 / 45 / 17 | 3 lines a tick | +| yazi, less | 17 / 53 / 21 and 10 / 51 / 24 | — | + +Classic modes scroll about ten times as often as fullscreen ones with a trackpad, by pixels instead of lines. Within a mode the four builds paint alike, except that 067 removes the blinked frames: Claude Code fullscreen with the wheel paints 43/s with both fixes against 56/s before, all of them complete. + +**Stream test** (frames at the bottom while scrolling back / frames at the bottom at rest / at-rest frames that moved): + +| Input | none | 066 | 067 | both | +|-------|------|-----|-----|------| +| trackpad | 277.5 of 277.5 / 46.5 of 47 / 45 | 12 of 300 / 0 of 85.5 / 0 | 293 of 295 / 87 of 87.5 / 47 | **13.5 of 295 / 0 of 85 / 0** | +| wheel | 114.5 of 131 / 88 of 88 / 46.5 | 15 of 131.5 / 0 of 90 / 0 | 109.5 of 131.5 / 88.5 of 89 / 47.5 | **14 of 131.5 / 0 of 89 / 0** | +| TrackPoint | 104.5 of 127.5 / 86.5 of 86.5 / 46.5 | 17.5 of 155.5 / 0 of 88 / 0 | 138.5 of 167.5 / 87.5 of 88 / 46 | **18 of 156 / 0 of 87 / 0** | + +Without ADR 066 the view is pulled back to the bottom on almost every frame; with it, only the frames before the scroll leaves the bottom show it. + +**Back-to-back updates.** Claude Code fullscreen, scrolled hard with a TrackPoint, sends synchronized updates of 5 ms on the wire (median; 36 ms at most) 1 ms apart. Painting only on frames that find the mode off dropped it to 7 paints/s with 380 ms between paints at p90; painting an update as it ends after a held frame (ADR 067) brought it back to 9–11 paints/s, against 11–13 without the hold (this input varies most between runs), with no blinks. + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| Harness | `scripts/scroll-bench`: input profiles, scenarios file, recorded frames, blink/paint/step/stream measures, builds compared side by side, `--dump` | [ADR 068](../adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md) | ✅ | +| Reference results | Four builds × ten scenarios × three inputs, 2026-10-15 | [ADR 068](../adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md) | ✅ | +| Real display | The same scenarios on a physical display and input devices, beside the headless numbers | — | ⬜ | From 0c5482990458b6360cd1152e153cea0fb0ad0417 Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:54:49 -0400 Subject: [PATCH 293/295] fix(client): an update's end is painted across a split sequence, the rest is written if the paint throws, and a find-anchored write paints on a frame --- docs/adrs/067.client.synchronized-output.md | 6 +-- .../vendor/ghostty-web/core/sync-hold.ts | 2 +- .../vendor/ghostty-web/core/sync-scan.ts | 14 ++++--- .../ghostty-web/core/terminal-controllers.ts | 5 ++- .../vendor/ghostty-web/core/write-pipeline.ts | 13 +++++-- .../vendor/tests/core/sync-hold.test.ts | 26 +++++++++++++ .../vendor/tests/core/sync-scan.test.ts | 7 ++++ .../tests/core/terminal-controllers.test.ts | 15 ++++++- .../vendor/tests/core/write-pipeline.test.ts | 39 ++++++++++++++++--- 9 files changed, 106 insertions(+), 21 deletions(-) diff --git a/docs/adrs/067.client.synchronized-output.md b/docs/adrs/067.client.synchronized-output.md index 380796e..d942437 100644 --- a/docs/adrs/067.client.synchronized-output.md +++ b/docs/adrs/067.client.synchronized-output.md @@ -22,7 +22,7 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - **Every begin restarts the time**, as in Ghostty. The write pipeline hands each write's output to `SyncHold.wrote`, which looks for `CSI ? 2026 h` with `scanSyncBegin` (`core/sync-scan.ts`): alone or among other modes (`CSI ? 25;2026 h`), and split across writes, an unfinished sequence carried to the next one. A repeated begin inside an update, or an end and a new begin in one write, starts a new second. A mode turned on in a form the scan does not read (a C1 CSI) is timed from the first frame that sees it. - **When the time runs out, the mode is turned off** in the engine (`GhosttyTerminal.setMode(2026, false)`, `GHOSTTY_TERMINAL_OPT_MODE`), as Ghostty's `resetSynchronizedOutput` does, so DECRQM, the echo and the next update all see it off. Only the render loop does it, once a frame (`SyncHold.expire`), on its frame timestamps; `SyncHold.held` and `Terminal.renderHeld` are reads that never change the engine. - **One decision for every painter.** The render loop skips only the render of a held frame: `beforeRender`, the find step, the reply drain and the textarea anchoring still run, so replies are not delayed and a glide keeps its time. The echo render after typed input asks the same `SyncHold`, so it waits too, and renders at once again after the update ends or expires. `Terminal.renderHeld(now)` lets the embedder's own painting follow the same rule: the kitty overlay skips its repaint on a held frame and keeps its dirty state, so it repaints on the first frame after the update. -- **An update that ends after a held frame is painted at its end**, before the rest of the output begins the next one, as Ghostty's renderer wakes when an update ends. An app that sends updates back to back is otherwise caught inside one on almost every frame: Claude Code fullscreen, scrolled hard with a TrackPoint, sends updates of 5 ms on the wire (median; 36 ms at most) 1 ms apart, and painting only between them dropped its paints from about 13 to 7 a second, with 380 ms between paints at the 90th percentile. `SyncHold` remembers that the render loop held a frame (`frameHeld`); while it does, `paintPoint` gives the index just past the last `CSI ? 2026 l` in a write (`lastSyncEnd`), and the write pipeline feeds the output up to there, paints (`paintUpdate`) and feeds the rest. Any paint ends the wait (`painted`), so this costs at most one extra render per held frame. `Terminal.onUpdatePainted` tells the embedder, and the kitty overlay paints its images in the same moment, so text and images never disagree. A write whose view is pinned (ADR 066, a scrolled-back normal screen) is fed whole and paints on a frame, since Ghostty's viewport is parked away from the view during it. +- **An update that ends after a held frame is painted at its end**, before the rest of the output begins the next one, as Ghostty's renderer wakes when an update ends. An app that sends updates back to back is otherwise caught inside one on almost every frame: Claude Code fullscreen, scrolled hard with a TrackPoint, sends updates of 5 ms on the wire (median; 36 ms at most) 1 ms apart, and in a benchmark run before this change painting only between them dropped it from about 13 to 7 paints a second, with 380 ms between paints at the 90th percentile. `SyncHold` remembers that the render loop held a frame (`frameHeld`); while it does, `paintPoint` gives the index just past the last `CSI ? 2026 l` in a write (`lastSyncEnd`, which also finishes an end the previous write left unfinished), and the write pipeline feeds the output up to there, paints (`paintUpdate`) and feeds the rest, the rest even if the paint throws. Every render of the terminal, a frame's, the echo's or an update's end, ends the wait (`painted`), so an update's end costs at most one render per held frame (the echo of typed input in the same write may add one more); the scrollbar's own repaints do not count. `paintUpdate` does nothing if the engine still has the mode on. `Terminal.onUpdatePainted` tells the embedder, and the kitty overlay paints its images in the same moment, so text and images never disagree. A write whose view is pinned (ADR 066, a scrolled-back normal screen) or on a selected find match is fed whole and paints on a frame: Ghostty's viewport is parked away from the view during the first, and the find step moves the view back only on a frame for the second. - `Terminal.reset()` (a reconnect replaces the engine before the replay) forgets the update and any unfinished begin. A replay that ends inside an update holds a second at most, like any other. - Input devices need nothing of their own: wheel ticks to an app are paced by `TickPacer` (ADR 045), not by painting, and a held frame only batches the app's redraws into the frame after the update. @@ -32,7 +32,7 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - **Hold only the kitty overlay.** Rejected: the text blinks too, since its rows are cleared and rewritten across messages. - **Coalesce WebSocket messages on the server.** Rejected: messages split anywhere, and the browser can still paint between two of them. - **Time the update from the first frame that sees the mode on, and never touch the mode.** The first version did. Rejected: a repeated begin did not restart the second, so back-to-back updates in one stream were cut short, and a stuck mode stayed on in the engine, so the echo, which read the mode directly, never rendered at once again. -- **Paint only on animation frames that find the mode off.** The version before the paint at an update's end. Rejected: measured with the scroll benchmark (`scripts/scroll-bench`), an app sending updates back to back starved the screen, as described above. +- **Paint only on animation frames that find the mode off.** The version before the paint at an update's end. Rejected: measured with the scroll benchmark (`scripts/scroll-bench`), an app sending updates back to back was starved of paints, as described above. - **Feed `CSI ? 2026 l` to the engine on expiry.** Rejected: it would go through the parser in the middle of whatever sequence the output left unfinished; the mode setter changes only the mode. ## Consequences @@ -40,5 +40,5 @@ libghostty-vt tracks mode 2026, but painting is the host's job. Ghostty's render - Measured again on the same session: 0 of 31 frames scrolling up and 0 of 30 scrolling down were torn, with pi sending the same traffic. - Any app using synchronized output (pi, Claude Code, neovim, tmux) is painted only between updates, as in Ghostty. - An app that turns the mode on and never off freezes painting for a second after its last begin; then the mode is off until the app begins another update. -- An app sending updates back to back is painted at each update's end that follows a held frame, at most once per frame, instead of waiting for a frame that falls between two updates. +- An app sending updates back to back is painted at each update's end that follows a held frame, instead of waiting for a frame that falls between two updates. It still paints less often than without the hold: in the reference run of the [scroll benchmark](../specs/scroll-benchmark.md), Claude Code fullscreen with a TrackPoint painted 9.1–9.5 times a second with 267–417 ms between paints at the 90th percentile, against 12.1–12.7 and 188–204 ms without the hold, and with no torn frames; with a wheel, 40–43 complete paints a second against 53–56 that included 6–7.5 torn frames a run. - Each write is scanned for `2026`. Bytes are searched as bytes (`scanSyncBeginBytes`): output without `2026` costs a byte search, plus decoding the few bytes after an unfinished begin carried from the last write and the tail from its last escape when that is short; only a write with `2026` in it is decoded whole. diff --git a/src/client/vendor/ghostty-web/core/sync-hold.ts b/src/client/vendor/ghostty-web/core/sync-hold.ts index 90f9f89..12dbeaf 100644 --- a/src/client/vendor/ghostty-web/core/sync-hold.ts +++ b/src/client/vendor/ghostty-web/core/sync-hold.ts @@ -89,7 +89,7 @@ export class SyncHold { * it, while a held frame waits for one; -1 to write it whole and paint on a frame. */ paintPoint(data: string | Uint8Array): number { - return this.waiting ? lastSyncEnd(data) : -1; + return this.waiting ? lastSyncEnd(data, this.carry) : -1; } /** Forgets the update and any unfinished sequence: the engine was replaced. */ diff --git a/src/client/vendor/ghostty-web/core/sync-scan.ts b/src/client/vendor/ghostty-web/core/sync-scan.ts index 5594fe8..a8911a7 100644 --- a/src/client/vendor/ghostty-web/core/sync-scan.ts +++ b/src/client/vendor/ghostty-web/core/sync-scan.ts @@ -74,13 +74,15 @@ export function scanSyncBeginBytes( /** * Where the last synchronized update that ends in `data` ends: the index just past its * `CSI ? 2026 l`, in UTF-16 units for a string and bytes for bytes, or -1 if none ends in - * it. An end split across chunks is not found; the update then paints on the next frame. + * it. An end begun in the `carry` the previous chunk left is found where `data` finishes it. */ -export function lastSyncEnd(data: string | Uint8Array): number { - if (typeof data !== 'string' && !has2026(data)) return -1; - const text = typeof data === 'string' ? data : LATIN1.decode(data); - if (typeof data === 'string' && !text.includes('2026')) return -1; +export function lastSyncEnd(data: string | Uint8Array, carry = ''): number { + const has = typeof data === 'string' ? data.includes('2026') : has2026(data); + if (!has && carry === '') return -1; + // Without `2026` in it, the chunk can only finish the carry's end, in its first bytes + const part = has ? data : data.slice(0, MAX_CARRY); + const text = carry + (typeof part === 'string' ? part : LATIN1.decode(part)); let end = -1; for (const match of text.matchAll(SYNC_END_SEQ)) end = match.index + match[0].length; - return end; + return end > carry.length ? end - carry.length : -1; } diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index 2d21f7b..2e0d666 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -176,7 +176,8 @@ export class TerminalControllers { // Each controller's host, read when called: the parts are undefined before open() and // after dispose(), and the other controllers are reached through `core` once all exist. -// Every member is one forwarding call, pinned by terminal-controllers.test.ts. +// Every member forwards to one place, the paints also telling the sync hold (ADR 067); +// terminal-controllers.test.ts pins where each goes. /** Short for the internals host in the builders below. */ type Inner = ControllersInternalsHost; @@ -229,6 +230,8 @@ export function writeHost(term: ControllersHost, inner: Inner, core: TerminalCon }, syncPaintPoint: (data) => core.sync.paintPoint(data), paintUpdate: () => { + // The output fed so far ended the update; the engine's mode has the last word + if (core.sync.held()) return; paint(inner, core); inner.emit.updatePainted.fire(); }, diff --git a/src/client/vendor/ghostty-web/core/write-pipeline.ts b/src/client/vendor/ghostty-web/core/write-pipeline.ts index 63c2488..5d7f92d 100644 --- a/src/client/vendor/ghostty-web/core/write-pipeline.ts +++ b/src/client/vendor/ghostty-web/core/write-pipeline.ts @@ -39,8 +39,9 @@ export class WritePipeline { // selected find match is kept by the find step instead (ADR 055). const pinned = !host.findAnchored() && !engine.isAlternateScreen() && host.pinView(); try { - // A pinned write keeps Ghostty's viewport away from the view, so it paints on a frame - if (pinned) this.feed(data); + // A pinned write keeps Ghostty's viewport away from the view, and a view on a find + // match moves back to it only in the find step: either paints on a frame + if (pinned || host.findAnchored()) this.feed(data); else this.feedPainting(data); } finally { if (pinned) host.unpinView(); @@ -134,8 +135,12 @@ export class WritePipeline { const split = (from: number, to?: number) => typeof data === 'string' ? data.slice(from, to) : data.subarray(from, to); this.feed(split(0, at)); - this.host.paintUpdate(); - if (at < data.length) this.feed(split(at)); + try { + this.host.paintUpdate(); + } finally { + // A paint that throws must not cost the engine the rest of the output + if (at < data.length) this.feed(split(at)); + } } /** The engine parses the VT stream, a waiting clear going in where it reaches ground. */ diff --git a/src/client/vendor/tests/core/sync-hold.test.ts b/src/client/vendor/tests/core/sync-hold.test.ts index 54dcf8a..719d3ae 100644 --- a/src/client/vendor/tests/core/sync-hold.test.ts +++ b/src/client/vendor/tests/core/sync-hold.test.ts @@ -215,6 +215,32 @@ describe('Terminal.renderHeld', () => { } }); + test('any paint ends the wait: a frame or an echo between the held frame and the end', async () => { + const h = await openTerminal(); + try { + const { term, clock } = h; + const render = spyOn(rendererOf(term), 'render'); + // Held, then a frame paints outside an update: no update end waits any more + term.write(`${BEGIN}one`); + clock.frame(); + term.write(`${END}`); + clock.frame(); + render.mockClear(); + term.write(`${BEGIN}two${END}${BEGIN}three`); + expect(render).not.toHaveBeenCalled(); + // Held, then the echo of typed input paints: likewise + clock.frame(); + term.write(`${END}`); + h.container.dispatch(keydown('a')); + term.write('a'); + render.mockClear(); + term.write(`${BEGIN}four${END}${BEGIN}five`); + expect(render).not.toHaveBeenCalled(); + } finally { + h.close(); + } + }); + test('reset replaces the engine and forgets its update; a replay ending inside one holds a second at most', async () => { const h = await openTerminal(); try { diff --git a/src/client/vendor/tests/core/sync-scan.test.ts b/src/client/vendor/tests/core/sync-scan.test.ts index 4da6d9b..e48e9b1 100644 --- a/src/client/vendor/tests/core/sync-scan.test.ts +++ b/src/client/vendor/tests/core/sync-scan.test.ts @@ -85,6 +85,13 @@ describe('lastSyncEnd', () => { expect(lastSyncEnd(new TextEncoder().encode(data))).toBe(end); }); + test('an end begun in the carry is found where the chunk finishes it', () => { + expect(lastSyncEnd('26lmore', '\x1b[?20')).toBe(3); + expect(lastSyncEnd(new TextEncoder().encode('26lmore'), '\x1b[?20')).toBe(3); + expect(lastSyncEnd('26hmore', '\x1b[?20')).toBe(-1); + expect(lastSyncEnd('more', '')).toBe(-1); + }); + test('an index in bytes for bytes, past multi-byte characters', () => { const data = '─\x1b[?2026l'; expect(lastSyncEnd(data)).toBe(9); diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 66f19fe..060e695 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -188,6 +188,7 @@ function world() { emit.desktopNotification.event((n) => void log.push(`notify ${n.title}`)); emit.findChange.event((state) => void log.push(`find ${state.total}`)); emit.pasteRequest.event(() => void log.push('paste request')); + emit.updatePainted.event(() => void log.push('update painted')); const inner: ControllersInternalsHost = { emit, parts: () => parts as unknown as OpenedParts, @@ -266,9 +267,17 @@ describe('TerminalControllers hosts', () => { host.unpinView(); host.wroteOutput('o'); host.renderEcho(); - // Inside the app's synchronized update the echo waits for the frame after it (ADR 067) + // An update's end paints only after a held frame, and tells the embedder (ADR 067) + expect(host.syncPaintPoint('\x1b[?2026l')).toBe(-1); + core.sync.frameHeld(); + expect(host.syncPaintPoint('\x1b[?2026l')).toBe(8); + host.paintUpdate(); + expect(host.syncPaintPoint('\x1b[?2026l')).toBe(-1); + // Inside the app's synchronized update the echo waits for the frame after it, and an + // update's end does not paint while the engine still has the mode on sync.on = true; host.renderEcho(); + host.paintUpdate(); expect(log).toEqual([ 'data r', 'follow focus mode', @@ -282,6 +291,8 @@ describe('TerminalControllers hosts', () => { 'unpin true', 'wrote o', 'render false undefined', + 'render false undefined', + 'update painted', ]); }); @@ -305,6 +316,8 @@ describe('TerminalControllers hosts', () => { ]); sync.on = true; expect(host.renderHeld(1)).toBe(true); + // A held frame makes the next update's end paint (ADR 067) + expect(core.sync.paintPoint('\x1b[?2026l')).toBe(8); sync.on = false; host.beforeRender(1); host.stepFind(2); diff --git a/src/client/vendor/tests/core/write-pipeline.test.ts b/src/client/vendor/tests/core/write-pipeline.test.ts index 830cc0a..805ed47 100644 --- a/src/client/vendor/tests/core/write-pipeline.test.ts +++ b/src/client/vendor/tests/core/write-pipeline.test.ts @@ -29,6 +29,8 @@ interface Options { throws?: boolean; /** Where SyncHold.paintPoint says to paint; -1 by default. */ paintAt?: number; + /** The paint at an update's end throws, as a failing listener would. */ + paintThrows?: boolean; } /** A pipeline on a recording engine and host: `calls` logs both, in order. */ @@ -94,7 +96,10 @@ function recordingHost( }, unpinView: () => calls.push('unpinView'), syncPaintPoint: () => options.paintAt ?? -1, - paintUpdate: () => calls.push('paintUpdate'), + paintUpdate: () => { + calls.push('paintUpdate'); + if (options.paintThrows) throw new Error('paint'); + }, wroteOutput: (data) => calls.push(`wroteOutput ${JSON.stringify(data)}`), renderEcho: () => calls.push('renderEcho'), }; @@ -204,10 +209,34 @@ describe('WritePipeline order', () => { const atEnd = recorded({ paintAt: 3 }); atEnd.pipeline.write(new TextEncoder().encode('end')); expect(writes(atEnd.calls)).toEqual(['engine.write [101,110,100]', 'paintUpdate']); - // A pinned write keeps Ghostty's viewport off the view: it paints on a frame - const pinned = recorded({ paintAt: 3, viewportY: 2 }); - pinned.pipeline.write('endnext'); - expect(writes(pinned.calls)).toEqual(['engine.write "endnext"']); + // A pinned write keeps Ghostty's viewport off the view, and a find match moves the view + // back only in the find step: both paint on a frame + for (const options of [{ viewportY: 2 }, { anchored: true }]) { + const whole = recorded({ paintAt: 3, ...options }); + whole.pipeline.write('endnext'); + expect(writes(whole.calls)).toEqual(['engine.write "endnext"']); + } + }); + + test('a paint that throws still writes the rest of the output (ADR 067)', () => { + const { calls, pipeline } = recorded({ paintAt: 3, paintThrows: true }); + expect(() => pipeline.write('endnext')).toThrow('paint'); + expect(calls.filter((c) => c.startsWith('engine.write'))).toEqual([ + 'engine.write "end"', + 'engine.write "next"', + ]); + }); + + test('a waiting clear goes in at ground across the two halves of a painted write', async () => { + const term = (await loadGhostty()).createTerminal(20, 5); + const calls: string[] = []; + const pipeline = new WritePipeline(recordingHost(calls, () => term, { paintAt: 4 })); + pipeline.write('top\r\n\x1b[3'); + pipeline.clearAtGround(); + pipeline.write('Cxx\x1b[?2026hbelow'); + expect(calls).toContain('paintUpdate'); + expect(term.getScrollbackLength()).toBe(0); + term.free(); }); test('drainReplies sends every queued reply, and nothing without an engine', () => { From 3752b40623b2eb7a94bfa8331a0f7c22c0acbddb Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:54:49 -0400 Subject: [PATCH 294/295] docs(scroll-benchmark): the SPEC and ADR 068 quote the reference run as measured, and the bench header matches the SPEC --- ...benchmark.app-modes-and-scroll-behaviour.md | 12 ++++++------ docs/specs/scroll-benchmark.md | 18 +++++++++--------- scripts/scroll-bench/bench.ts | 6 +++--- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md b/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md index 3962fe6..e870e32 100644 --- a/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md +++ b/docs/adrs/068.scroll-benchmark.app-modes-and-scroll-behaviour.md @@ -15,18 +15,18 @@ Scrolling through pi blinked. Two fixes came out of it: holding painting during What was known, besides the measurements below: -- **Where the apps are heading.** pi made fullscreen the default in 1.0.0 and has added only fullscreen features since (search, selection, scrollbar, jump to latest). Claude Code makes fullscreen the default for new users and always renders agent-view sessions fullscreen; classic is not deprecated. -- **Classic reprints.** pi regular clears the screen and the scrollback and reprints the whole transcript on every width change, most height changes and some updates (`tui-main-screen.js`, `ESC[2J ESC[H ESC[3J`). Claude Code classic replays its history on resize, with open reports of duplicated output (anthropics/claude-code#98411, #100728). webtty resizes the PTY on every browser resize and zoom, and a long transcript also outgrows the 10,000-line scrollback. +- **Where the apps are heading.** pi made fullscreen the default in 1.0.0 and has added only fullscreen features since (search, selection, scrollbar, jump to latest; pi-coding-agent `CHANGELOG.md` and `docs/settings.md`). Claude Code makes fullscreen the default for new users and always renders agent-view sessions fullscreen; classic is not deprecated (code.claude.com/docs/en/fullscreen). +- **Classic reprints.** pi regular clears the screen and the scrollback and reprints the whole transcript on every width change, most height changes and some updates (`tui-main-screen.js`, `ESC[2J ESC[H ESC[3J`). Claude Code classic replays its history on resize, with open reports of duplicated output (github.com/anthropics/claude-code/issues/98411 and 100728). webtty resizes the PTY on every browser resize and zoom, and a long transcript also outgrows the 10,000-line scrollback. - **What fullscreen costs.** Scrolling moves by the app's lines. Selection and search are the app's own; Shift-drag still gives webtty's selection, and Cmd+F does not see the conversation. - **Other programs.** vim (9.1+, when the terminal reports mode 2026), neovim (`termsync`, on by default), yazi, fzf, btop, lazygit, zellij and herdr wrap their redraws in synchronized updates; all run on the alternate screen. Only normal-screen output gains from ADR 066: shells, `tail -f`, build logs, `less -X` (git's pager), fzf's inline mode and the classic modes. -- **Other terminals.** Synchronized output with a timeout is near universal (Ghostty, foot, iTerm2 and xterm.js 1 s; kitty 2 s; Alacritty 150 ms; Windows Terminal ~100 ms), and no terminal makes the timeout a setting. Keeping a scrolled view on output is the default everywhere but xterm (Ghostty `scroll-to-bottom = keystroke, no-output`, kitty, WezTerm, Alacritty, xterm.js, gnome-terminal); a key returns to the bottom. +- **Other terminals.** Synchronized output with a timeout is near universal (Ghostty, foot, iTerm2 and xterm.js 1 s; kitty 2 s; Alacritty 150 ms; Windows Terminal ~100 ms), and no terminal makes the timeout a setting. Keeping a scrolled view on output is the default everywhere but xterm (Ghostty `scroll-to-bottom = keystroke, no-output`, kitty, WezTerm, Alacritty, xterm.js, gnome-terminal); a key returns to the bottom. The timeouts and defaults are from each terminal's source and documentation (Ghostty `termio/Thread.zig` and its config reference, kitty `screen.c`, alacritty/vte `ansi.rs`, foot `terminal.c`, xterm.js PR 5453, the xterm manual, gnome-terminal's schema). The scroll benchmark ([SPEC](../specs/scroll-benchmark.md)) measured four builds (neither fix, each alone, both) on ten scenarios with a trackpad, a mouse wheel and a TrackPoint: -- **Blinks.** Without ADR 067, pi fullscreen and Claude Code fullscreen blink with every input (up to 6.5 and 7.5 frames a run). With it, 0, for every app and input. No other scenario ever blinked. -- **Smoothness.** In the classic modes webtty paints 111 times a second with a trackpad, moving 1.5 px a frame; fullscreen apps paint about 11 times a second, a line (14 px) at a time, the same with or without the fixes. With a wheel or a TrackPoint both modes glide, classic in 13–31 px frames and fullscreen in the app's lines. +- **Blinks.** Before either fix, pi fullscreen and Claude Code fullscreen blink with every input (up to 1.5 and 6 frames a run; up to 6.5 and 7.5 with only ADR 066). With both fixes, 0 for every app and input; with ADR 067 alone, 0 except one blink in two runs of Claude Code fullscreen with a TrackPoint. No other scenario ever blinked. +- **Smoothness.** In the classic modes webtty paints 111 times a second with a trackpad, moving 1.5 px a frame; fullscreen apps paint about 11 times a second, a line (14 px) at a time, with or without the fixes, except for the torn frames ADR 067 removes. With a wheel or a TrackPoint both modes glide, classic in 13–31 px frames and fullscreen in the app's lines. - **The kept view.** While output streams, a scrolled-back view is pulled to the bottom on almost every frame without ADR 066, and never at rest with it, for all three inputs. -- **Back-to-back updates.** ADR 067's first version starved Claude Code fullscreen, which sends updates 1 ms apart, of paints. Painting an update as it ends after a held frame, as Ghostty's renderer wakes, fixed it. +- **Back-to-back updates.** ADR 067's first version starved Claude Code fullscreen, which sends updates 1 ms apart, of paints (7 a second with a TrackPoint, in a run before the reference run). Painting an update as it ends after a held frame, as Ghostty's renderer wakes, reduced that: 9.1–9.5 paints a second against 12.1–12.7 without the hold, with longer gaps (267–417 ms against 188–204 ms at the 90th percentile) and no torn frames. Holding still costs a fast TrackPoint scroll in Claude Code some smoothness. ## Decision diff --git a/docs/specs/scroll-benchmark.md b/docs/specs/scroll-benchmark.md index bcd19fd..2a29d5e 100644 --- a/docs/specs/scroll-benchmark.md +++ b/docs/specs/scroll-benchmark.md @@ -81,7 +81,7 @@ Commands that resume a personal pi or Claude Code session belong in your own sce |-------|--------|--------------------| | `trackpad` | 150 deltas of 2.5 px, 16 ms apart | A slow two-finger scroll: a small delta every frame | | `wheel` | 14 notches of 100 px, 100–120 ms apart | A mouse wheel spun by hand, as Chromium on macOS reports a notch | -| `trackpoint` | 8 light 4.000244 px steps ~150 ms apart, then a hard push of 15 fractional deltas up to 334.75 px, 29–45 ms apart | ADR 045's TrackPoint capture: its light step, its peak and its gaps (the push's ramp is synthetic) | +| `trackpoint` | 8 light 4.000244 px steps ~150 ms apart, then, after a 300 ms pause, a hard push of 15 fractional deltas up to 334.75 px, 29–45 ms apart | ADR 045's TrackPoint capture: its light step, its peak and its gaps (the push's ramp is synthetic) | ### Scenarios measured @@ -99,11 +99,11 @@ Commands that resume a personal pi or Claude Code session belong in your own sce ## Measures -- **Frames** come from `Page.startScreencast` at the page's own size, each reduced to a profile: the mean brightness of each pixel row over the left 600 px. Two frames *match* at the vertical shift with the least mean row error; an error above 3 is a different picture. -- **Blink**: a frame that is a different picture from both neighbours at any shift, while the neighbours match each other (error ≤ 3 and under half the smaller of the two). Smooth motion never counts, nor do three frames that all differ (an app redrawing); a half-drawn or flashed frame does. Calibrated at 0 on `seq`, and every reference blink was checked in its saved frames. +- **Frames** come from `Page.startScreencast` at the page's own size, each reduced to a profile: the mean of the red and green channels of each pixel row, over 600 px from x = 8. Two frames *match* at the vertical shift with the least mean row error, searched within ±60, ±150 or ±240 px for the trackpad, the wheel and the TrackPoint; an error above 3 is a different picture. +- **Blink**: a frame that is a different picture from both neighbours at any shift, while the neighbours match each other (error ≤ 3 and under half the smaller of the two). Smooth motion never counts, nor do three frames that all differ (an app redrawing); a half-drawn or flashed frame does. Calibrated at 0 on `seq`; while building the harness, blinks were checked in their saved frames (`--dump`), which showed real torn frames in pi and Claude Code fullscreen and the false hits the pitfalls below now guard against. - **Paints/s, gap p50/p90**: from a counter in the page that flags each animation frame in which the canvas was drawn, between the first wheel event and 400 ms after the last. - **Step px**: the mean shift of the frames that moved and still matched. In a fullscreen app it is the app's own step (a line is 14 px). -- **Stream test**: frames showing `stream.sh`'s bar are at the bottom. It counts them while scrolling back and at rest after, and counts at-rest frames that moved. +- **Stream test**: frames showing `stream.sh`'s bar are at the bottom, read from the red channel over 400 px from x = 8. It counts them while scrolling back and at rest after, and counts at-rest frames that moved or changed. ### Pitfalls the harness guards against @@ -111,14 +111,14 @@ Commands that resume a personal pi or Claude Code session belong in your own sce |---------|--------|-------| | Recording smaller than the page | A smooth step becomes a fractional shift; the resampling blur on high-contrast text reads as blinks | Record at `innerWidth × devicePixelRatio` | | Lines that look alike | A frame moved by a line matches the one before; motion reads as 0 | Test files and `stream.sh` vary their line lengths | -| A brightness test over the whole row | `stream.sh`'s 80-column bar is too dim averaged over 1400 px | The stream test reads the left 400 px | +| A brightness test over the whole row | `stream.sh` sizes its bar with `tput cols`, which can be narrower than the page (80 columns in the reference sessions): averaged over 1400 px it is too dim | The stream test reads the left 400 px, under the bar in any terminal 50 columns wide | | The wheel over the wrong pane | yazi scrolls the pane under the pointer (its preview, at the centre) | A scenario's `pointer` field | | Typing before the shell's prompt | The command is lost; nothing moves | A 1.5 s wait, and a scroll in which nothing moved runs again (`# retried` on stderr) | | Apps left running after a session is deleted | Hundreds of leaked processes load the machine | Fixed in the server: deleting a session hangs up its shell (SIGHUP) | ## Reference results -Run on 2026-10-15 in headless Chrome 151 (1400×657, DPR 1) on macOS, two runs each, medians shown. Four builds of this branch: **none** (before ADR 066 and 067, `58ea652`), **066** and **067** (one fix each), and **both** (`2886492`). The machine was loaded by background indexing during part of the run; paints per second of apps that scroll by lines vary by about ±20% between runs, the blink and stream counts do not. +Run on 2026-10-15 in headless Chrome 151 (1400×657, DPR 1) on macOS, two runs each, medians shown. Four builds of this branch: **none** (`58ea652`, before ADR 066 and 067, with the server's SIGHUP fix of `ede827a` applied so sessions do not leak), **066** and **067** (`2886492` with the other fix switched off), and **both** (`2886492`). Spotlight indexing loaded the machine during part of the run. Paints per second of apps that scroll by lines moved noticeably between this and two earlier runs of the day, so compare builds within one run; the blink and stream counts held steady. **Blinks per run** (trackpad / wheel / TrackPoint): @@ -135,10 +135,10 @@ Run on 2026-10-15 in headless Chrome 151 (1400×657, DPR 1) on macOS, two runs e | pi classic, Claude Code classic, seq | 111 / 67 / 61 | 1.5 / 13 / 31 px: webtty's pixel-smooth scrollback | | pi fullscreen | 11 / 45 / 18 | 14 / 17 / 72 px: pi's lines | | Claude Code fullscreen | 11 / 43 / 9.5 | 14 / 78 / 65 px: Claude Code's lines | -| vim, nvim | 11 / 51 / 19 and 9 / 45 / 17 | 3 lines a tick | +| vim, nvim | 11 / 51 / 19 and 9 / 45 / 17 | 3 lines a tick (42 px with a trackpad) | | yazi, less | 17 / 53 / 21 and 10 / 51 / 24 | — | -Classic modes scroll about ten times as often as fullscreen ones with a trackpad, by pixels instead of lines. Within a mode the four builds paint alike, except that 067 removes the blinked frames: Claude Code fullscreen with the wheel paints 43/s with both fixes against 56/s before, all of them complete. +Classic modes scroll about ten times as often as fullscreen ones with a trackpad, by pixels instead of lines. Within a mode the four builds paint alike, except where ADR 067 holds frames during an app's updates: Claude Code fullscreen with the wheel paints 43/s with both fixes against 56/s before, every paint now a complete update: the paints it drops are the torn frames (6 a run before) and other in-between states. **Stream test** (frames at the bottom while scrolling back / frames at the bottom at rest / at-rest frames that moved): @@ -150,7 +150,7 @@ Classic modes scroll about ten times as often as fullscreen ones with a trackpad Without ADR 066 the view is pulled back to the bottom on almost every frame; with it, only the frames before the scroll leaves the bottom show it. -**Back-to-back updates.** Claude Code fullscreen, scrolled hard with a TrackPoint, sends synchronized updates of 5 ms on the wire (median; 36 ms at most) 1 ms apart. Painting only on frames that find the mode off dropped it to 7 paints/s with 380 ms between paints at p90; painting an update as it ends after a held frame (ADR 067) brought it back to 9–11 paints/s, against 11–13 without the hold (this input varies most between runs), with no blinks. +**Back-to-back updates.** Claude Code fullscreen, scrolled hard with a TrackPoint, sends synchronized updates of 5 ms on the wire (median; 36 ms at most) 1 ms apart, traced over CDP. In a run before the reference run, painting only on frames that find the mode off dropped it to 7 paints/s with 380 ms between paints at p90. With an update painted as it ends after a held frame (ADR 067), the reference run gives 9.1 (067) and 9.5 (both) paints/s with 417 and 267 ms at p90, against 12.1–12.7 and 188–204 ms without the hold, and no torn frames: better than before, still a cost of the hold for this input. ## Features diff --git a/scripts/scroll-bench/bench.ts b/scripts/scroll-bench/bench.ts index 8bf2cf5..cbe5044 100644 --- a/scripts/scroll-bench/bench.ts +++ b/scripts/scroll-bench/bench.ts @@ -9,13 +9,13 @@ // // Start Chrome or Chromium yourself; the benchmark never launches or closes a browser: // -// "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +// "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \ // --remote-debugging-port=9333 --user-data-dir=/tmp/scroll-bench-chrome \ -// --window-size=1280,800 --no-first-run about:blank +// --window-size=1400,800 --no-first-run about:blank // // Start each webtty build to compare on its own port, then: // -// bun run bench:scroll --base before=http://localhost:3001 --base after=http://localhost:3002 \ +// bun run bench:scroll --base before=http://localhost:2401 --base after=http://localhost:2400 \ // --scenarios my-scenarios.json --inputs trackpad,wheel,trackpoint --runs 2 // // Scenarios live in a JSON file of your own (scenarios.example.json shows the fields), so From afd2858065aea297e70f5fa31d8f5340824b4857 Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:40:04 -0400 Subject: [PATCH 295/295] fix(client): a mouse wheel is told from a TrackPoint at any browser zoom, its deltas taken back to 100% and its notches compared by ratio --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 +- docs/specs/architecture.md | 2 +- scripts/check-coverage.ts | 1 + src/client/features/scroll/page-zoom.test.ts | 36 ++++++++++++ src/client/features/scroll/page-zoom.ts | 57 +++++++++++++++++++ .../features/scroll/wheel-classifier.test.ts | 11 ++++ .../features/scroll/wheel-classifier.ts | 21 ++++--- .../features/scroll/wheel-stream.test.ts | 16 ++++++ src/client/features/scroll/wheel-stream.ts | 11 +++- 9 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 src/client/features/scroll/page-zoom.test.ts create mode 100644 src/client/features/scroll/page-zoom.ts diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index d9dcb72..8be67d8 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -48,7 +48,7 @@ In detail: - The first deltas of a trackpad gesture come after a pause, so they glide over a frame or two; the second short gap in a row ends that. - End-to-end in headless Chrome (wheel notches, a 16 ms trackpad stream and the replayed TrackPoint capture; shell scrollback, a mouse-tracking app and an alternate-screen app): tick and arrow totals match one per cell height of travel, trackpad scrolling paints in the frame its event arrives, and reversals, output while gliding and the ends of scrollback behave. - The classifier needs a couple of events to settle; until then a notch after trackpad scrolling gets the sparse glide instead of the wheel glide. A trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. -- VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers, and its notches get the sparse glide instead of the wheel glide. +- Chromium divides a wheel notch's pixels by the page zoom, so at 110% a 100 px notch arrives as 90.909… px and no longer looks like a notch. VS Code multiplies the deltas back by its own zoom factor before classifying them. A page cannot read the zoom directly, but its window's outer width is in screen pixels and its inner width in CSS pixels: `WheelStream` takes their ratio, snapped to Chromium's preset zoom levels (`features/scroll/page-zoom.ts`; 1 when it is near none, as with a side panel open), and classifies the deltas multiplied by it. The classifier also compares two notches by their ratio, not a floating-point remainder, which left 181.818… / 90.909… short of a multiple. Replaying notches of 100, 200 and 300 px at every preset from 50% to 200% went from up to 3 of 5 events taken for a TrackPoint's (at 67, 75, 90, 110, 150 and 175%) to none, and fractional trackpad deltas are still a trackpad at every zoom (2026-10-15). ## Considered Options diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index ced6698..392fa4a 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -150,7 +150,7 @@ src/ find/ find-bar.ts (view), find-format.ts (util), find-keys.ts (util; takes the shortcut lookup, never imports keyboard/), find-feature.ts keyboard/ key-bindings.ts, shortcut-map.ts (was shortcuts.ts), shortcut-commands.ts, screen-clear.ts (Cmd+K), font-zoom.ts, key-dispatcher.ts (feature), font-zoom-feature.ts compose/ composer.ts (view + dispose), composer-geometry.ts (util) - scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, scroll-feature.ts + scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, page-zoom.ts, scroll-feature.ts kitty/ protocol/ apc-splitter.ts, apc-command.ts, chunk-assembler.ts, kitty-reply.ts, png-size.ts, cell-geometry.ts, control-scanner.ts graphics/ image-store.ts, image-decoder.ts, placements.ts, placement-geometry.ts, kitty-limits.ts, diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index a14fcd4..7d3a605 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -61,6 +61,7 @@ export const PURE_MODULES: readonly string[] = [ 'src/client/features/kitty/protocol/kitty-reply.ts', 'src/client/features/kitty/protocol/png-size.ts', 'src/client/features/paste/non-text-paste.ts', + 'src/client/features/scroll/page-zoom.ts', 'src/client/features/scroll/scroll-split.ts', 'src/client/layout/edge-rects.ts', 'src/client/layout/fit-insets.ts', diff --git a/src/client/features/scroll/page-zoom.test.ts b/src/client/features/scroll/page-zoom.test.ts new file mode 100644 index 0000000..224c959 --- /dev/null +++ b/src/client/features/scroll/page-zoom.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { pageZoom, snapZoom, ZOOM_LEVELS } from './page-zoom'; + +describe('snapZoom', () => { + test.each([ + [1, 1], + [1.1, 1.1], + [1.105, 1.1], + [0.667, 2 / 3], + [1 / 3, 1 / 3], + [1.75, 1.75], + [5, 5], + // Near no preset (a side panel open), or nonsense: 1 + [1.3, 1], + [0.6, 1], + [0, 1], + [Number.NaN, 1], + ])('%p is %p', (ratio, zoom) => expect(snapZoom(ratio)).toBe(zoom)); + + test('every preset snaps to itself', () => { + for (const level of ZOOM_LEVELS) expect(snapZoom(level)).toBe(level); + }); +}); + +describe('pageZoom', () => { + test('is the outer width over the inner width, snapped to a preset', () => { + expect(pageZoom({ outerWidth: 1400, innerWidth: 1273 })).toBe(1.1); + expect(pageZoom({ outerWidth: 1400, innerWidth: 1400 })).toBe(1); + expect(pageZoom({ outerWidth: 1400, innerWidth: 2100 })).toBe(2 / 3); + }); + + test('is 1 without the sizes, as outside a browser', () => { + expect(pageZoom({})).toBe(1); + expect(pageZoom({ outerWidth: 0, innerWidth: 800 })).toBe(1); + }); +}); diff --git a/src/client/features/scroll/page-zoom.ts b/src/client/features/scroll/page-zoom.ts new file mode 100644 index 0000000..f634ccd --- /dev/null +++ b/src/client/features/scroll/page-zoom.ts @@ -0,0 +1,57 @@ +// The browser's page zoom, as far as a page can tell it (util, pure, ADR 045): Chromium +// divides a mouse wheel notch's pixels by the zoom, so at 110% a notch of 100 px arrives as +// 90.909… and no longer looks like a notch. VS Code multiplies the deltas back by its own +// zoom factor before classifying them; a page has no such value, but the window's outer +// width is in screen pixels and its inner width in CSS pixels, so their ratio is the zoom. + +/** Chromium's preset zoom levels (`kPresetZoomFactors`), which the menu and Cmd+/- step through. */ +export const ZOOM_LEVELS = [ + 0.25, + 1 / 3, + 0.5, + 2 / 3, + 0.75, + 0.8, + 0.9, + 1, + 1.1, + 1.25, + 1.5, + 1.75, + 2, + 2.5, + 3, + 4, + 5, +] as const; + +/** + * How far the measured ratio may be from a preset, relative: the outer width can include a + * window frame or a scrollbar of a few pixels. + */ +const TOLERANCE = 0.02; + +/** The preset zoom level `ratio` stands for, or 1 when it is near none (a side panel open). */ +export function snapZoom(ratio: number): number { + if (!Number.isFinite(ratio) || ratio <= 0) return 1; + let best = 1; + let bestError = TOLERANCE; + for (const level of ZOOM_LEVELS) { + const error = Math.abs(ratio / level - 1); + if (error < bestError) { + best = level; + bestError = error; + } + } + return best; +} + +/** The window sizes the zoom is read from. */ +export type ZoomWindow = Partial<Pick<Window, 'outerWidth' | 'innerWidth'>>; + +/** The page zoom of `win`, 1 when its sizes are missing. */ +export function pageZoom(win: ZoomWindow): number { + const { outerWidth, innerWidth } = win; + if (!outerWidth || !innerWidth) return 1; + return snapZoom(outerWidth / innerWidth); +} diff --git a/src/client/features/scroll/wheel-classifier.test.ts b/src/client/features/scroll/wheel-classifier.test.ts index 74c5105..33e6c63 100644 --- a/src/client/features/scroll/wheel-classifier.test.ts +++ b/src/client/features/scroll/wheel-classifier.test.ts @@ -41,6 +41,17 @@ describe('WheelClassifier', () => { ).toBe(false); }); + test('notches that are multiples of each other count, though floating point leaves a remainder', () => { + // 100 and 200 px notches at a 110% page zoom + expect( + feed([ + [0, 100 / 1.1], + [0, 200 / 1.1], + [0, 100 / 1.1], + ]), + ).toBe(true); + }); + test('no events is not a wheel', () => { expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); }); diff --git a/src/client/features/scroll/wheel-classifier.ts b/src/client/features/scroll/wheel-classifier.ts index ee1c102..da320ba 100644 --- a/src/client/features/scroll/wheel-classifier.ts +++ b/src/client/features/scroll/wheel-classifier.ts @@ -39,17 +39,24 @@ export class WheelClassifier { if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. const prev = this.previous; - if (prev) { - const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; - const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; - const minX = Math.max(Math.min(ax, px), 1); - const minY = Math.max(Math.min(ay, py), 1); - if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; - } + if (prev && isMultiple(deltaX, prev.deltaX) && isMultiple(deltaY, prev.deltaY)) score -= 0.5; return Math.min(Math.max(score, 0), 1); } } +/** + * Whether the larger of two deltas is a whole multiple of the smaller (taken as at least 1), + * by their ratio: at a browser zoom other than 100%, Chromium divides a notch's pixels by the + * zoom, and notches of 90.909… and 181.818… px leave a remainder in floating point though + * one is twice the other. (webtty: VS Code compares remainders, after multiplying by its own + * zoom factor, which a page cannot read.) + */ +function isMultiple(a: number, b: number): boolean { + const low = Math.max(Math.min(Math.abs(a), Math.abs(b)), 1); + const ratio = Math.max(Math.abs(a), Math.abs(b)) / low; + return Math.abs(ratio - Math.round(ratio)) < 0.01; +} + function isAlmostInt(value: number): boolean { return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; } diff --git a/src/client/features/scroll/wheel-stream.test.ts b/src/client/features/scroll/wheel-stream.test.ts index 8202ef4..3eaea3b 100644 --- a/src/client/features/scroll/wheel-stream.test.ts +++ b/src/client/features/scroll/wheel-stream.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { easeOutCubic, linear } from '../../vendor/ghostty-web'; import { STREAM_OMEGA, WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; describe('WheelStream.forApp', () => { @@ -128,3 +129,18 @@ describe('WheelStream classifies each event once', () => { expect(s.forApp(second)).toEqual(pace); }); }); + +describe('WheelStream at a page zoom', () => { + const at = (deltaY: number, timeStamp: number) => + ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; + /** Accelerating notches of 100, 200 and 300 px as Chromium reports them at `zoom`. */ + const lastPace = (s: WheelStream, zoom: number) => + [1, 2, 3, 2].map((n, i) => s.forScrollback(at((-100 * n) / zoom, 1000 + i * 300))).at(-1); + + test('a wheel at 110% eases out as a wheel, once the deltas are taken back to 100%', () => { + const zoomed = new WheelStream({ outerWidth: 1400, innerWidth: 1273 }); + expect(lastPace(zoomed, 1.1)).toEqual({ duration: WHEEL_GLIDE_MS, ease: easeOutCubic }); + // Without the zoom, the same deltas glide linearly, as a TrackPoint's sparse stream + expect(lastPace(new WheelStream({}), 1.1)?.ease).toBe(linear); + }); +}); diff --git a/src/client/features/scroll/wheel-stream.ts b/src/client/features/scroll/wheel-stream.ts index 2c66dc1..47e95ed 100644 --- a/src/client/features/scroll/wheel-stream.ts +++ b/src/client/features/scroll/wheel-stream.ts @@ -18,6 +18,7 @@ * - Trackpad deltas, a frame apart, move at once. */ import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from '../../vendor/ghostty-web'; +import { pageZoom, type ZoomWindow } from './page-zoom'; import { WheelClassifier } from './wheel-classifier'; /** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ @@ -47,8 +48,8 @@ export const FAST_STREAM_MS = 60; /** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ export const STREAM_OMEGA = 28; -/** The browser object the stream asks whether the OS wants less motion. */ -export interface WheelStreamDeps { +/** The browser object the stream asks whether the OS wants less motion, and the page zoom. */ +export interface WheelStreamDeps extends ZoomWindow { /** `window.matchMedia`; without one, motion is full. */ matchMedia?(query: string): { matches: boolean }; } @@ -114,7 +115,11 @@ export class WheelStream { const dir = Math.sign(e.deltaY); const sameDir = dir === this.lastDir; this.lastDir = dir; - if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); + // In the deltas' size before the page zoom divided them, as VS Code classifies them + if (e.deltaMode === 0) { + const zoom = pageZoom(this.deps); + this.classifier.accept((e.deltaX || 0) * zoom, e.deltaY * zoom); + } const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); // A stream turns sparse on its second long gap in a row, as it stops being sparse on // its second short one: the first long gap is the pause before any gesture, and